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.
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/CHANGELOG.md +33 -1
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/PKG-INFO +1 -2
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/README.md +0 -1
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/configuration.md +2 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/hooks.md +1 -1
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/installation.md +28 -3
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/quickstart.md +1 -1
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/usage.md +36 -1
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/pyproject.toml +1 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/__init__.py +1 -1
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/acp.py +1 -1
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/agent.py +28 -1
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/api.py +6 -2
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/cli.py +17 -1
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/config.py +2 -0
- lcode_cli-0.21.0/src/lcode/jobs.py +165 -0
- lcode_cli-0.21.0/src/lcode/notify.py +43 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/planning.py +3 -1
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/repl.py +26 -3
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/subagents.py +1 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/tools.py +80 -3
- lcode_cli-0.21.0/src/lcode/update.py +557 -0
- lcode_cli-0.21.0/tests/test_jobs.py +172 -0
- lcode_cli-0.21.0/tests/test_update.py +303 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/.editorconfig +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/.github/CODEOWNERS +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/.github/dependabot.yml +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/.github/workflows/ci.yml +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/.github/workflows/docs.yml +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/.github/workflows/release.yml +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/.gitignore +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/.pre-commit-config.yaml +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/AGENTS.md +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/CODE_OF_CONDUCT.md +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/CONTRIBUTING.md +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/LICENSE +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/SECURITY.md +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/action.yml +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/agents.md +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/assets/demo.svg +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/assets/extra.css +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/assets/logo.svg +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/assets/social-preview.png +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/automation.md +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/bench.md +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/changelog.md +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/commands.md +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/contributing.md +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/editors.md +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/git.md +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/how-it-works.md +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/index.md +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/lsp.md +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/mcp.md +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/memory.md +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/models.md +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/sandbox.md +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/search.md +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/servers.md +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/docs/troubleshooting.md +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/examples/README.md +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/examples/api/fix_tests.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/examples/commands/review.md +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/examples/skills/commit-message/SKILL.md +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/install.sh +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/mkdocs.yml +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/overrides/main.html +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/scripts/record_demo.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/scripts/social-preview.html +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/__main__.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/action.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/backends.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/bench.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/bench_backend.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/catalog.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/checkpoints.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/codesearch.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/context.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/extensions.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/frontmatter.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/gitflow.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/hardware.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/hooks.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/limits.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/lsp.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/mcp/__init__.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/mcp/auth.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/mcp/catalog.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/mcp/catalog.toml +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/mcp/client.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/mcp/commands.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/mcp/config.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/mcp/http.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/mcp/manager.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/mcp/protocol.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/mcp/server.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/mcp/stdio.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/memory.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/models.toml +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/ollama.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/permissions.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/render.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/repomap.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/sandbox.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/servers/__init__.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/servers/encord.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/servers/valohai.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/sessions.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/vision.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/src/lcode/web.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/conftest.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/fake_lsp_server.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/fake_mcp_server.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_acp.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_action.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_agent.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_api.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_backends.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_bench.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_catalog.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_checkpoints.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_cli.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_codesearch.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_config.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_context.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_context_picker.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_extensions.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_gitflow.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_hooks.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_lsp.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_mcp.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_memory.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_permissions.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_plan_mode.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_repomap.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_sandbox.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_servers.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_sessions.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_subagents.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_tools.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_vision.py +0 -0
- {lcode_cli-0.19.0 → lcode_cli-0.21.0}/tests/test_web.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
-
##
|
|
164
|
+
## Updating
|
|
165
165
|
|
|
166
166
|
```bash
|
|
167
|
-
|
|
168
|
-
|
|
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
|
```
|
|
@@ -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:
|
|
@@ -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
|
-
|
|
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:
|