lcode-cli 0.22.0__tar.gz → 0.24.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 (155) hide show
  1. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/CHANGELOG.md +36 -1
  2. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/PKG-INFO +1 -1
  3. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/automation.md +3 -2
  4. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/configuration.md +1 -0
  5. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/how-it-works.md +19 -0
  6. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/quickstart.md +1 -1
  7. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/usage.md +13 -2
  8. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/__init__.py +1 -1
  9. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/acp.py +6 -1
  10. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/action.py +4 -2
  11. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/agent.py +109 -19
  12. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/api.py +5 -4
  13. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/cli.py +4 -0
  14. lcode_cli-0.24.0/src/lcode/clipboard.py +139 -0
  15. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/config.py +1 -0
  16. lcode_cli-0.24.0/src/lcode/repeats.py +104 -0
  17. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/repl.py +22 -1
  18. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/subagents.py +8 -3
  19. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_agent.py +17 -0
  20. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_api.py +8 -0
  21. lcode_cli-0.24.0/tests/test_clipboard.py +118 -0
  22. lcode_cli-0.24.0/tests/test_repeats.py +88 -0
  23. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_subagents.py +14 -0
  24. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_vision.py +31 -0
  25. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/.editorconfig +0 -0
  26. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/.github/CODEOWNERS +0 -0
  27. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  28. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  29. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  30. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
  31. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  32. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/.github/dependabot.yml +0 -0
  33. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/.github/workflows/ci.yml +0 -0
  34. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/.github/workflows/docs.yml +0 -0
  35. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/.github/workflows/release.yml +0 -0
  36. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/.gitignore +0 -0
  37. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/.pre-commit-config.yaml +0 -0
  38. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/AGENTS.md +0 -0
  39. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/CODE_OF_CONDUCT.md +0 -0
  40. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/CONTRIBUTING.md +0 -0
  41. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/LICENSE +0 -0
  42. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/README.md +0 -0
  43. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/SECURITY.md +0 -0
  44. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/action.yml +0 -0
  45. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/agents.md +0 -0
  46. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/assets/demo.svg +0 -0
  47. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/assets/extra.css +0 -0
  48. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/assets/logo.svg +0 -0
  49. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/assets/social-preview.png +0 -0
  50. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/bench.md +0 -0
  51. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/changelog.md +0 -0
  52. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/commands.md +0 -0
  53. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/contributing.md +0 -0
  54. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/editors.md +0 -0
  55. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/git.md +0 -0
  56. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/hooks.md +0 -0
  57. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/index.md +0 -0
  58. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/installation.md +0 -0
  59. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/lsp.md +0 -0
  60. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/mcp.md +0 -0
  61. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/memory.md +0 -0
  62. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/models.md +0 -0
  63. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/sandbox.md +0 -0
  64. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/search.md +0 -0
  65. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/servers.md +0 -0
  66. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/docs/troubleshooting.md +0 -0
  67. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/examples/README.md +0 -0
  68. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/examples/api/fix_tests.py +0 -0
  69. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/examples/commands/review.md +0 -0
  70. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/examples/skills/commit-message/SKILL.md +0 -0
  71. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
  72. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/install.sh +0 -0
  73. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/mkdocs.yml +0 -0
  74. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/overrides/main.html +0 -0
  75. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/pyproject.toml +0 -0
  76. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/scripts/record_demo.py +0 -0
  77. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/scripts/social-preview.html +0 -0
  78. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/__main__.py +0 -0
  79. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/backends.py +0 -0
  80. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/bench.py +0 -0
  81. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/bench_backend.py +0 -0
  82. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/catalog.py +0 -0
  83. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/checkpoints.py +0 -0
  84. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/codesearch.py +0 -0
  85. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/context.py +0 -0
  86. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/extensions.py +0 -0
  87. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/frontmatter.py +0 -0
  88. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/gitflow.py +0 -0
  89. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/hardware.py +0 -0
  90. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/hooks.py +0 -0
  91. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/jobs.py +0 -0
  92. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/limits.py +0 -0
  93. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/lsp.py +0 -0
  94. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/mcp/__init__.py +0 -0
  95. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/mcp/auth.py +0 -0
  96. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/mcp/catalog.py +0 -0
  97. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/mcp/catalog.toml +0 -0
  98. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/mcp/client.py +0 -0
  99. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/mcp/commands.py +0 -0
  100. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/mcp/config.py +0 -0
  101. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/mcp/http.py +0 -0
  102. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/mcp/manager.py +0 -0
  103. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/mcp/protocol.py +0 -0
  104. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/mcp/server.py +0 -0
  105. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/mcp/stdio.py +0 -0
  106. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/memory.py +0 -0
  107. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/models.toml +0 -0
  108. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/notify.py +0 -0
  109. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/ollama.py +0 -0
  110. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/permissions.py +0 -0
  111. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/planning.py +0 -0
  112. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/render.py +0 -0
  113. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/repomap.py +0 -0
  114. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/sandbox.py +0 -0
  115. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/servers/__init__.py +0 -0
  116. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/servers/encord.py +0 -0
  117. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/servers/valohai.py +0 -0
  118. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/servers/xai.py +0 -0
  119. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/sessions.py +0 -0
  120. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/tools.py +0 -0
  121. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/update.py +0 -0
  122. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/vision.py +0 -0
  123. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/src/lcode/web.py +0 -0
  124. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/conftest.py +0 -0
  125. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/fake_lsp_server.py +0 -0
  126. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/fake_mcp_server.py +0 -0
  127. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_acp.py +0 -0
  128. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_action.py +0 -0
  129. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_backends.py +0 -0
  130. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_bench.py +0 -0
  131. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_catalog.py +0 -0
  132. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_checkpoints.py +0 -0
  133. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_cli.py +0 -0
  134. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_codesearch.py +0 -0
  135. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_config.py +0 -0
  136. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_context.py +0 -0
  137. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_context_picker.py +0 -0
  138. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_extensions.py +0 -0
  139. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_gitflow.py +0 -0
  140. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_hooks.py +0 -0
  141. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_jobs.py +0 -0
  142. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_lsp.py +0 -0
  143. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_mcp.py +0 -0
  144. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_memory.py +0 -0
  145. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_permissions.py +0 -0
  146. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_plan_mode.py +0 -0
  147. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_repomap.py +0 -0
  148. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_sandbox.py +0 -0
  149. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_servers.py +0 -0
  150. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_sessions.py +0 -0
  151. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_tools.py +0 -0
  152. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_update.py +0 -0
  153. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_web.py +0 -0
  154. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/tests/test_xai.py +0 -0
  155. {lcode_cli-0.22.0 → lcode_cli-0.24.0}/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.24.0] - 2026-10-10
