lcode-cli 0.19.0__tar.gz → 0.21.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 (149) hide show
  1. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/CHANGELOG.md +33 -1
  2. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/PKG-INFO +1 -2
  3. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/README.md +0 -1
  4. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/configuration.md +2 -0
  5. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/hooks.md +1 -1
  6. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/installation.md +28 -3
  7. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/quickstart.md +1 -1
  8. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/usage.md +36 -1
  9. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/pyproject.toml +1 -0
  10. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/__init__.py +1 -1
  11. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/acp.py +1 -1
  12. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/agent.py +28 -1
  13. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/api.py +6 -2
  14. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/cli.py +17 -1
  15. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/config.py +2 -0
  16. lcode_cli-0.21.0/src/lcode/jobs.py +165 -0
  17. lcode_cli-0.21.0/src/lcode/notify.py +43 -0
  18. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/planning.py +3 -1
  19. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/repl.py +26 -3
  20. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/subagents.py +1 -0
  21. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/tools.py +80 -3
  22. lcode_cli-0.21.0/src/lcode/update.py +557 -0
  23. lcode_cli-0.21.0/tests/test_jobs.py +172 -0
  24. lcode_cli-0.21.0/tests/test_update.py +303 -0
  25. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/.editorconfig +0 -0
  26. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/.github/CODEOWNERS +0 -0
  27. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  28. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  29. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  30. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
  31. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  32. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/.github/dependabot.yml +0 -0
  33. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/.github/workflows/ci.yml +0 -0
  34. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/.github/workflows/docs.yml +0 -0
  35. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/.github/workflows/release.yml +0 -0
  36. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/.gitignore +0 -0
  37. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/.pre-commit-config.yaml +0 -0
  38. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/AGENTS.md +0 -0
  39. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/CODE_OF_CONDUCT.md +0 -0
  40. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/CONTRIBUTING.md +0 -0
  41. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/LICENSE +0 -0
  42. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/SECURITY.md +0 -0
  43. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/action.yml +0 -0
  44. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/agents.md +0 -0
  45. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/assets/demo.svg +0 -0
  46. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/assets/extra.css +0 -0
  47. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/assets/logo.svg +0 -0
  48. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/assets/social-preview.png +0 -0
  49. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/automation.md +0 -0
  50. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/bench.md +0 -0
  51. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/changelog.md +0 -0
  52. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/commands.md +0 -0
  53. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/contributing.md +0 -0
  54. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/editors.md +0 -0
  55. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/git.md +0 -0
  56. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/how-it-works.md +0 -0
  57. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/index.md +0 -0
  58. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/lsp.md +0 -0
  59. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/mcp.md +0 -0
  60. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/memory.md +0 -0
  61. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/models.md +0 -0
  62. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/sandbox.md +0 -0
  63. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/search.md +0 -0
  64. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/servers.md +0 -0
  65. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/troubleshooting.md +0 -0
  66. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/examples/README.md +0 -0
  67. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/examples/api/fix_tests.py +0 -0
  68. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/examples/commands/review.md +0 -0
  69. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/examples/skills/commit-message/SKILL.md +0 -0
  70. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
  71. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/install.sh +0 -0
  72. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/mkdocs.yml +0 -0
  73. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/overrides/main.html +0 -0
  74. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/scripts/record_demo.py +0 -0
  75. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/scripts/social-preview.html +0 -0
  76. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/__main__.py +0 -0
  77. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/action.py +0 -0
  78. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/backends.py +0 -0
  79. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/bench.py +0 -0
  80. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/bench_backend.py +0 -0
  81. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/catalog.py +0 -0
  82. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/checkpoints.py +0 -0
  83. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/codesearch.py +0 -0
  84. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/context.py +0 -0
  85. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/extensions.py +0 -0
  86. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/frontmatter.py +0 -0
  87. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/gitflow.py +0 -0
  88. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/hardware.py +0 -0
  89. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/hooks.py +0 -0
  90. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/limits.py +0 -0
  91. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/lsp.py +0 -0
  92. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/mcp/__init__.py +0 -0
  93. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/mcp/auth.py +0 -0
  94. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/mcp/catalog.py +0 -0
  95. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/mcp/catalog.toml +0 -0
  96. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/mcp/client.py +0 -0
  97. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/mcp/commands.py +0 -0
  98. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/mcp/config.py +0 -0
  99. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/mcp/http.py +0 -0
  100. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/mcp/manager.py +0 -0
  101. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/mcp/protocol.py +0 -0
  102. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/mcp/server.py +0 -0
  103. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/mcp/stdio.py +0 -0
  104. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/memory.py +0 -0
  105. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/models.toml +0 -0
  106. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/ollama.py +0 -0
  107. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/permissions.py +0 -0
  108. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/render.py +0 -0
  109. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/repomap.py +0 -0
  110. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/sandbox.py +0 -0
  111. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/servers/__init__.py +0 -0
  112. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/servers/encord.py +0 -0
  113. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/servers/valohai.py +0 -0
  114. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/sessions.py +0 -0
  115. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/vision.py +0 -0
  116. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/web.py +0 -0
  117. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/conftest.py +0 -0
  118. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/fake_lsp_server.py +0 -0
  119. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/fake_mcp_server.py +0 -0
  120. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_acp.py +0 -0
  121. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_action.py +0 -0
  122. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_agent.py +0 -0
  123. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_api.py +0 -0
  124. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_backends.py +0 -0
  125. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_bench.py +0 -0
  126. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_catalog.py +0 -0
  127. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_checkpoints.py +0 -0
  128. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_cli.py +0 -0
  129. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_codesearch.py +0 -0
  130. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_config.py +0 -0
  131. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_context.py +0 -0
  132. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_context_picker.py +0 -0
  133. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_extensions.py +0 -0
  134. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_gitflow.py +0 -0
  135. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_hooks.py +0 -0
  136. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_lsp.py +0 -0
  137. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_mcp.py +0 -0
  138. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_memory.py +0 -0
  139. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_permissions.py +0 -0
  140. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_plan_mode.py +0 -0
  141. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_repomap.py +0 -0
  142. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_sandbox.py +0 -0
  143. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_servers.py +0 -0
  144. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_sessions.py +0 -0
  145. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_subagents.py +0 -0
  146. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_tools.py +0 -0
  147. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_vision.py +0 -0
  148. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_web.py +0 -0
  149. {lcode_cli-0.19.0 → lcode_cli-0.21.0}/uv.lock +0 -0
