Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ lcode config path # print the file location
| `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)) |
| `subagents` | `true` | Let the model hand tasks to [subagents](agents.md) with their own context |
| `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)) |
| `notify` | `true` | A desktop [notification](usage.md#notifications) when a long request is done or waits for your answer |
| `notify_after` | `30` | Seconds a request runs before it notifies |
| `checkpoints` | `true` | Save a checkpoint before the model changes files, so [`/undo`](usage.md#undo-and-checkpoints) can restore them |

Besides these settings, `config.toml` can hold [hooks](hooks.md) (`[[hooks]]`) and
Expand Down
2 changes: 1 addition & 1 deletion docs/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ command = "ruff format {path} && ruff check --fix {path}"
| `after_tool` | After a tool call | Its output goes to the model when it exits with an error, or always with `feedback = true` |
| `after_request` | When a request is done | Its output is shown to you |
| `session_start` | When a session starts | Its output is shown, and with `feedback = true` given to the model |
| `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 |
| `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) |

| Key | |
|---|---|
Expand Down
36 changes: 35 additions & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ reasoning is on.
| `/mode [ask\|plan\|auto-edit\|yolo]` | Set the permission mode |
| `/cd DIR` | Change the working directory |
| `/todos` | Show the model's task list |
| `/jobs [stop ID]` | The [background commands](#background-commands) the model started; stop one |
| `/commit [notes]` | [Commit](git.md#commit) the changes with a message in the repository's style, once you approve it |
| `/review [base]` | [Review](git.md#review) the uncommitted changes, or the branch against a base |
| `/pr [base] [notes]` | Push the branch and open a GitHub [pull request](git.md#pr), once you approve it |
Expand All @@ -94,7 +95,8 @@ reasoning is on.
| `list_dir` | Directory tree, skipping `.git`, `node_modules`, virtualenvs and caches |
| `glob` | Find files by pattern, newest first |
| `grep` | Regex search with ripgrep (falls back to Python if ripgrep is missing) |
| `bash` | Run a shell command with live output, a timeout and a persistent working directory |
| `bash` | Run a shell command with live output, a timeout and a persistent working directory; with `background`, keep it running ([details](#background-commands)) |
| `bash_output`, `bash_stop` | Read what a background command printed since last time; stop it |
| `todo_write` | Keep a visible task list for multi-step work |
| `web_search` | Search the web for current information (needs a [search provider](#web-search)) |
| `web_fetch` | Read a web page or text file by URL as clean text |
Expand All @@ -110,6 +112,38 @@ reasoning is on.
lcode refuses to edit a file the model hasn't read in the session, or one that changed on disk since
it was read, so the model always edits the current version.

## Background commands

Some commands keep running: a dev server, a test watcher, a build in watch mode. The model starts
one with `bash` and `background: true` and carries on working. It reads what the command printed
since last time with `bash_output`, and stops it with `bash_stop`.

```text
$ npm run dev (in the background)
> vite
VITE v6.0.0 ready in 312 ms
⎿ background job 1, /jobs to see it
```

- `/jobs` lists the background commands; `/jobs stop 1` stops one.
- They all stop when the session ends.
- Anything else a command starts with `&` is stopped when that command ends. Only background
commands keep running, so no process outlives the session unnoticed.
- Background commands ask for permission like any other command, and run in the
[sandbox](sandbox.md) when it's on.

## Notifications

Local models take a while, so you'll often switch to something else during a request. lcode shows
a desktop notification:
- when a request that ran longer than 30 seconds is done;
- when, during such a request, lcode waits for your answer (a permission question or a plan).

It uses `notify-send` on Linux and `osascript` on macOS, and rings the terminal bell where neither
works. Change the time with `lcode config set notify_after 60`, or turn notifications off with
`lcode config set notify false`. For something else, such as a sound or a phone message, use a
[notification hook](hooks.md#hooks).

## Images

lcode can look at screenshots, mockups, diagrams and photos:
Expand Down
2 changes: 1 addition & 1 deletion src/lcode/acp.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
"read_file": "read", "view_image": "read", "list_dir": "search", "glob": "search", "grep": "search",
"repo_map": "search", "search_code": "search", "lsp": "search", "edit_file": "edit", "write_file": "edit",
"bash": "execute", "web_search": "fetch", "web_fetch": "fetch", "agent": "think", "todo_write": "think",
"present_plan": "switch_mode",
"present_plan": "switch_mode", "bash_output": "read", "bash_stop": "execute",
} # fmt: skip
STOP = {"success": "end_turn", "max_steps": "max_turn_requests", "interrupted": "cancelled"}
MAX_INLINE = 200_000 # bytes of a file the editor attaches that go into the prompt
Expand Down
29 changes: 28 additions & 1 deletion src/lcode/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@
from rich.panel import Panel
from rich.text import Text

from lcode import catalog, codesearch, extensions, limits, planning, repomap, sessions, subagents, vision, web
from lcode import catalog, codesearch, extensions, limits, notify, planning, repomap, sessions, subagents, vision, web
from lcode import context as context_tools
from lcode import memory as memory_notes
from lcode.checkpoints import Checkpoints
from lcode.config import format_tokens
from lcode.jobs import Jobs
from lcode.mcp import McpManager
from lcode.ollama import Ollama, OllamaError
from lcode.permissions import Permissions
Expand Down Expand Up @@ -212,6 +213,8 @@ class Settings:
lsp: str = "off" # auto: use the installed language servers (lcode.lsp)
repo_map: bool = False # the repository map (lcode.repomap)
embed_model: str = "off" # semantic code search (lcode.codesearch): auto, off or a model
notify: bool = False # desktop notifications after long requests (lcode.notify)
notify_after: int = 30 # seconds: a request this long notifies when it's done or waits for an answer
max_parallel_agents: int = 1


Expand Down Expand Up @@ -256,6 +259,8 @@ def __init__(self, ollama: Ollama, settings: Settings, cwd: Path, console: Conso
self.no_changes = "" # set during a review: why nothing may change (read-only tools only)
self.cancel: threading.Event | None = None # set from another thread to stop
self.on_tool = None # called with (name, arguments) before each tool runs
self.jobs = Jobs() # background commands (shared with subagents)
self.turn_started = 0.0 # when the running request started (time.monotonic)
self.on_event: Callable[[dict], None] | None = None # each step and tool result, for --output stream-json
self.on_delta: Callable[[str, str], None] | None = None # ("text" or "thinking", piece) as it streams
self.current_call = "" # id of the tool call that's running, so a permission request can name it
Expand Down Expand Up @@ -746,6 +751,7 @@ def describe_call(self, name: str, args: dict) -> str:
return name

def run_turn(self, user_text: str) -> None:
self.turn_started = time.monotonic()
self.checkpoints.begin_turn(self.session_id, user_text, len(self.messages))
try:
self._run_turn(user_text)
Expand All @@ -760,6 +766,27 @@ def run_turn(self, user_text: str) -> None:
Text(f" ⎿ after_request hook ({outcome.code}): {outcome.output[:500]}", style=style)
)

def waiting(self, title: str) -> None:
"""lcode waits for the user's answer: tell them, if they've likely walked away (lcode.notify)."""
if self.hooks.for_event("notification"):
self.hooks.notify(f"lcode needs you: {title}", self.cwd)
if (
self.settings.notify
and self.interactive
and time.monotonic() - self.turn_started >= self.settings.notify_after
):
notify.desktop("lcode needs you", title)

def finished(self, request: str, seconds: float) -> None:
"""A request is done: after a long one, tell the user."""
if seconds < self.settings.notify_after:
return
title = sessions.title_from([{"role": "user", "content": request}])
if self.hooks.for_event("notification"):
self.hooks.notify(f"lcode finished: {title}", self.cwd)
if self.settings.notify and self.interactive:
notify.desktop("lcode is done", f"{title} ({seconds:.0f}s)")

def sandbox_root(self) -> Path | None:
"""The folder the model is limited to while the sandbox is on (None when it's off)."""
if not self.sandbox:
Expand Down
8 changes: 6 additions & 2 deletions src/lcode/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
prune=cfg["prune"],
repo_map=cfg["repo_map"],
embed_model=cfg["embed_model"],
notify=cfg["notify"],
notify_after=cfg["notify_after"],
)
agent = Agent(ollama, settings, cwd, console=console)
agent.interactive = options.interactive
Expand All @@ -206,8 +208,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
agent.hooks, agent.perms.rules = hooks.load(cwd, trust_project)
for problem in agent.hooks.problems:
console.print(f"[yellow]Settings: {problem}[/]")
if agent.hooks.for_event("notification"):
agent.perms.on_prompt = lambda title: agent.hooks.notify(f"lcode needs you: {title}", agent.cwd)
agent.perms.on_prompt = agent.waiting # notification hooks and desktop notifications
if cfg["lsp"] == "auto":
from lcode import lsp
from lcode.checkpoints import work_tree_for
Expand All @@ -233,6 +234,9 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non


def close_agent(agent: Agent) -> None:
stopped = agent.jobs.stop_all()
if stopped:
agent.console.print(f"[dim]Stopped {stopped} background job(s).[/]")
if agent.lsp is not None:
agent.lsp.close()
if agent.mcp:
Expand Down
2 changes: 2 additions & 0 deletions src/lcode/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@
"lsp": ("auto", str, "language servers for code navigation and errors after edits: auto | off"),
"prune": (True, bool, "before summarizing a full conversation, first remove old tool output from it"),
"subagents": (True, bool, "let the model hand tasks to subagents that have their own context"),
"notify": (True, bool, "desktop notification when a long request is done or waits for you"),
"notify_after": (30, int, "seconds a request runs before it notifies (with notify on)"),
"max_parallel_agents": (1, int, "subagents that may run at the same time (more needs OLLAMA_NUM_PARALLEL)"),
}
ENV_OVERRIDES = {
Expand Down
165 changes: 165 additions & 0 deletions src/lcode/jobs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
"""Background commands: dev servers, watchers and other processes that keep running.

The model starts one with `bash(command, background=true)` and gets an id back, reads what it
printed since last time with `bash_output(id)` and stops it with `bash_stop(id)`. `/jobs` lists
them. They're all stopped when the session ends.
"""

from __future__ import annotations

import contextlib
import os
import signal
import subprocess
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path

KEEP = 1_000_000 # characters of a job's output kept in memory
STARTUP_WAIT = 3.0 # seconds to wait for a new job's first output (and quick failures)


class JobError(Exception):
pass


@dataclass
class Job:
id: str
command: str
proc: subprocess.Popen
kill: Callable[[], None] | None = None # extra cleanup, e.g. the process in the sandbox
started: float = field(default_factory=time.monotonic)
output: str = ""
read: int = 0 # how much of `output` the model has seen
dropped: int = 0 # characters dropped from the front to stay under KEEP
stopped: bool = False
ended: float = 0.0
lock: threading.Lock = field(default_factory=threading.Lock)

def pump(self) -> None:
assert self.proc.stdout is not None
for line in self.proc.stdout:
with self.lock:
self.output += line
if len(self.output) > KEEP:
cut = len(self.output) - KEEP
self.output = self.output[cut:]
self.dropped += cut
self.read = max(0, self.read - cut)
self.proc.wait()
self.ended = time.monotonic()

@property
def running(self) -> bool:
return self.proc.poll() is None

def status(self) -> str:
if self.running:
return "running"
if self.stopped:
return "stopped"
return f"exited with code {self.proc.returncode}"

def runtime(self) -> float:
return (self.ended or time.monotonic()) - self.started

def new_output(self, limit: int) -> str:
with self.lock:
text, self.read = self.output[self.read :], len(self.output)
if len(text) > limit:
text = f"[… {len(text) - limit:,} earlier characters not shown]\n" + text[-limit:]
return text


class Jobs:
"""The session's background commands (shared with its subagents)."""

def __init__(self) -> None:
self.items: dict[str, Job] = {}
self.counter = 0

def start(self, argv: list[str], cwd: Path, command: str, kill: Callable[[], None] | None = None) -> Job:
proc = subprocess.Popen(
argv,
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
text=True,
errors="replace",
start_new_session=True, # its own process group, so stopping it stops its children too
bufsize=1,
)
self.counter += 1
job = Job(str(self.counter), command, proc, kill)
self.items[job.id] = job
threading.Thread(target=job.pump, daemon=True).start()
deadline = time.monotonic() + STARTUP_WAIT
while time.monotonic() < deadline and job.running and not job.output:
time.sleep(0.05)
if job.running:
time.sleep(min(0.5, max(0.0, deadline - time.monotonic()))) # a little more of its first output
else:
time.sleep(0.1) # let the pump read the rest
return job

def get(self, job_id: str) -> Job:
job = self.items.get(str(job_id).strip().lstrip("#"))
if job is None:
known = ", ".join(self.items) or "none"
raise JobError(f"there's no background job {job_id} (jobs: {known})")
return job

def stop(self, job_id: str) -> Job:
job = self.get(job_id)
if job.running:
job.stopped = True
terminate(job)
return job

def stop_all(self) -> int:
running = [j for j in self.items.values() if j.running]
for job in running:
job.stopped = True
terminate(job)
return len(running)

def running(self) -> list[Job]:
return [j for j in self.items.values() if j.running]


def stop_leftovers(group: int) -> bool:
"""Stop what a finished command left running in its process group; whether there was anything."""
try:
os.killpg(group, 0) # raises when nothing is left
except (ProcessLookupError, PermissionError):
return False
for sig in (signal.SIGTERM, signal.SIGKILL):
with contextlib.suppress(ProcessLookupError, PermissionError):
os.killpg(group, sig)
time.sleep(0.3)
return True


def terminate(job: Job, grace: float = 3.0) -> None:
"""SIGTERM the job's process group, then SIGKILL whatever is left."""
if job.kill is not None:
with contextlib.suppress(Exception):
job.kill()
for sig in (signal.SIGTERM, signal.SIGKILL):
with contextlib.suppress(ProcessLookupError, PermissionError):
os.killpg(job.proc.pid, sig)
try:
job.proc.wait(timeout=grace)
return
except subprocess.TimeoutExpired:
continue


def describe(job: Job) -> str:
minutes, seconds = divmod(int(job.runtime()), 60)
took = f"{minutes}m {seconds:02d}s" if minutes else f"{seconds}s"
return f"job {job.id} ({job.status()}, {took}): {job.command}"
43 changes: 43 additions & 0 deletions src/lcode/notify.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Desktop notifications: when a long request is done, or lcode waits for an answer during one.

Local models are slow, so people switch to something else while a request runs. lcode shows a
notification with `notify-send` on Linux and `osascript` on macOS, and rings the terminal bell
where neither is available.
"""

from __future__ import annotations

import platform
import shutil
import subprocess
import sys
import threading


def command(title: str, message: str) -> list[str] | None:
"""The command that shows a notification on this machine, or None."""
if platform.system() == "Darwin" and shutil.which("osascript"):
quoted = message.replace("\\", "\\\\").replace('"', '\\"')
heading = title.replace("\\", "\\\\").replace('"', '\\"')
return ["osascript", "-e", f'display notification "{quoted}" with title "{heading}"']
if shutil.which("notify-send"):
return ["notify-send", "--app-name=lcode", title, message]
return None


def desktop(title: str, message: str) -> None:
"""Show the notification without waiting for it; the terminal bell if there's no way to."""
argv = command(title, message[:200])
if argv is None:
sys.stderr.write("\a")
sys.stderr.flush()
return

def run() -> None:
try:
subprocess.run(argv, capture_output=True, timeout=10)
except (OSError, subprocess.SubprocessError):
sys.stderr.write("\a")
sys.stderr.flush()

threading.Thread(target=run, daemon=True).start()
Loading
Loading