10
+
11
+ ### Added
12
+
13
+ - Ctrl+V in lcode's prompt pastes a screenshot: lcode reads the clipboard (with `wl-paste` on
14
+ Wayland, `xclip` on X11, `pngpaste` or `osascript` on macOS), saves the image and attaches it as
15
+ `@path`. Other clipboard content is pasted as text. `lcode doctor` shows whether it works here.
16
+ - Image paths in a message are attached without `@`: absolute and `~` paths, quoted paths with
17
+ spaces, and `file://` paths dragged into the terminal. `@"path with spaces"` works for any file.
18
+
19
+ ### Fixed
20
+
21
+ - An image path that doesn't exist is no longer ignored: lcode says so, and tells the model it can't
22
+ see that image, so it asks instead of guessing what the picture shows. See
23
+ [Images](https://nasser1941.github.io/lcode/usage/#images).
24
+
25
+ ## [0.23.0] - 2026-10-10
26
+
27
+ ### Added
28
+
29
+ - lcode notices when the model goes round in circles, making the same tool call with the same result
30
+ again and again, as small models sometimes do. The third time in a request (the second, for a call
31
+ that failed) the result tells the model so; two more, and lcode ends the request (status `loop`,
32
+ exit code `4` with `lcode -p`). Results that change, and checks run after an edit, don't count.
33
+ `repeat_limit` changes the threshold; `0` turns it off. See
34
+ [When the model repeats itself](https://nasser1941.github.io/lcode/how-it-works/#when-the-model-repeats-itself).
35
+
36
+ ### Fixed
37
+
38
+ - A tool call Ollama can't parse because its XML is broken (`XML syntax error … unexpected EOF`, from
39
+ models such as Qwen3.6 that write their calls in XML) is now sent back to the model to make again,
40
+ like a call with broken JSON, instead of ending the request with an error.
41
+
9
42
  ## [0.22.0] - 2026-10-06
10
43
 
11
44
  ### Added
@@ -491,7 +524,9 @@ First public release.
491
524
  - `AGENTS.md` project instructions and `/init` to generate them.
492
525
  - One-line installer for Linux and macOS.
493
526
 
494
- [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.22.0...HEAD
527
+ [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.24.0...HEAD
528
+ [0.24.0]: https://github.com/nasser1941/lcode/compare/v0.23.0...v0.24.0
529
+ [0.23.0]: https://github.com/nasser1941/lcode/compare/v0.22.0...v0.23.0
495
530
  [0.22.0]: https://github.com/nasser1941/lcode/compare/v0.21.0...v0.22.0
496
531
  [0.21.0]: https://github.com/nasser1941/lcode/compare/v0.20.0...v0.21.0
497
532
  [0.20.0]: https://github.com/nasser1941/lcode/compare/v0.19.0...v0.20.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.22.0
3
+ Version: 0.24.0
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/
@@ -23,7 +23,8 @@ lcode -p "Explain the retry logic" --output stream-json --max-steps 20
23
23
  | `--allowed-tools LIST` | Only these tools, e.g. `"read_file,grep,glob,bash"` |
24
24
 
25
25
  lcode's usual output goes to stderr, so stdout holds only the JSON. The exit code says how it
26
- went: `0` success, `1` an error, `3` stopped at `--max-steps`, `130` interrupted.
26
+ went: `0` success, `1` an error, `3` stopped at `--max-steps`, `4` stopped because the model
27
+ [kept repeating itself](how-it-works.md#when-the-model-repeats-itself), `130` interrupted.
27
28
 
28
29
  ### The result
29
30
 
@@ -49,7 +50,7 @@ went: `0` success, `1` an error, `3` stopped at `--max-steps`, `130` interrupted
49
50
 
50
51
  | Field | |
51
52
  |---|---|
52
- | `status` | `success`, `error`, `max_steps` or `interrupted` |
53
+ | `status` | `success`, `error`, `max_steps`, `loop` (the model kept repeating the same call) or `interrupted` |
53
54
  | `text` | The model's final answer |
54
55
  | `tool_calls` | Every tool call, with its arguments and output (the first 4,000 characters). `error` is true when the call failed or wasn't allowed |
55
56
  | `files_changed` | Files the request added (`A`), modified (`M`) or deleted (`D`), shell commands included |
@@ -42,6 +42,7 @@ lcode config path # print the file location
42
42
  | `max_parallel_agents` | `1` | Subagents that run at the same time; more needs `OLLAMA_NUM_PARALLEL` on the Ollama server ([details](agents.md#several-at-once)) |
43
43
  | `notify` | `true` | A desktop [notification](usage.md#notifications) when a long request is done or waits for your answer |
44
44
  | `notify_after` | `30` | Seconds a request runs before it notifies |
45
+ | `repeat_limit` | `3` | Identical tool calls with identical results before lcode [steps in](how-it-works.md#when-the-model-repeats-itself); `0` turns it off |
45
46
  | `checkpoints` | `true` | Save a checkpoint before the model changes files, so [`/undo`](usage.md#undo-and-checkpoints) can restore them |
46
47
 
47
48
  Besides these settings, `config.toml` can hold [hooks](hooks.md) (`[[hooks]]`) and
@@ -29,6 +29,25 @@ All tools return text, and failures come back to the model as `Error: …` messa
29
29
  crashing the session, so the model can correct itself (for example re-read a file when an edit
30
30
  didn't match).
31
31
 
32
+ ### When the model repeats itself
33
+
34
+ Small models sometimes fall into a loop: they make a call, get a result, and make the very same call
35
+ again, and again. lcode counts, within a request, the calls that repeat an earlier one exactly: the
36
+ same tool, the same arguments and the same result.
37
+
38
+ - **At the third one** (the second, when the call failed) the result gets a note: the call returned
39
+ the same thing several times, so the model should use what it got, try something different, or
40
+ tell you what's blocking it.
41
+ - **Two more,** and lcode ends the request with
42
+ `Stopped: the model kept making the same call with the same result`. The JSON output's status is
43
+ `loop`.
44
+
45
+ A result that changes isn't a repeat, so a test that's run again after a fix, or a background job
46
+ that's checked while it runs, never adds up. A call that may have changed something, like an edit or
47
+ a shell command that isn't read-only, starts the count again for the other calls: checking the build
48
+ after each edit is fine. Change the threshold with `lcode config set repeat_limit 5`, or turn it off
49
+ with `0`.
50
+
32
51
  ## Subagents
33
52
 
34
53
  The `agent` tool starts a [subagent](agents.md): another agent loop with its own conversation, the
@@ -12,7 +12,7 @@ lcode
12
12
 
13
13
  ```text
14
14
  ╭──────────────────────────────────────────────╮
15
- │ lcode v0.22.0 — local coding agent │
15
+ │ lcode v0.24.0 — local coding agent │
16
16
  │ │
17
17
  │ model lcode-qwen3.6-35b │
18
18
  │ context 256K tokens │
@@ -154,8 +154,19 @@ lcode can look at screenshots, mockups, diagrams and photos:
154
154
  ❯ make the settings page match @design/settings.png
155
155
  ```
156
156
 
157
- - Attach an image with `@path`, like a file. lcode describes it in detail, with all visible text
158
- transcribed and with your question in mind, and the model works from that description.
157
+ - **Paste a screenshot with ++ctrl+v++.** A terminal can only paste text, so lcode reads the
158
+ clipboard itself, saves the image in its state folder (`~/.local/state/lcode/pasted/`, the last
159
+ 50) and puts `@path` in the prompt. Anything else on the clipboard is pasted as text. It needs
160
+ `wl-clipboard` on Wayland or `xclip` on X11 (`sudo apt install wl-clipboard`); on macOS it works
161
+ as is, and faster with `brew install pngpaste`. `lcode doctor` shows whether it works.
162
+ - Attach an image with `@path`, like a file, or just write its path: `/tmp/shot.png`,
163
+ `~/Pictures/bug.png`, `'./shots/My Shot.png'` and `file://` paths dragged into the terminal are
164
+ attached too. lcode describes the image in detail, with all visible text transcribed and with your
165
+ question in mind, and the model works from that description.
166
+ - **A path to an image that doesn't exist** is reported right away
167
+ (`/tmp/pasted-image-.png doesn't exist, so the model can't see it`), and the model is told it
168
+ can't see the image, so it asks instead of guessing what it shows. A bare name such as `logo.png`
169
+ may be a file you want created, so it's left alone.
159
170
  - The model can open images itself with the `view_image` tool, for example a screenshot a test wrote.
160
171
  - Screenshots that [MCP](mcp.md) tools return, such as the Playwright browser's, are described too.
161
172
 
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.22.0"
6
+ __version__ = "0.24.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
@@ -48,7 +48,12 @@ KINDS = {
48
48
  "bash": "execute", "web_search": "fetch", "web_fetch": "fetch", "agent": "think", "todo_write": "think",
49
49
  "present_plan": "switch_mode", "bash_output": "read", "bash_stop": "execute",
50
50
  } # fmt: skip
51
- STOP = {"success": "end_turn", "max_steps": "max_turn_requests", "interrupted": "cancelled"}
51
+ STOP = {
52
+ "success": "end_turn",
53
+ "max_steps": "max_turn_requests",
54
+ "loop": "max_turn_requests",
55
+ "interrupted": "cancelled",
56
+ }
52
57
  MAX_INLINE = 200_000 # bytes of a file the editor attaches that go into the prompt
53
58
  METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL_ERROR, NOT_FOUND = -32601, -32602, -32603, -32002
54
59
 
@@ -297,7 +297,7 @@ def answer(hub: GitHub, task: Task, inputs: Inputs, cwd: Path) -> None:
297
297
  )
298
298
  with open_session(inputs, cwd) as session:
299
299
  result = session.run(prompt)
300
- if result.status not in ("success", "max_steps"):
300
+ if result.status not in ("success", "max_steps", "loop"):
301
301
  raise ActionError(result.error or result.status)
302
302
  reply = result.text.strip() or "(no answer)"
303
303
  changed = git(cwd, "status", "--porcelain").strip()
@@ -336,6 +336,8 @@ def answer(hub: GitHub, task: Task, inputs: Inputs, cwd: Path) -> None:
336
336
  reply += f"\n\n{link}"
337
337
  if result.status == "max_steps":
338
338
  reply += f"\n\n(Stopped after {inputs.max_steps} steps.)"
339
+ elif result.status == "loop":
340
+ reply += "\n\n(Stopped: the model kept repeating the same call.)"
339
341
  hub.comment(task.number, reply[:MAX_COMMENT] + footer(result))
340
342
 
341
343
 
@@ -349,7 +351,7 @@ def review(hub: GitHub, task: Task, inputs: Inputs, cwd: Path) -> None:
349
351
  prompt = gitflow.review_prompt(session.agent, base)
350
352
  session.agent.no_changes = "This is a review, so nothing can be changed: report what should change instead."
351
353
  result = session.run(prompt)
352
- if result.status not in ("success", "max_steps"):
354
+ if result.status not in ("success", "max_steps", "loop"):
353
355
  raise ActionError(result.error or result.status)
354
356
  hub.comment(task.number, f"**lcode review**\n\n{result.text.strip()[:MAX_COMMENT]}{footer(result)}")
355
357
 
@@ -11,6 +11,7 @@ import shutil
11
11
  import subprocess
12
12
  import threading
13
13
  import time
14
+ import urllib.parse
14
15
  import uuid
15
16
  from collections.abc import Callable
16
17
  from dataclasses import dataclass
@@ -32,6 +33,7 @@ from lcode.mcp import McpManager
32
33
  from lcode.ollama import Ollama, OllamaError
33
34
  from lcode.permissions import Permissions
34
35
  from lcode.render import MarkdownStreamer
36
+ from lcode.repeats import Repeats
35
37
  from lcode.sandbox import Sandbox, SandboxError, project_root
36
38
  from lcode.tools import (
37
39
  LSP_SCHEMA,
@@ -57,9 +59,20 @@ MAX_STEPS_PER_TURN = 150
57
59
  EVENT_OUTPUT = 4000 # characters of a tool's output in an event
58
60
  # How a tool result starts when the call failed or wasn't allowed (an "error" in events and results).
59
61
  REFUSED = ("Error:", "A deny rule", "The user denied", "This needs the user's permission")
60
- MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren't valid JSON # safety cap on tool-call iterations for one request
62
+ MAX_MALFORMED_CALL_RETRIES = 2 # Ollama rejects tool calls whose arguments aren't valid JSON
63
+ # How Ollama reports a tool call it couldn't parse (Qwen3.6's calls are XML)
64
+ MALFORMED_CALL = ("error parsing tool call", "XML syntax error")
61
65
  AUTO_COMPACT_RATIO = 0.85 # summarize the history when the context is this full
62
66
  PRUNED_ENOUGH = 0.6 # after pruning old tool output, summarize only if the context is still this full
67
+ # @path, @"path with spaces" or @'path'
68
+ MENTION = re.compile(r"""(?<!\S)@(?:"([^"\n]+)"|'([^'\n]+)'|([\w./~\-]+))""")
69
+ # An image path written in a message: in quotes, or without spaces (a space escaped as "\ " is fine), or as a
70
+ # file:// address, the way terminals paste a dragged file.
71
+ IMAGE_PATH = re.compile(
72
+ r"""(['"])((?:file://)?[^'"\n]+?\.(?:png|jpe?g|webp|gif))\1"""
73
+ r"""|(?<![^\s(])((?:[^\s\\'"(]|\\ )+?\.(?:png|jpe?g|webp|gif))(?=[\s,;:!?)\]]|\.(?:\s|$)|$)""",
74
+ re.IGNORECASE,
75
+ )
63
76
  PROJECT_FILES = ("AGENTS.md", "LCODE.md", "CLAUDE.md")
64
77
 
65
78
  SYSTEM_PROMPT = """You are lcode, an autonomous software-engineering agent running in the user's terminal on their own machine, powered by the local model {model}. You help the user understand codebases, answer questions about code, write scripts (Python by default), fix bugs, refactor, and run commands.
@@ -216,6 +229,7 @@ class Settings:
216
229
  notify: bool = False # desktop notifications after long requests (lcode.notify)
217
230
  notify_after: int = 30 # seconds: a request this long notifies when it's done or waits for an answer
218
231
  max_parallel_agents: int = 1
232
+ repeat_limit: int = 3 # identical calls with identical results before the model is told (lcode.repeats)
219
233
 
220
234
 
221
235
  class Agent:
@@ -267,7 +281,8 @@ class Agent:
267
281
  # Decides on a presented plan instead of asking in the terminal: "ask", "auto-edit", or why not.
268
282
  self.plan_review: Callable[[str, str], str] | None = None
269
283
  self.max_steps = MAX_STEPS_PER_TURN
270
- self.turn_status = "" # how the last request ended: success, max_steps
284
+ self.turn_status = "" # how the last request ended: success, max_steps, loop
285
+ self.repeats = Repeats(settings.repeat_limit) # notices when the model repeats itself
271
286
  self.response = None # the streaming response, so another thread can abort it
272
287
  self.ctx_used = 0
273
288
  self.last_speed = 0.0
@@ -809,7 +824,9 @@ class Agent:
809
824
  self.turn_status = "running"
810
825
  note = planning.NOTE if self.planning() and self.allowed_tools is None else ""
811
826
  self.messages.append({"role": "user", "content": self.expand_mentions(user_text) + note})
827
+ self.repeats.reset()
812
828
  malformed = 0
829
+ looping = "" # the call the model kept repeating, when lcode stopped the request for it
813
830
  for step in range(self.max_steps):
814
831
  if self.cancel is not None and self.cancel.is_set():
815
832
  raise KeyboardInterrupt
@@ -834,17 +851,22 @@ class Agent:
834
851
  }
835
852
  )
836
853
  except OllamaError as e:
837
- if "error parsing tool call" not in str(e) or malformed >= MAX_MALFORMED_CALL_RETRIES:
854
+ if not any(m in str(e) for m in MALFORMED_CALL) or malformed >= MAX_MALFORMED_CALL_RETRIES:
838
855
  raise
839
856
  malformed += 1
840
- reason = str(e).rsplit("err=", 1)[-1].strip() if "err=" in str(e) else "invalid JSON"
857
+ if "err=" in str(e):
858
+ reason = str(e).rsplit("err=", 1)[-1].strip()
859
+ elif "XML syntax error" in str(e):
860
+ reason = str(e).split("XML syntax error", 1)[1].strip(" :") or "invalid XML"
861
+ else:
862
+ reason = "invalid JSON"
841
863
  self.console.print("[yellow]The model wrote a malformed tool call; asking it to try again.[/]")
842
864
  self.messages.append(
843
865
  {
844
866
  "role": "user",
845
- "content": f"[lcode] Your last tool call could not be parsed ({reason}): its arguments "
846
- "must be one complete, valid JSON object. Make the call again. If it writes a file, "
847
- "make sure the whole content is included and properly escaped.",
867
+ "content": f"[lcode] Your last tool call could not be parsed ({reason}): it must be "
868
+ "complete and well-formed, with every argument closed. Make the call again. If it writes "
869
+ "a file, make sure the whole content is included and properly escaped.",
848
870
  }
849
871
  )
850
872
  continue
@@ -892,6 +914,15 @@ class Agent:
892
914
  self.console.print(Text(f" {result[:300]}", style="red"))
893
915
  elif name in ("read_file", "grep", "glob", "list_dir"):
894
916
  self.console.print(Text(f" ⎿ {result.count(chr(10)) + 1} line(s)", style="dim"))
917
+ verdict = self.repeats.check(name, args, result)
918
+ if verdict.note:
919
+ result += verdict.note
920
+ what = "stopping the request" if verdict.stop else "told the model"
921
+ self.console.print(
922
+ Text(f" ⎿ same call, same result, {verdict.count} times: {what}", style="yellow")
923
+ )
924
+ if verdict.stop:
925
+ looping = self.describe_call(name, args)
895
926
  tool_msg = {"role": "tool", "tool_name": name, "content": result}
896
927
  if call.get("id"):
897
928
  tool_msg["tool_call_id"] = call["id"]
@@ -905,6 +936,27 @@ class Agent:
905
936
  "output": result[:EVENT_OUTPUT] + ("…" if len(result) > EVENT_OUTPUT else ""),
906
937
  }
907
938
  )
939
+ if looping:
940
+ for rest in calls[i + 1 :]:
941
+ skipped = {
942
+ "role": "tool",
943
+ "tool_name": rest.get("function", {}).get("name", ""),
944
+ "content": "Not run: lcode stopped the request before this call.",
945
+ }
946
+ if rest.get("id"):
947
+ skipped["tool_call_id"] = rest["id"]
948
+ self.messages.append(skipped)
949
+ break
950
+ if looping:
951
+ self.turn_status = "loop"
952
+ self.console.print(
953
+ Text(
954
+ f"Stopped: the model kept making the same call with the same result: {looping}. Tell it "
955
+ "what to do differently, or try again.",
956
+ style="yellow",
957
+ )
958
+ )
959
+ return
908
960
  else:
909
961
  self.turn_status = "max_steps"
910
962
  self.console.print(f"[yellow]Stopped after {self.max_steps} steps.[/]")
@@ -923,30 +975,68 @@ class Agent:
923
975
  )
924
976
 
925
977
  def expand_mentions(self, text: str) -> str:
926
- """Inline files referenced as @path in the user's message; @name of an agent asks for that agent."""
927
- attached = []
928
- for ref in re.findall(r"(?<!\S)@([\w./~\-]+)", text):
978
+ """Attach what the user's message points to: files and folders written as @path, image paths (with or
979
+ without @), and @name of an agent asks for that agent. An image path that doesn't exist is reported to
980
+ the user and the model, so the model doesn't guess what the image shows."""
981
+ attached: list[str] = []
982
+ seen: set[Path] = set()
983
+ question = MENTION.sub("", text)
984
+
985
+ def attach_image(p: Path) -> None:
986
+ try:
987
+ description = self.look(p, question)
988
+ who = f' described_by="{self.vision_model()}"'
989
+ except ToolError as e:
990
+ description, who = f"(lcode couldn't look at this image: {e})", ""
991
+ self.console.print(Text(f" ⎿ {e}", style="yellow"))
992
+ else:
993
+ self.console.print(Text(f" ⎿ looked at {self.tools.rel(p)}", style="dim"))
994
+ attached.append(f'<image path="{self.tools.rel(p)}"{who}>\n{description}\n</image>')
995
+
996
+ def missing_image(shown: str) -> None:
997
+ self.console.print(
998
+ Text(f" ⎿ {shown} doesn't exist, so the model can't see it. Paste images with Ctrl+V.", style="yellow")
999
+ )
1000
+ attached.append(
1001
+ f"[lcode: the user's message mentions the image {shown}, but there's no such file, so it wasn't "
1002
+ "attached and you can't see it. Don't guess what it shows: ask the user to paste it again (Ctrl+V "
1003
+ "in lcode's prompt) or to describe it.]"
1004
+ )
1005
+
1006
+ for match in MENTION.finditer(text):
1007
+ ref = next(g for g in match.groups() if g)
929
1008
  p = self.tools.resolve(ref)
1009
+ seen.add(p)
930
1010
  name = ref.removeprefix("agent-")
931
1011
  if not p.exists() and self.settings.subagents and name in self.agent_types():
932
1012
  attached.append(f'[The user wants the {name} agent for this: call the agent tool with type "{name}".]')
933
1013
  continue
934
1014
  if p.is_file() and vision.is_image(p):
935
- try:
936
- description = self.look(p, re.sub(r"(?<!\S)@[\w./~\-]+", "", text))
937
- who = f' described_by="{self.vision_model()}"'
938
- except ToolError as e:
939
- description, who = f"(lcode couldn't look at this image: {e})", ""
940
- self.console.print(Text(f" ⎿ {e}", style="yellow"))
941
- else:
942
- self.console.print(Text(f" ⎿ looked at {self.tools.rel(p)}", style="dim"))
943
- attached.append(f'<image path="{self.tools.rel(p)}"{who}>\n{description}\n</image>')
1015
+ attach_image(p)
944
1016
  elif p.is_file() and not is_binary(p) and p.stat().st_size < 200_000:
945
1017
  attached.append(f'<file path="{self.tools.rel(p)}">\n{p.read_text(errors="replace")}\n</file>')
946
1018
  self.tools.read_mtimes[str(p)] = p.stat().st_mtime
947
1019
  self.console.print(Text(f" ⎿ attached {self.tools.rel(p)}", style="dim"))
948
1020
  elif p.is_dir():
949
1021
  attached.append(f'<directory path="{self.tools.rel(p)}">\n{tree(p, 2)}\n</directory>')
1022
+ elif not p.exists() and vision.is_image(p):
1023
+ missing_image(ref)
1024
+ for match in IMAGE_PATH.finditer(text):
1025
+ quoted, raw = bool(match.group(2)), match.group(2) or match.group(3)
1026
+ if raw.startswith("@") or "://" in raw.removeprefix("file://"):
1027
+ continue # attached above, or a web address
1028
+ if raw.startswith("file://"):
1029
+ shown = urllib.parse.unquote(raw.removeprefix("file://"))
1030
+ else:
1031
+ shown = raw.replace("\\ ", " ")
1032
+ p = self.tools.resolve(shown)
1033
+ if p in seen:
1034
+ continue
1035
+ seen.add(p)
1036
+ if p.is_file():
1037
+ attach_image(p)
1038
+ elif quoted or raw.startswith(("/", "~", "./", "../", "file://")):
1039
+ missing_image(shown) # a name without a folder may be a file to create: no warning
950
1040
  return text + ("\n\n" + "\n\n".join(attached) if attached else "")
951
1041
 
952
1042
  # -- context management
@@ -35,8 +35,8 @@ if TYPE_CHECKING:
35
35
  from lcode.hardware import Hardware
36
36
 
37
37
  SCHEMA_VERSION = 1
38
- STATUSES = ("success", "error", "max_steps", "interrupted")
39
- EXIT_CODES = {"success": 0, "error": 1, "max_steps": 3, "interrupted": 130}
38
+ STATUSES = ("success", "error", "max_steps", "loop", "interrupted")
39
+ EXIT_CODES = {"success": 0, "error": 1, "max_steps": 3, "loop": 4, "interrupted": 130}
40
40
  OLLAMA_INSTALL = {
41
41
  "linux": "curl -fsSL https://ollama.com/install.sh | sh",
42
42
  "darwin": "brew install ollama (or download it from https://ollama.com/download)",
@@ -69,7 +69,7 @@ class Options:
69
69
  class Result:
70
70
  """How a request went. `to_json()` is what `lcode -p --output json` prints."""
71
71
 
72
- status: str # success, error, max_steps or interrupted
72
+ status: str # success, error, max_steps, loop (stopped: the model kept repeating itself) or interrupted
73
73
  text: str # the model's final answer
74
74
  session_id: str
75
75
  model: str
@@ -194,6 +194,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
194
194
  embed_model=cfg["embed_model"],
195
195
  notify=cfg["notify"],
196
196
  notify_after=cfg["notify_after"],
197
+ repeat_limit=cfg["repeat_limit"],
197
198
  )
198
199
  agent = Agent(ollama, settings, cwd, console=console)
199
200
  agent.interactive = options.interactive
@@ -272,7 +273,7 @@ def run_request(agent: Agent, prompt: str) -> Result:
272
273
 
273
274
  try:
274
275
  agent.run_turn(prompt)
275
- status = "max_steps" if agent.turn_status == "max_steps" else "success"
276
+ status = agent.turn_status if agent.turn_status in ("max_steps", "loop") else "success"
276
277
  except KeyboardInterrupt:
277
278
  status, error = "interrupted", "interrupted before the request was done"
278
279
  except ModelError as e:
@@ -346,6 +346,10 @@ def cmd_doctor(args) -> None:
346
346
  line("Vision", "off; set vision_model to a model the server serves that can see images", None)
347
347
  else:
348
348
  line("Vision", "off" if cfg["vision_model"] == "off" else vision.INSTALL_HINT, None)
349
+ if seer:
350
+ from lcode import clipboard
351
+
352
+ line("Clipboard", *clipboard.status())
349
353
  if limits.get(model):
350
354
  line(
351
355
  "Limit",
@@ -0,0 +1,139 @@
1
+ """Paste from the clipboard with Ctrl+V, images included.
2
+
3
+ A terminal can only paste text, so a screenshot never reaches lcode that way. On Ctrl+V lcode reads the
4
+ clipboard itself: with wl-paste on Wayland, xclip on X11, and pngpaste or osascript on macOS. An image is
5
+ saved in lcode's state folder and goes into the prompt as `@path`, so it's attached like any other image;
6
+ anything else is pasted as text.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+
19
+ from lcode import config
20
+
21
+ TIMEOUT = 5 # seconds for a clipboard tool
22
+ IMAGE_TYPES = ("image/png", "image/jpeg", "image/webp", "image/gif")
23
+ SUFFIXES = {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "image/gif": ".gif"}
24
+ KEEP = 50 # pasted images kept; older ones are removed
25
+
26
+
27
+ @dataclass
28
+ class Paste:
29
+ image: Path | None = None # a saved image
30
+ text: str = "" # or text
31
+ problem: str = "" # why nothing could be pasted
32
+
33
+
34
+ def run(argv: list[str]) -> bytes | None:
35
+ try:
36
+ done = subprocess.run(argv, capture_output=True, timeout=TIMEOUT, check=False)
37
+ except (OSError, subprocess.SubprocessError):
38
+ return None
39
+ return done.stdout if done.returncode == 0 else None
40
+
41
+
42
+ def folder() -> Path:
43
+ return config.STATE_DIR / "pasted"
44
+
45
+
46
+ def save(data: bytes, suffix: str) -> Path:
47
+ where = folder()
48
+ where.mkdir(parents=True, exist_ok=True)
49
+ old = sorted(where.glob("pasted-*")) # the names sort by time
50
+ for path in old[: max(0, len(old) - KEEP + 1)]:
51
+ path.unlink(missing_ok=True)
52
+ now = time.time_ns()
53
+ stamp = f"{time.strftime('%Y%m%d-%H%M%S', time.localtime(now / 1e9))}-{now % 1_000_000_000:09d}"
54
+ path = where / f"pasted-{stamp}{suffix}"
55
+ n = 1
56
+ while path.exists():
57
+ n += 1
58
+ path = where / f"pasted-{stamp}-{n}{suffix}"
59
+ path.write_bytes(data)
60
+ return path
61
+
62
+
63
+ def pick(types: list[str]) -> str | None:
64
+ return next((t for t in IMAGE_TYPES if t in types), None)
65
+
66
+
67
+ def paste() -> Paste:
68
+ """Whatever is on the clipboard: an image (saved to a file) or text."""
69
+ if sys.platform == "darwin":
70
+ return paste_mac()
71
+ if os.environ.get("WAYLAND_DISPLAY") and shutil.which("wl-paste"):
72
+ return paste_with(
73
+ ["wl-paste", "--list-types"],
74
+ lambda kind: ["wl-paste", "--no-newline", "--type", kind],
75
+ ["wl-paste", "--no-newline"],
76
+ )
77
+ if os.environ.get("DISPLAY") and shutil.which("xclip"):
78
+ return paste_with(
79
+ ["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"],
80
+ lambda kind: ["xclip", "-selection", "clipboard", "-t", kind, "-o"],
81
+ ["xclip", "-selection", "clipboard", "-o"],
82
+ )
83
+ return Paste(problem=status()[0])
84
+
85
+
86
+ def status() -> tuple[str, bool | None]:
87
+ """Whether Ctrl+V can paste images here, and if not, what to do (for the paste and `lcode doctor`)."""
88
+ if sys.platform == "darwin":
89
+ return "Ctrl+V pastes images" + (" (with pngpaste)" if shutil.which("pngpaste") else ""), True
90
+ if os.environ.get("WAYLAND_DISPLAY"):
91
+ if shutil.which("wl-paste"):
92
+ return "Ctrl+V pastes images (with wl-paste)", True
93
+ return "To paste images with Ctrl+V, install wl-clipboard (for example `sudo apt install wl-clipboard`).", None
94
+ if os.environ.get("DISPLAY"):
95
+ if shutil.which("xclip"):
96
+ return "Ctrl+V pastes images (with xclip)", True
97
+ return "To paste images with Ctrl+V, install xclip (for example `sudo apt install xclip`).", None
98
+ return "There's no clipboard here (no desktop session), so give an image by its path.", None
99
+
100
+
101
+ def paste_with(list_types: list[str], get, get_text: list[str]) -> Paste:
102
+ types = (run(list_types) or b"").decode(errors="replace").split()
103
+ kind = pick(types)
104
+ if kind:
105
+ data = run(get(kind))
106
+ if data:
107
+ return Paste(image=save(data, SUFFIXES[kind]))
108
+ text = run(get_text)
109
+ if text:
110
+ return Paste(text=text.decode(errors="replace"))
111
+ return Paste(problem="The clipboard is empty.")
112
+
113
+
114
+ def paste_mac() -> Paste:
115
+ if shutil.which("pngpaste"):
116
+ data = run(["pngpaste", "-"])
117
+ if data:
118
+ return Paste(image=save(data, ".png"))
119
+ else:
120
+ target = folder() / ".clipboard.png"
121
+ target.parent.mkdir(parents=True, exist_ok=True)
122
+ target.unlink(missing_ok=True)
123
+ script = [
124
+ "try",
125
+ "set png to (the clipboard as «class PNGf»)",
126
+ f'set f to open for access POSIX file "{target}" with write permission',
127
+ "write png to f",
128
+ "close access f",
129
+ "end try",
130
+ ]
131
+ run(["osascript", *[arg for line in script for arg in ("-e", line)]])
132
+ if target.exists() and target.stat().st_size:
133
+ data = target.read_bytes()
134
+ target.unlink(missing_ok=True)
135
+ return Paste(image=save(data, ".png"))
136
+ text = run(["pbpaste"])
137
+ if text:
138
+ return Paste(text=text.decode(errors="replace"))
139
+ return Paste(problem="The clipboard is empty.")
@@ -59,6 +59,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
59
59
  "subagents": (True, bool, "let the model hand tasks to subagents that have their own context"),
60
60
  "notify": (True, bool, "desktop notification when a long request is done or waits for you"),
61
61
  "notify_after": (30, int, "seconds a request runs before it notifies (with notify on)"),
62
+ "repeat_limit": (3, int, "identical tool calls with identical results before lcode steps in (0: never)"),
62
63
  "max_parallel_agents": (1, int, "subagents that may run at the same time (more needs OLLAMA_NUM_PARALLEL)"),
63
64
  }
64
65
  ENV_OVERRIDES = {