@@ -6,6 +6,36 @@ All notable changes to lcode are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.21.0] - 2026-10-06
10
+
11
+ ### Added
12
+
13
+ - `lcode update` updates lcode the way it was installed (uv tool, pipx, Homebrew, pip, or
14
+ `git pull` for a checkout), and Ollama when it runs in Docker. The newest Ollama release comes
15
+ from Docker Hub, not a local `latest` tag, and is pulled by its version tag (such as
16
+ `ollama/ollama:0.35.1`) so `docker ps` shows the version. A container from Docker Compose gets
17
+ the new tag in its compose file and is recreated with `docker compose up`; any other container is
18
+ recreated with the same settings, and the old one comes back if the new one doesn't start.
19
+ `lcode update --check` only shows which versions are out. See
20
+ [Updating](https://nasser1941.github.io/lcode/installation/#updating).
21
+
22
+ ## [0.20.0] - 2026-10-06
23
+
24
+ ### Added
25
+
26
+ - Background commands: the model starts a dev server, a watcher or another process that keeps
27
+ running with `bash` and `background: true`, reads its new output with `bash_output` and stops it
28
+ with `bash_stop`. `/jobs` lists them, and they all stop when the session ends.
29
+ - Desktop notifications (`notify-send` on Linux, `osascript` on macOS, else the terminal bell)
30
+ when a request that ran longer than 30 seconds is done, or lcode waits for your answer during
31
+ one. Settings: `notify` and `notify_after`.
32
+
33
+ ### Fixed
34
+
35
+ - A command that starts something with `&` no longer hangs until its timeout while that process
36
+ holds the output open. What it left running is stopped when it ends, and the model is told to
37
+ use a background command instead.
38
+
9
39
  ## [0.19.0] - 2026-10-06
10
40
 
11
41
  ### Added
@@ -450,7 +480,9 @@ First public release.
450
480
  - `AGENTS.md` project instructions and `/init` to generate them.
451
481
  - One-line installer for Linux and macOS.
452
482
 
453
- [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.19.0...HEAD
483
+ [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.21.0...HEAD
484
+ [0.21.0]: https://github.com/nasser1941/lcode/compare/v0.20.0...v0.21.0
485
+ [0.20.0]: https://github.com/nasser1941/lcode/compare/v0.19.0...v0.20.0
454
486
  [0.19.0]: https://github.com/nasser1941/lcode/compare/v0.18.0...v0.19.0
455
487
  [0.18.0]: https://github.com/nasser1941/lcode/compare/v0.17.0...v0.18.0
456
488
  [0.17.0]: https://github.com/nasser1941/lcode/compare/v0.16.0...v0.17.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.19.0
3
+ Version: 0.21.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/
@@ -216,7 +216,6 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
216
216
 
217
217
  See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
218
218
 
219
- - [Long-running work: finish notifications and background commands](https://github.com/nasser1941/lcode/issues/51)
220
219
  - More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
221
220
 
222
221
  ## Contributing
@@ -180,7 +180,6 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
180
180
 
181
181
  See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
182
182
 
183
- - [Long-running work: finish notifications and background commands](https://github.com/nasser1941/lcode/issues/51)
184
183
  - More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
185
184
 
186
185
  ## Contributing
@@ -40,6 +40,8 @@ lcode config path # print the file location
40
40
  | `prune` | `true` | When the context is 85% full, first remove old tool output, and summarize the conversation only if that's not enough ([how](how-it-works.md#context-management)) |
41
41
  | `subagents` | `true` | Let the model hand tasks to [subagents](agents.md) with their own context |
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
+ | `notify` | `true` | A desktop [notification](usage.md#notifications) when a long request is done or waits for your answer |
44
+ | `notify_after` | `30` | Seconds a request runs before it notifies |
43
45
  | `checkpoints` | `true` | Save a checkpoint before the model changes files, so [`/undo`](usage.md#undo-and-checkpoints) can restore them |
44
46
 
45
47
  Besides these settings, `config.toml` can hold [hooks](hooks.md) (`[[hooks]]`) and
@@ -46,7 +46,7 @@ command = "ruff format {path} && ruff check --fix {path}"
46
46
  | `after_tool` | After a tool call | Its output goes to the model when it exits with an error, or always with `feedback = true` |
47
47
  | `after_request` | When a request is done | Its output is shown to you |
48
48
  | `session_start` | When a session starts | Its output is shown, and with `feedback = true` given to the model |
49
- | `notification` | When lcode waits for your answer, and when a request that took over 30 seconds is done | Show a desktop notification, ring a bell |
49
+ | `notification` | When lcode waits for your answer, and when a request that ran longer than `notify_after` seconds (30) is done | Send a message, play a sound (lcode shows [desktop notifications](usage.md#notifications) itself) |
50
50
 
51
51
  | Key | |
52
52
  |---|---|
@@ -161,11 +161,36 @@ lmstudio` (or `llama.cpp`, `vllm`, `mlx`). See [Other model servers](servers.md)
161
161
  Use [WSL2](https://learn.microsoft.com/windows/wsl/install) with Ubuntu and follow the Ubuntu
162
162
  steps. NVIDIA GPUs work inside WSL2 with the regular Windows driver.
163
163
 
164
- ## Upgrade and uninstall
164
+ ## Updating
165
165
 
166
166
  ```bash
167
- uv tool upgrade lcode-cli # upgrade (pipx: pipx upgrade lcode-cli; Homebrew: brew upgrade lcode)
168
- uv tool uninstall lcode-cli # remove lcode (Homebrew: brew uninstall lcode)
167
+ lcode update # update lcode, and Ollama if it runs in Docker
168
+ lcode update --check # only show which versions are out
169
+ ```
170
+
171
+ - **lcode** updates the way you installed it: `uv tool upgrade`, `pipx upgrade`, `brew upgrade`
172
+ or `pip`. If it runs from a git checkout, `git pull` (on `main`, without local changes).
173
+ - **Ollama in a Docker container** is updated to the newest release on Docker Hub. lcode looks
174
+ that up instead of trusting a local `latest` tag, which can be months old, and skips release
175
+ candidates. It pulls the version's own tag, such as `ollama/ollama:0.35.1`, so `docker ps` shows
176
+ which version runs. A ROCm image stays ROCm.
177
+ - **A container from Docker Compose:** lcode changes the `image:` line in its compose file and
178
+ runs `docker compose up -d` for that service, so Compose keeps managing it.
179
+ - **Any other container** is recreated with the same name, ports, volumes, environment, GPUs,
180
+ network and restart policy. The old one is kept until the new one answers with the new
181
+ version, and is put back if it doesn't.
182
+ - Your models stay: they're in the container's volume.
183
+ - **Ollama installed without Docker** gets the command that updates it: on Linux,
184
+ `curl -fsSL https://ollama.com/install.sh | sh`; on macOS, `brew upgrade ollama` or the app
185
+ itself.
186
+
187
+ lcode shows what it will do and asks first. `lcode update lcode` or `lcode update ollama` updates
188
+ only one; `-y` doesn't ask.
189
+
190
+ ## Uninstall
191
+
192
+ ```bash
193
+ uv tool uninstall lcode-cli # remove lcode (pipx: pipx uninstall lcode-cli; Homebrew: brew uninstall lcode)
169
194
  rm -rf ~/.config/lcode ~/.local/state/lcode # remove settings and saved sessions
170
195
  ollama rm lcode-qwen3.6-35b qwen3.6:35b-a3b-coding # remove downloaded models
171
196
  ```
@@ -12,7 +12,7 @@ lcode
12
12
 
13
13
  ```text
14
14
  ╭──────────────────────────────────────────────╮
15
- │ lcode v0.19.0 — local coding agent │
15
+ │ lcode v0.21.0 — local coding agent │
16
16
  │ │
17
17
  │ model lcode-qwen3.6-35b │
18
18
  │ context 256K tokens │
@@ -11,6 +11,7 @@ lcode config [set|unset] # show or change settings
11
11
  lcode bench [models] # score models on small coding tasks on this machine
12
12
  lcode mcp [catalog|add|list] # connect MCP servers: Jira, GitHub, AWS, databases… (see MCP servers)
13
13
  lcode acp # run inside an editor over the Agent Client Protocol (see Editors)
14
+ lcode update [--check] # update lcode, and Ollama when it runs in Docker (see Installation)
14
15
  ```
15
16
 
16
17
  | Option | Meaning |
@@ -79,6 +80,7 @@ reasoning is on.
79
80
  | `/mode [ask\|plan\|auto-edit\|yolo]` | Set the permission mode |
80
81
  | `/cd DIR` | Change the working directory |
81
82
  | `/todos` | Show the model's task list |
83
+ | `/jobs [stop ID]` | The [background commands](#background-commands) the model started; stop one |
82
84
  | `/commit [notes]` | [Commit](git.md#commit) the changes with a message in the repository's style, once you approve it |
83
85
  | `/review [base]` | [Review](git.md#review) the uncommitted changes, or the branch against a base |
84
86
  | `/pr [base] [notes]` | Push the branch and open a GitHub [pull request](git.md#pr), once you approve it |
@@ -94,7 +96,8 @@ reasoning is on.
94
96
  | `list_dir` | Directory tree, skipping `.git`, `node_modules`, virtualenvs and caches |
95
97
  | `glob` | Find files by pattern, newest first |
96
98
  | `grep` | Regex search with ripgrep (falls back to Python if ripgrep is missing) |
97
- | `bash` | Run a shell command with live output, a timeout and a persistent working directory |
99
+ | `bash` | Run a shell command with live output, a timeout and a persistent working directory; with `background`, keep it running ([details](#background-commands)) |
100
+ | `bash_output`, `bash_stop` | Read what a background command printed since last time; stop it |
98
101
  | `todo_write` | Keep a visible task list for multi-step work |
99
102
  | `web_search` | Search the web for current information (needs a [search provider](#web-search)) |
100
103
  | `web_fetch` | Read a web page or text file by URL as clean text |
@@ -110,6 +113,38 @@ reasoning is on.
110
113
  lcode refuses to edit a file the model hasn't read in the session, or one that changed on disk since
111
114
  it was read, so the model always edits the current version.
112
115
 
116
+ ## Background commands
117
+
118
+ Some commands keep running: a dev server, a test watcher, a build in watch mode. The model starts
119
+ one with `bash` and `background: true` and carries on working. It reads what the command printed
120
+ since last time with `bash_output`, and stops it with `bash_stop`.
121
+
122
+ ```text
123
+ $ npm run dev (in the background)
124
+ > vite
125
+ VITE v6.0.0 ready in 312 ms
126
+ ⎿ background job 1, /jobs to see it
127
+ ```
128
+
129
+ - `/jobs` lists the background commands; `/jobs stop 1` stops one.
130
+ - They all stop when the session ends.
131
+ - Anything else a command starts with `&` is stopped when that command ends. Only background
132
+ commands keep running, so no process outlives the session unnoticed.
133
+ - Background commands ask for permission like any other command, and run in the
134
+ [sandbox](sandbox.md) when it's on.
135
+
136
+ ## Notifications
137
+
138
+ Local models take a while, so you'll often switch to something else during a request. lcode shows
139
+ a desktop notification:
140
+ - when a request that ran longer than 30 seconds is done;
141
+ - when, during such a request, lcode waits for your answer (a permission question or a plan).
142
+
143
+ It uses `notify-send` on Linux and `osascript` on macOS, and rings the terminal bell where neither
144
+ works. Change the time with `lcode config set notify_after 60`, or turn notifications off with
145
+ `lcode config set notify false`. For something else, such as a sound or a phone message, use a
146
+ [notification hook](hooks.md#hooks).
147
+
113
148
  ## Images
114
149
 
115
150
  lcode can look at screenshots, mockups, diagrams and photos:
@@ -87,6 +87,7 @@ ignore = [
87
87
  "src/lcode/gitflow.py" = ["E501"]
88
88
  "src/lcode/action.py" = ["E501"]
89
89
  "src/lcode/acp.py" = ["E501"]
90
+ "src/lcode/update.py" = ["E501"]
90
91
 
91
92
  [tool.pytest.ini_options]
92
93
  testpaths = ["tests"]
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.19.0"
6
+ __version__ = "0.21.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
@@ -46,7 +46,7 @@ KINDS = {
46
46
  "read_file": "read", "view_image": "read", "list_dir": "search", "glob": "search", "grep": "search",
47
47
  "repo_map": "search", "search_code": "search", "lsp": "search", "edit_file": "edit", "write_file": "edit",
48
48
  "bash": "execute", "web_search": "fetch", "web_fetch": "fetch", "agent": "think", "todo_write": "think",
49
- "present_plan": "switch_mode",
49
+ "present_plan": "switch_mode", "bash_output": "read", "bash_stop": "execute",
50
50
  } # fmt: skip
51
51
  STOP = {"success": "end_turn", "max_steps": "max_turn_requests", "interrupted": "cancelled"}
52
52
  MAX_INLINE = 200_000 # bytes of a file the editor attaches that go into the prompt
@@ -22,11 +22,12 @@ from rich.markup import escape
22
22
  from rich.panel import Panel
23
23
  from rich.text import Text
24
24
 
25
- from lcode import catalog, codesearch, extensions, limits, planning, repomap, sessions, subagents, vision, web
25
+ from lcode import catalog, codesearch, extensions, limits, notify, planning, repomap, sessions, subagents, vision, web
26
26
  from lcode import context as context_tools
27
27
  from lcode import memory as memory_notes
28
28
  from lcode.checkpoints import Checkpoints
29
29
  from lcode.config import format_tokens
30
+ from lcode.jobs import Jobs
30
31
  from lcode.mcp import McpManager
31
32
  from lcode.ollama import Ollama, OllamaError
32
33
  from lcode.permissions import Permissions
@@ -212,6 +213,8 @@ class Settings:
212
213
  lsp: str = "off" # auto: use the installed language servers (lcode.lsp)
213
214
  repo_map: bool = False # the repository map (lcode.repomap)
214
215
  embed_model: str = "off" # semantic code search (lcode.codesearch): auto, off or a model
216
+ notify: bool = False # desktop notifications after long requests (lcode.notify)
217
+ notify_after: int = 30 # seconds: a request this long notifies when it's done or waits for an answer
215
218
  max_parallel_agents: int = 1
216
219
 
217
220
 
@@ -256,6 +259,8 @@ class Agent:
256
259
  self.no_changes = "" # set during a review: why nothing may change (read-only tools only)
257
260
  self.cancel: threading.Event | None = None # set from another thread to stop
258
261
  self.on_tool = None # called with (name, arguments) before each tool runs
262
+ self.jobs = Jobs() # background commands (shared with subagents)
263
+ self.turn_started = 0.0 # when the running request started (time.monotonic)
259
264
  self.on_event: Callable[[dict], None] | None = None # each step and tool result, for --output stream-json
260
265
  self.on_delta: Callable[[str, str], None] | None = None # ("text" or "thinking", piece) as it streams
261
266
  self.current_call = "" # id of the tool call that's running, so a permission request can name it
@@ -746,6 +751,7 @@ class Agent:
746
751
  return name
747
752
 
748
753
  def run_turn(self, user_text: str) -> None:
754
+ self.turn_started = time.monotonic()
749
755
  self.checkpoints.begin_turn(self.session_id, user_text, len(self.messages))
750
756
  try:
751
757
  self._run_turn(user_text)
@@ -760,6 +766,27 @@ class Agent:
760
766
  Text(f" ⎿ after_request hook ({outcome.code}): {outcome.output[:500]}", style=style)
761
767
  )
762
768
 
769
+ def waiting(self, title: str) -> None:
770
+ """lcode waits for the user's answer: tell them, if they've likely walked away (lcode.notify)."""
771
+ if self.hooks.for_event("notification"):
772
+ self.hooks.notify(f"lcode needs you: {title}", self.cwd)
773
+ if (
774
+ self.settings.notify
775
+ and self.interactive
776
+ and time.monotonic() - self.turn_started >= self.settings.notify_after
777
+ ):
778
+ notify.desktop("lcode needs you", title)
779
+
780
+ def finished(self, request: str, seconds: float) -> None:
781
+ """A request is done: after a long one, tell the user."""
782
+ if seconds < self.settings.notify_after:
783
+ return
784
+ title = sessions.title_from([{"role": "user", "content": request}])
785
+ if self.hooks.for_event("notification"):
786
+ self.hooks.notify(f"lcode finished: {title}", self.cwd)
787
+ if self.settings.notify and self.interactive:
788
+ notify.desktop("lcode is done", f"{title} ({seconds:.0f}s)")
789
+
763
790
  def sandbox_root(self) -> Path | None:
764
791
  """The folder the model is limited to while the sandbox is on (None when it's off)."""
765
792
  if not self.sandbox:
@@ -192,6 +192,8 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
192
192
  prune=cfg["prune"],
193
193
  repo_map=cfg["repo_map"],
194
194
  embed_model=cfg["embed_model"],
195
+ notify=cfg["notify"],
196
+ notify_after=cfg["notify_after"],
195
197
  )
196
198
  agent = Agent(ollama, settings, cwd, console=console)
197
199
  agent.interactive = options.interactive
@@ -206,8 +208,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
206
208
  agent.hooks, agent.perms.rules = hooks.load(cwd, trust_project)
207
209
  for problem in agent.hooks.problems:
208
210
  console.print(f"[yellow]Settings: {problem}[/]")
209
- if agent.hooks.for_event("notification"):
210
- agent.perms.on_prompt = lambda title: agent.hooks.notify(f"lcode needs you: {title}", agent.cwd)
211
+ agent.perms.on_prompt = agent.waiting # notification hooks and desktop notifications
211
212
  if cfg["lsp"] == "auto":
212
213
  from lcode import lsp
213
214
  from lcode.checkpoints import work_tree_for
@@ -233,6 +234,9 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
233
234
 
234
235
 
235
236
  def close_agent(agent: Agent) -> None:
237
+ stopped = agent.jobs.stop_all()
238
+ if stopped:
239
+ agent.console.print(f"[dim]Stopped {stopped} background job(s).[/]")
236
240
  if agent.lsp is not None:
237
241
  agent.lsp.close()
238
242
  if agent.mcp:
@@ -678,7 +678,7 @@ def build_parser() -> argparse.ArgumentParser:
678
678
  parser = argparse.ArgumentParser(
679
679
  prog="lcode",
680
680
  description="A local-first terminal coding agent powered by open-weight models via Ollama.",
681
- epilog="Subcommands: lcode setup | models | doctor | config | bench | mcp | index "
681
+ epilog="Subcommands: lcode setup | models | doctor | config | bench | mcp | index | update "
682
682
  "(lcode <subcommand> --help). "
683
683
  "Docs: https://nasser1941.github.io/lcode/",
684
684
  )
@@ -774,6 +774,15 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
774
774
  from lcode.mcp.commands import build_parser as mcp_parser
775
775
 
776
776
  subs["mcp"] = mcp_parser()
777
+ p = argparse.ArgumentParser(
778
+ prog="lcode update",
779
+ description="Update lcode, and Ollama when it runs in Docker (to the newest version on Docker Hub, pinned "
780
+ "to its version tag).",
781
+ )
782
+ p.add_argument("what", nargs="?", choices=["lcode", "ollama"], help="only this one (default: both)")
783
+ p.add_argument("--check", action="store_true", help="only show which versions are out")
784
+ p.add_argument("-y", "--yes", action="store_true", help="don't ask before updating")
785
+ subs["update"] = p
777
786
  subs["acp"] = argparse.ArgumentParser(
778
787
  prog="lcode acp",
779
788
  description="Run as the agent inside an editor, over the Agent Client Protocol on stdin and stdout "
@@ -787,6 +796,12 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
787
796
  return subs
788
797
 
789
798
 
799
+ def cmd_update(args) -> None:
800
+ from lcode import update
801
+
802
+ sys.exit(update.command(console, args.what or "", check=args.check, yes=args.yes))
803
+
804
+
790
805
  def cmd_acp(args) -> None:
791
806
  from lcode import acp
792
807
 
@@ -863,6 +878,7 @@ COMMANDS = {
863
878
  "index": cmd_index,
864
879
  "action": cmd_action,
865
880
  "acp": cmd_acp,
881
+ "update": cmd_update,
866
882
  }
867
883
 
868
884
 
@@ -57,6 +57,8 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
57
57
  "lsp": ("auto", str, "language servers for code navigation and errors after edits: auto | off"),
58
58
  "prune": (True, bool, "before summarizing a full conversation, first remove old tool output from it"),
59
59
  "subagents": (True, bool, "let the model hand tasks to subagents that have their own context"),
60
+ "notify": (True, bool, "desktop notification when a long request is done or waits for you"),
61
+ "notify_after": (30, int, "seconds a request runs before it notifies (with notify on)"),
60
62
  "max_parallel_agents": (1, int, "subagents that may run at the same time (more needs OLLAMA_NUM_PARALLEL)"),
61
63
  }
62
64
  ENV_OVERRIDES = {
@@ -0,0 +1,165 @@
1
+ """Background commands: dev servers, watchers and other processes that keep running.
2
+
3
+ The model starts one with `bash(command, background=true)` and gets an id back, reads what it
4
+ printed since last time with `bash_output(id)` and stops it with `bash_stop(id)`. `/jobs` lists
5
+ them. They're all stopped when the session ends.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import os
12
+ import signal
13
+ import subprocess
14
+ import threading
15
+ import time
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+
20
+ KEEP = 1_000_000 # characters of a job's output kept in memory
21
+ STARTUP_WAIT = 3.0 # seconds to wait for a new job's first output (and quick failures)
22
+
23
+
24
+ class JobError(Exception):
25
+ pass
26
+
27
+
28
+ @dataclass
29
+ class Job:
30
+ id: str
31
+ command: str
32
+ proc: subprocess.Popen
33
+ kill: Callable[[], None] | None = None # extra cleanup, e.g. the process in the sandbox
34
+ started: float = field(default_factory=time.monotonic)
35
+ output: str = ""
36
+ read: int = 0 # how much of `output` the model has seen
37
+ dropped: int = 0 # characters dropped from the front to stay under KEEP
38
+ stopped: bool = False
39
+ ended: float = 0.0
40
+ lock: threading.Lock = field(default_factory=threading.Lock)
41
+
42
+ def pump(self) -> None:
43
+ assert self.proc.stdout is not None
44
+ for line in self.proc.stdout:
45
+ with self.lock:
46
+ self.output += line
47
+ if len(self.output) > KEEP:
48
+ cut = len(self.output) - KEEP
49
+ self.output = self.output[cut:]
50
+ self.dropped += cut
51
+ self.read = max(0, self.read - cut)
52
+ self.proc.wait()
53
+ self.ended = time.monotonic()
54
+
55
+ @property
56
+ def running(self) -> bool:
57
+ return self.proc.poll() is None
58
+
59
+ def status(self) -> str:
60
+ if self.running:
61
+ return "running"
62
+ if self.stopped:
63
+ return "stopped"
64
+ return f"exited with code {self.proc.returncode}"
65
+
66
+ def runtime(self) -> float:
67
+ return (self.ended or time.monotonic()) - self.started
68
+
69
+ def new_output(self, limit: int) -> str:
70
+ with self.lock:
71
+ text, self.read = self.output[self.read :], len(self.output)
72
+ if len(text) > limit:
73
+ text = f"[… {len(text) - limit:,} earlier characters not shown]\n" + text[-limit:]
74
+ return text
75
+
76
+
77
+ class Jobs:
78
+ """The session's background commands (shared with its subagents)."""
79
+
80
+ def __init__(self) -> None:
81
+ self.items: dict[str, Job] = {}
82
+ self.counter = 0
83
+
84
+ def start(self, argv: list[str], cwd: Path, command: str, kill: Callable[[], None] | None = None) -> Job:
85
+ proc = subprocess.Popen(
86
+ argv,
87
+ cwd=cwd,
88
+ stdout=subprocess.PIPE,
89
+ stderr=subprocess.STDOUT,
90
+ stdin=subprocess.DEVNULL,
91
+ text=True,
92
+ errors="replace",
93
+ start_new_session=True, # its own process group, so stopping it stops its children too
94
+ bufsize=1,
95
+ )
96
+ self.counter += 1
97
+ job = Job(str(self.counter), command, proc, kill)
98
+ self.items[job.id] = job
99
+ threading.Thread(target=job.pump, daemon=True).start()
100
+ deadline = time.monotonic() + STARTUP_WAIT
101
+ while time.monotonic() < deadline and job.running and not job.output:
102
+ time.sleep(0.05)
103
+ if job.running:
104
+ time.sleep(min(0.5, max(0.0, deadline - time.monotonic()))) # a little more of its first output
105
+ else:
106
+ time.sleep(0.1) # let the pump read the rest
107
+ return job
108
+
109
+ def get(self, job_id: str) -> Job:
110
+ job = self.items.get(str(job_id).strip().lstrip("#"))
111
+ if job is None:
112
+ known = ", ".join(self.items) or "none"
113
+ raise JobError(f"there's no background job {job_id} (jobs: {known})")
114
+ return job
115
+
116
+ def stop(self, job_id: str) -> Job:
117
+ job = self.get(job_id)
118
+ if job.running:
119
+ job.stopped = True
120
+ terminate(job)
121
+ return job
122
+
123
+ def stop_all(self) -> int:
124
+ running = [j for j in self.items.values() if j.running]
125
+ for job in running:
126
+ job.stopped = True
127
+ terminate(job)
128
+ return len(running)
129
+
130
+ def running(self) -> list[Job]:
131
+ return [j for j in self.items.values() if j.running]
132
+
133
+
134
+ def stop_leftovers(group: int) -> bool:
135
+ """Stop what a finished command left running in its process group; whether there was anything."""
136
+ try:
137
+ os.killpg(group, 0) # raises when nothing is left
138
+ except (ProcessLookupError, PermissionError):
139
+ return False
140
+ for sig in (signal.SIGTERM, signal.SIGKILL):
141
+ with contextlib.suppress(ProcessLookupError, PermissionError):
142
+ os.killpg(group, sig)
143
+ time.sleep(0.3)
144
+ return True
145
+
146
+
147
+ def terminate(job: Job, grace: float = 3.0) -> None:
148
+ """SIGTERM the job's process group, then SIGKILL whatever is left."""
149
+ if job.kill is not None:
150
+ with contextlib.suppress(Exception):
151
+ job.kill()
152
+ for sig in (signal.SIGTERM, signal.SIGKILL):
153
+ with contextlib.suppress(ProcessLookupError, PermissionError):
154
+ os.killpg(job.proc.pid, sig)
155
+ try:
156
+ job.proc.wait(timeout=grace)
157
+ return
158
+ except subprocess.TimeoutExpired:
159
+ continue
160
+
161
+
162
+ def describe(job: Job) -> str:
163
+ minutes, seconds = divmod(int(job.runtime()), 60)
164
+ took = f"{minutes}m {seconds:02d}s" if minutes else f"{seconds}s"
165
+ return f"job {job.id} ({job.status()}, {took}): {job.command}"
@@ -0,0 +1,43 @@
1
+ """Desktop notifications: when a long request is done, or lcode waits for an answer during one.
2
+
3
+ Local models are slow, so people switch to something else while a request runs. lcode shows a
4
+ notification with `notify-send` on Linux and `osascript` on macOS, and rings the terminal bell
5
+ where neither is available.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import platform
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import threading
15
+
16
+
17
+ def command(title: str, message: str) -> list[str] | None:
18
+ """The command that shows a notification on this machine, or None."""
19
+ if platform.system() == "Darwin" and shutil.which("osascript"):
20
+ quoted = message.replace("\\", "\\\\").replace('"', '\\"')
21
+ heading = title.replace("\\", "\\\\").replace('"', '\\"')
22
+ return ["osascript", "-e", f'display notification "{quoted}" with title "{heading}"']
23
+ if shutil.which("notify-send"):
24
+ return ["notify-send", "--app-name=lcode", title, message]
25
+ return None
26
+
27
+
28
+ def desktop(title: str, message: str) -> None:
29
+ """Show the notification without waiting for it; the terminal bell if there's no way to."""
30
+ argv = command(title, message[:200])
31
+ if argv is None:
32
+ sys.stderr.write("\a")
33
+ sys.stderr.flush()
34
+ return
35
+
36
+ def run() -> None:
37
+ try:
38
+ subprocess.run(argv, capture_output=True, timeout=10)
39
+ except (OSError, subprocess.SubprocessError):
40
+ sys.stderr.write("\a")
41
+ sys.stderr.flush()
42
+
43
+ threading.Thread(target=run, daemon=True).start()
@@ -32,7 +32,7 @@ if TYPE_CHECKING:
32
32
  PLAN_MODE_TOOLS = frozenset(
33
33
  {
34
34
  "read_file", "list_dir", "glob", "grep", "bash", "web_search", "web_fetch", "view_image", "todo_write",
35
- "agent", "memory", "present_plan", "mcp_find_tools", "skill", "lsp", "repo_map", "search_code",
35
+ "agent", "memory", "present_plan", "mcp_find_tools", "skill", "lsp", "repo_map", "search_code", "bash_output",
36
36
  }
37
37
  ) # fmt: skip
38
38
 
@@ -103,6 +103,8 @@ def present(agent: Agent, title: str, plan: str) -> str:
103
103
  if not agent.interactive:
104
104
  return "The plan was shown to the user, who will review it later. Stop here and don't change anything."
105
105
  c.print(CHOICES)
106
+ if agent.perms.on_prompt:
107
+ agent.perms.on_prompt(f"Plan: {title}")
106
108
  try:
107
109
  answer = input(" > ").strip()
108
110
  except EOFError: