lcode-cli 0.19.0__py3-none-any.whl → 0.21.0__py3-none-any.whl

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/__init__.py CHANGED
@@ -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
 
lcode/acp.py CHANGED
@@ -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
lcode/agent.py CHANGED
@@ -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:
lcode/api.py CHANGED
@@ -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:
lcode/cli.py CHANGED
@@ -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
 
lcode/config.py CHANGED
@@ -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 = {
lcode/jobs.py ADDED
@@ -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}"
lcode/notify.py ADDED
@@ -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()
lcode/planning.py CHANGED
@@ -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:
lcode/repl.py CHANGED
@@ -62,6 +62,7 @@ COMMANDS = {
62
62
  "/mode": "Permission mode: ask | plan | auto-edit | yolo (Shift+Tab cycles)",
63
63
  "/cd": "Change the working directory",
64
64
  "/todos": "Show the current todo list",
65
+ "/jobs": "Background commands the model started: /jobs, /jobs stop <id>",
65
66
  "/commit": "Commit the changes with a message in the repository's style, after you approve it",
66
67
  "/review": "Review the uncommitted changes, or the branch against a base: /review [base]",
67
68
  "/pr": "Push the branch and open a GitHub pull request, after you approve it: /pr [base] [notes]",
@@ -776,7 +777,28 @@ def web_status(agent: Agent) -> str:
776
777
  return f"{agent.settings.web} · {search}"
777
778
 
778
779
 
779
- LONG_REQUEST = 30 # seconds: a request this long sends a notification when it's done (notification hooks)
780
+ def jobs_command(agent: Agent, arg: str) -> None:
781
+ """List the background commands, or stop one: /jobs stop <id>."""
782
+ from lcode.jobs import JobError, describe
783
+
784
+ c = agent.console
785
+ word, _, job_id = arg.partition(" ")
786
+ if word == "stop":
787
+ try:
788
+ job = agent.jobs.stop(job_id or "?")
789
+ except JobError as e:
790
+ c.print(f"[red]{escape(str(e))}[/]")
791
+ return
792
+ c.print(f"Stopped {escape(describe(job))}")
793
+ return
794
+ if not agent.jobs.items:
795
+ c.print("No background commands. The model starts them with bash(background=true), e.g. a dev server.")
796
+ return
797
+ for job in agent.jobs.items.values():
798
+ style = "green" if job.running else "dim"
799
+ c.print(f"[{style}]{escape(describe(job))}[/]")
800
+ if agent.jobs.running():
801
+ c.print("[dim]/jobs stop <id> stops one; they all stop when the session ends.[/]")
780
802
 
781
803
 
782
804
  def git_command(agent: Agent, cmd: str, arg: str) -> None:
@@ -810,8 +832,7 @@ def run_safely(agent: Agent, text: str) -> None:
810
832
  agent.console.print(f"[red]{e}[/]")
811
833
  finally:
812
834
  agent.save()
813
- if time.monotonic() - started > LONG_REQUEST:
814
- agent.hooks.notify(f"lcode finished: {sessions.title_from([{'role': 'user', 'content': text}])}", agent.cwd)
835
+ agent.finished(text, time.monotonic() - started)
815
836
 
816
837
 
817
838
  def session_start(agent: Agent) -> None:
@@ -931,6 +952,8 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
931
952
  c.print(f"[red]Not a directory: {p}[/]")
932
953
  elif cmd == "/todos":
933
954
  agent.tools.show_todos()
955
+ elif cmd == "/jobs":
956
+ jobs_command(agent, arg)
934
957
  elif cmd == "/init":
935
958
  run_safely(agent, INIT_PROMPT)
936
959
  elif cmd in REPLACEABLE and cmd[1:] not in agent.extensions().commands:
lcode/subagents.py CHANGED
@@ -463,6 +463,7 @@ class Run:
463
463
  child = Agent(parent.ollama, settings, cwd, console=Console(file=io.StringIO(), width=120))
464
464
  child.perms = self.relay # type: ignore[assignment]
465
465
  child.sandbox = parent.sandbox
466
+ child.jobs = parent.jobs # its background commands belong to the session
466
467
  if not self.worktree:
467
468
  child.checkpoints = parent.checkpoints # changes in the shared tree are part of the running request
468
469
  child.mcp = None
lcode/tools.py CHANGED
@@ -25,6 +25,7 @@ from rich.text import Text
25
25
 
26
26
  from lcode import web
27
27
  from lcode.context import shorten
28
+ from lcode.jobs import JobError, describe, stop_leftovers
28
29
  from lcode.permissions import bash_key, is_read_only
29
30
  from lcode.planning import BLOCKED, PLAN_MODE_TOOLS
30
31
  from lcode.sandbox import SandboxError
@@ -38,6 +39,7 @@ NO_NETWORK = re.compile(
38
39
  r"EAI_AGAIN|Name or service not known|network is unreachable|No route to host"
39
40
  )
40
41
  MAX_TOOL_OUTPUT = 30_000 # characters returned to the model per tool call
42
+ LEFTOVER_GRACE = 0.5 # seconds to wait for output after a command ends, before stopping what it left running
41
43
  IGNORE_DIRS = {
42
44
  ".git", "node_modules", "__pycache__", ".venv", "venv", "env", ".mypy_cache", ".pytest_cache",
43
45
  ".ruff_cache", ".tox", ".idea", ".vscode", "dist", "build", ".next", "target", ".cache", ".gradle",
@@ -119,13 +121,28 @@ SCHEMAS = [
119
121
  "bash",
120
122
  "Run a shell command with bash in the working directory and return its output and exit code. The "
121
123
  "working directory persists between calls (cd works). Use it to run scripts and tests, use git, install "
122
- "packages, etc. Avoid interactive commands.",
124
+ "packages, etc. Avoid interactive commands. For a process that keeps running (a dev server, a watcher), "
125
+ "set background=true and give the plain command, without & or redirecting its output: lcode keeps it "
126
+ "running and keeps its output, you get a job id back and can carry on.",
123
127
  {
124
128
  "command": {"type": "string"},
125
129
  "timeout": {"type": "integer", "description": "Seconds before the command is killed (default 180)"},
130
+ "background": {
131
+ "type": "boolean",
132
+ "description": "Keep it running in the background; read its output with bash_output and stop it "
133
+ "with bash_stop (not with kill)",
134
+ },
126
135
  },
127
136
  ["command"],
128
137
  ),
138
+ _fn(
139
+ "bash_output",
140
+ "Read what a background command (bash with background=true) printed since you last read it, and whether "
141
+ "it's still running.",
142
+ {"id": {"type": "string", "description": "The job id bash returned"}},
143
+ ["id"],
144
+ ),
145
+ _fn("bash_stop", "Stop a background command.", {"id": {"type": "string"}}, ["id"]),
129
146
  _fn(
130
147
  "todo_write",
131
148
  "Create or update your task list for multi-step work. Pass the full list every time. Keep exactly one "
@@ -792,7 +809,7 @@ class Toolbox:
792
809
  return f"Edited {self.rel(p)} ({count} replacement(s)). Result:\n{snippet}" + problems
793
810
 
794
811
  # -- shell
795
- def t_bash(self, command: str, timeout: int = 180) -> str:
812
+ def t_bash(self, command: str, timeout: int = 180, background: bool = False) -> str:
796
813
  sandbox = self.agent.sandbox
797
814
  if self.agent.planning() and not is_read_only(command):
798
815
  raise ToolError(f"{BLOCKED} Until then, only read-only commands run (ls, cat, grep, git log, …).")
@@ -814,7 +831,9 @@ class Toolbox:
814
831
  return feedback
815
832
  if not is_read_only(command):
816
833
  self.agent.checkpoint()
817
- self.console.print(Text(f" $ {command}", style="bold cyan"))
834
+ self.console.print(Text(f" $ {command}" + (" (in the background)" if background else ""), style="bold cyan"))
835
+ if background is True or str(background).lower() == "true":
836
+ return self._background(command)
818
837
  marker = f"__lcode_cwd_{secrets.token_hex(8)}__"
819
838
  script = f"{command}\n__lcode_ec=$?\nprintf '\\n{marker}%s\\n' \"$(pwd -P)\"\nexit $__lcode_ec\n"
820
839
  token = ""
@@ -855,6 +874,8 @@ class Toolbox:
855
874
  out: list[str] = []
856
875
  shown, status, recorded = 0, "", ""
857
876
  deadline = time.time() + int(timeout or 180)
877
+ finished = 0.0 # when the command itself ended (its marker arrived)
878
+ held_open = False # something it started still holds the output open
858
879
  try:
859
880
  while True:
860
881
  if self.agent.cancel is not None and self.agent.cancel.is_set():
@@ -862,6 +883,9 @@ class Toolbox:
862
883
  try:
863
884
  line = lines.get(timeout=0.2)
864
885
  except queue.Empty:
886
+ if finished and time.time() - finished > LEFTOVER_GRACE:
887
+ held_open = True
888
+ break
865
889
  if time.time() > deadline:
866
890
  stop()
867
891
  status = f"\n[Command timed out after {timeout}s and was killed]"
@@ -871,6 +895,7 @@ class Toolbox:
871
895
  break
872
896
  if line.startswith(marker):
873
897
  recorded = line[len(marker) :].strip()
898
+ finished = time.time()
874
899
  continue
875
900
  out.append(line)
876
901
  if shown <= 40:
@@ -881,6 +906,13 @@ class Toolbox:
881
906
  stop()
882
907
  raise
883
908
  code = proc.wait()
909
+ if held_open and sandbox and token:
910
+ sandbox.kill(token) # what it left running in the container
911
+ if (not sandbox and stop_leftovers(proc.pid)) or held_open:
912
+ status += (
913
+ "\n[lcode stopped what this command left running (started with &). To keep a process running, "
914
+ "start it with background=true: then bash_output reads its output and bash_stop stops it]"
915
+ )
884
916
  if out and out[-1] == "\n":
885
917
  out.pop() # the blank line printed before the marker
886
918
  if recorded and Path(recorded).is_dir() and Path(recorded).resolve() != self.agent.cwd:
@@ -933,6 +965,51 @@ class Toolbox:
933
965
  raise ToolError(str(e)) from e
934
966
  return web.format_page(url, title, text, max(1000, min(int(max_chars or 20000), MAX_TOOL_OUTPUT)))
935
967
 
968
+ def _background(self, command: str) -> str:
969
+ sandbox, kill = self.agent.sandbox, None
970
+ if sandbox:
971
+ try:
972
+ with self.console.status("Starting the sandbox…"):
973
+ sandbox.ensure(self.agent.cwd)
974
+ except SandboxError as e:
975
+ return f"Error: the sandbox can't start, so the command didn't run: {e}"
976
+ argv, token = sandbox.exec_argv(self.agent.cwd, command)
977
+ kill = lambda: sandbox.kill(token) # noqa: E731
978
+ else:
979
+ argv = ["bash", "-c", command]
980
+ job = self.agent.jobs.start(argv, self.agent.cwd, command, kill)
981
+ first = job.new_output(MAX_TOOL_OUTPUT // 3).rstrip()
982
+ for line in first.splitlines()[:10]:
983
+ self.console.print(Text(" " + line[:300], style="dim"))
984
+ if not job.running:
985
+ return (
986
+ f"The command ended right away ({job.status()}), so it isn't running in the background:\n"
987
+ f"{first or '(no output)'}"
988
+ )
989
+ self.console.print(Text(f" ⎿ background job {job.id}, /jobs to see it", style="dim"))
990
+ return (
991
+ f"Started background job {job.id}: it keeps running while you work. Read its new output with "
992
+ f'bash_output(id="{job.id}") and stop it with bash_stop(id="{job.id}") when it\'s no longer needed.\n'
993
+ f"Output so far:\n{first or '(none yet)'}"
994
+ )
995
+
996
+ def t_bash_output(self, id: str) -> str:
997
+ try:
998
+ job = self.agent.jobs.get(id)
999
+ except JobError as e:
1000
+ raise ToolError(str(e)) from e
1001
+ text = job.new_output(MAX_TOOL_OUTPUT).rstrip()
1002
+ return f"[{describe(job)}]\n{text or '(no new output)'}"
1003
+
1004
+ def t_bash_stop(self, id: str) -> str:
1005
+ try:
1006
+ job = self.agent.jobs.stop(id)
1007
+ except JobError as e:
1008
+ raise ToolError(str(e)) from e
1009
+ self.console.print(Text(f" ⎿ {describe(job)}", style="dim"))
1010
+ last = job.new_output(4000).rstrip()
1011
+ return f"[{describe(job)}]" + (f"\nIts last output:\n{last}" if last else "")
1012
+
936
1013
  # -- planning
937
1014
  def t_todo_write(self, todos: list | str) -> str:
938
1015
  if isinstance(todos, str):
lcode/update.py ADDED
@@ -0,0 +1,557 @@
1
+ """`lcode update`: update lcode, and Ollama when it runs in Docker.
2
+
3
+ lcode updates itself the way it was installed: uv tool, pipx, Homebrew, pip, or `git pull` for a
4
+ checkout (an editable install).
5
+
6
+ For Ollama in Docker, the newest version is looked up on Docker Hub, never taken from a local
7
+ `latest` tag (which can be months old), and pulled by its version tag (`ollama/ollama:0.35.1`), so
8
+ `docker ps` shows which version runs. Release candidates are skipped, and a ROCm image stays ROCm.
9
+ - A container from Docker Compose gets the new tag in its compose file and is recreated with
10
+ `docker compose up`, so Compose keeps managing it.
11
+ - Any other container is recreated with the same name, ports, volumes, environment, GPUs, network
12
+ and restart policy. The old one is kept until the new one answers with the new version, and is
13
+ put back if it doesn't.
14
+ Ollama installed without Docker gets the command to update it, which lcode doesn't run itself.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import importlib.metadata
20
+ import json
21
+ import re
22
+ import shutil
23
+ import subprocess
24
+ import sys
25
+ import time
26
+ from collections.abc import Callable
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+ from urllib.parse import urlsplit
30
+
31
+ import requests
32
+
33
+ from lcode import __version__
34
+
35
+ IMAGE = "ollama/ollama"
36
+ HUB_TAGS = "https://hub.docker.com/v2/namespaces/ollama/repositories/ollama/tags"
37
+ PYPI = "https://pypi.org/pypi/lcode-cli/json"
38
+ SEMVER = re.compile(r"\d+\.\d+\.\d+")
39
+ START_TIMEOUT = 120 # seconds for a recreated container to answer
40
+ NATIVE = {
41
+ "linux": "curl -fsSL https://ollama.com/install.sh | sh",
42
+ "darwin": "brew upgrade ollama (or let the Ollama app update itself)",
43
+ }
44
+
45
+
46
+ class UpdateError(Exception):
47
+ pass
48
+
49
+
50
+ def version_key(version: str) -> tuple[int, ...]:
51
+ return tuple(int(p) for p in re.findall(r"\d+", version)[:3])
52
+
53
+
54
+ def run(argv: list[str], cwd: Path | None = None, timeout: float = 120) -> subprocess.CompletedProcess:
55
+ """A command whose output lcode reads (and tests replace)."""
56
+ try:
57
+ return subprocess.run(argv, cwd=cwd, capture_output=True, text=True, timeout=timeout)
58
+ except (OSError, subprocess.SubprocessError) as e:
59
+ raise UpdateError(f"{argv[0]} failed: {e}") from e
60
+
61
+
62
+ def stream(argv: list[str], cwd: Path | None = None) -> int:
63
+ """A command the user watches (pulls, installs); returns its exit code."""
64
+ try:
65
+ return subprocess.call(argv, cwd=cwd)
66
+ except OSError as e:
67
+ raise UpdateError(f"{argv[0]} failed: {e}") from e
68
+
69
+
70
+ def get_json(url: str, **params) -> dict:
71
+ try:
72
+ r = requests.get(url, params=params or None, timeout=20)
73
+ r.raise_for_status()
74
+ return r.json()
75
+ except (requests.RequestException, ValueError) as e:
76
+ raise UpdateError(f"couldn't read {url.split('?')[0]}: {e}") from e
77
+
78
+
79
+ # ----------------------------------------------------------------------------- the newest versions
80
+
81
+
82
+ def latest_lcode() -> str:
83
+ return str(get_json(PYPI)["info"]["version"])
84
+
85
+
86
+ def latest_ollama(flavor: str = "") -> str:
87
+ """The newest released Ollama image tag on Docker Hub: "0.35.1", or "0.35.1-rocm" for flavor "rocm"."""
88
+ names: list[str] = []
89
+ url, params = HUB_TAGS, {"page_size": 100, "ordering": "last_updated"}
90
+ for _ in range(3): # the newest tags come first; three pages reach well past them
91
+ page = get_json(url, **params)
92
+ names += [str(t.get("name", "")) for t in page.get("results") or []]
93
+ url, params = page.get("next"), {}
94
+ if not url:
95
+ break
96
+ suffix = f"-{flavor}" if flavor else ""
97
+ versions = [
98
+ n.removesuffix(suffix) for n in names if n.endswith(suffix) and SEMVER.fullmatch(n.removesuffix(suffix))
99
+ ]
100
+ if not versions:
101
+ raise UpdateError(f"Docker Hub lists no released {IMAGE} versions{f' for {flavor}' if flavor else ''}")
102
+ return max(versions, key=version_key) + suffix
103
+
104
+
105
+ def image_flavor(image: str) -> str:
106
+ """The image variant to stay on: "rocm" for ollama/ollama:rocm or :0.35.1-rocm, else ""."""
107
+ tag = image.rsplit(":", 1)[1] if ":" in image.rsplit("/", 1)[-1] else "latest"
108
+ return "rocm" if tag == "rocm" or tag.endswith("-rocm") else ""
109
+
110
+
111
+ # ----------------------------------------------------------------------------- lcode itself
112
+
113
+
114
+ @dataclass
115
+ class Install:
116
+ kind: str # checkout, uv, pipx, brew, uvx, pip
117
+ command: list[str] | None = None # what updates it
118
+ path: Path | None = None # the checkout, for kind checkout
119
+
120
+ def describe(self) -> str:
121
+ return {
122
+ "checkout": f"a git checkout at {self.path}",
123
+ "uv": "uv tool",
124
+ "pipx": "pipx",
125
+ "brew": "Homebrew",
126
+ "uvx": "uvx",
127
+ "pip": "pip",
128
+ }[self.kind]
129
+
130
+
131
+ def lcode_install(prefix: Path | None = None, direct_url: str | None = None) -> Install:
132
+ """How this lcode was installed."""
133
+ prefix = (prefix or Path(sys.prefix)).resolve()
134
+ if direct_url is None:
135
+ try:
136
+ direct_url = importlib.metadata.distribution("lcode-cli").read_text("direct_url.json") or ""
137
+ except importlib.metadata.PackageNotFoundError:
138
+ direct_url = ""
139
+ try:
140
+ origin = json.loads(direct_url) if direct_url else {}
141
+ except ValueError:
142
+ origin = {}
143
+ if (origin.get("dir_info") or {}).get("editable") and str(origin.get("url", "")).startswith("file://"):
144
+ return Install("checkout", path=Path(urlsplit(origin["url"]).path))
145
+ text = str(prefix)
146
+ if (prefix / "uv-receipt.toml").exists():
147
+ return Install("uv", ["uv", "tool", "upgrade", "lcode-cli"])
148
+ if "/Cellar/lcode/" in text:
149
+ return Install("brew", ["brew", "upgrade", "nasser1941/tap/lcode"])
150
+ if "pipx" in prefix.parts:
151
+ return Install("pipx", ["pipx", "upgrade", "lcode-cli"])
152
+ if "/uv/" in text and ("archive-v" in text or "/cache/" in text or "/.cache/" in text):
153
+ return Install("uvx")
154
+ return Install("pip", [sys.executable, "-m", "pip", "install", "--upgrade", "lcode-cli"])
155
+
156
+
157
+ @dataclass
158
+ class CheckoutState:
159
+ branch: str
160
+ dirty: bool
161
+ behind: int
162
+ upstream: str
163
+
164
+
165
+ def checkout_state(path: Path) -> CheckoutState:
166
+ def git(*args: str) -> str:
167
+ r = run(["git", "-C", str(path), *args], timeout=120)
168
+ if r.returncode != 0:
169
+ raise UpdateError(f"git {args[0]} failed in {path}: {(r.stderr or r.stdout).strip()[:200]}")
170
+ return r.stdout.strip()
171
+
172
+ branch = git("rev-parse", "--abbrev-ref", "HEAD")
173
+ dirty = bool(git("status", "--porcelain", "--untracked-files=no"))
174
+ upstream = run(["git", "-C", str(path), "rev-parse", "--abbrev-ref", "@{upstream}"]).stdout.strip()
175
+ behind = 0
176
+ if upstream:
177
+ git("fetch", "-q")
178
+ behind = int(git("rev-list", "--count", "HEAD..@{upstream}") or 0)
179
+ return CheckoutState(branch, dirty, behind, upstream)
180
+
181
+
182
+ def update_checkout(path: Path, install_receipt: bool) -> str:
183
+ """git pull a checkout lcode runs from; reinstall its environment when its dependencies changed."""
184
+ before = run(["git", "-C", str(path), "rev-parse", "HEAD"]).stdout.strip()
185
+ if stream(["git", "-C", str(path), "pull", "--ff-only"]) != 0:
186
+ raise UpdateError(f"git pull failed in {path}")
187
+ changed = run(["git", "-C", str(path), "diff", "--name-only", before, "HEAD"]).stdout.split()
188
+ reinstall = ["uv", "tool", "install", "--force", "--editable", str(path)]
189
+ if "pyproject.toml" in changed and install_receipt and stream(reinstall) != 0:
190
+ raise UpdateError("reinstalling lcode's environment failed: run uv tool install --force --editable .")
191
+ count = run(["git", "-C", str(path), "rev-list", "--count", f"{before}..HEAD"]).stdout.strip() or "0"
192
+ return f"{count} new commit(s)"
193
+
194
+
195
+ # ----------------------------------------------------------------------------- Ollama in Docker
196
+
197
+
198
+ @dataclass
199
+ class Container:
200
+ id: str
201
+ name: str
202
+ image: str # as configured, e.g. ollama/ollama:latest
203
+ info: dict = field(repr=False)
204
+
205
+ @property
206
+ def compose(self) -> dict | None:
207
+ labels = (self.info.get("Config") or {}).get("Labels") or {}
208
+ project, service = labels.get("com.docker.compose.project"), labels.get("com.docker.compose.service")
209
+ if not project or not service:
210
+ return None
211
+ files = [f for f in str(labels.get("com.docker.compose.project.config_files", "")).split(",") if f]
212
+ return {"project": project, "service": service, "files": files,
213
+ "workdir": labels.get("com.docker.compose.project.working_dir", "")} # fmt: skip
214
+
215
+
216
+ def docker_available() -> bool:
217
+ return shutil.which("docker") is not None and run(["docker", "info", "--format", "{{.ID}}"]).returncode == 0
218
+
219
+
220
+ def find_container(host: str) -> Container | None:
221
+ """The Ollama container that serves `host` (a local address), if Ollama runs in Docker."""
222
+ parts = urlsplit(host)
223
+ if parts.hostname not in ("localhost", "127.0.0.1", "::1", "0.0.0.0", None):
224
+ return None
225
+ port = str(parts.port or 11434)
226
+ ids = run(["docker", "ps", "-q"]).stdout.split()
227
+ if not ids:
228
+ return None
229
+ infos = json.loads(run(["docker", "inspect", *ids]).stdout or "[]")
230
+ found = []
231
+ for info in infos:
232
+ image = (info.get("Config") or {}).get("Image", "")
233
+ if not image.startswith(IMAGE):
234
+ continue
235
+ bindings = ((info.get("HostConfig") or {}).get("PortBindings") or {}).get("11434/tcp") or []
236
+ if any(str(b.get("HostPort")) == port for b in bindings):
237
+ found.append(Container(info["Id"][:12], info["Name"].lstrip("/"), image, info))
238
+ return found[0] if len(found) == 1 else None
239
+
240
+
241
+ def set_compose_image(text: str, service: str, image: str) -> tuple[str, str]:
242
+ """The compose file with the service's image changed, and the image it had."""
243
+ lines = text.splitlines(keepends=True)
244
+ services = next((i for i, line in enumerate(lines) if re.match(r"services:\s*(#.*)?$", line)), None)
245
+ if services is None:
246
+ raise UpdateError("no services: section")
247
+ start = indent = None
248
+ for i in range(services + 1, len(lines)):
249
+ m = re.match(rf"(\s+)['\"]?{re.escape(service)}['\"]?:\s*(#.*)?$", lines[i])
250
+ if m:
251
+ start, indent = i, len(m.group(1))
252
+ break
253
+ if lines[i].strip() and not lines[i][0].isspace() and not lines[i].lstrip().startswith("#"):
254
+ break # the next top-level key: services ended
255
+ if start is None or indent is None:
256
+ raise UpdateError(f"no service {service} in it")
257
+ for i in range(start + 1, len(lines)):
258
+ line = lines[i]
259
+ if line.strip() and not line.lstrip().startswith("#") and len(line) - len(line.lstrip()) <= indent:
260
+ break # the next service
261
+ m = re.match(r"(\s+image:\s*)(['\"]?)([^'\"#\s]+)\2(\s*(#.*)?)$", line.rstrip("\n"))
262
+ if m:
263
+ if "${" in line:
264
+ raise UpdateError(f"the image comes from a variable ({m.group(3)}): set it to {image}")
265
+ newline = "\n" if line.endswith("\n") else ""
266
+ lines[i] = f"{m.group(1)}{m.group(2)}{image}{m.group(2)}{m.group(4)}{newline}"
267
+ return "".join(lines), m.group(3)
268
+ raise UpdateError(f"service {service} has no image: line (it may be built from a Dockerfile)")
269
+
270
+
271
+ def run_args(info: dict, image_info: dict, image: str) -> list[str]:
272
+ """`docker run` arguments that recreate a container with another image and the same settings."""
273
+ config, host = info.get("Config") or {}, info.get("HostConfig") or {}
274
+ base = image_info.get("Config") or {}
275
+ args = ["docker", "run", "-d", "--name", info["Name"].lstrip("/")]
276
+ restart = host.get("RestartPolicy") or {}
277
+ if restart.get("Name") not in (None, "", "no"):
278
+ retries = restart.get("MaximumRetryCount") or 0
279
+ args += ["--restart", restart["Name"] + (f":{retries}" if restart["Name"] == "on-failure" and retries else "")]
280
+ for port, bindings in (host.get("PortBindings") or {}).items():
281
+ target = port.removesuffix("/tcp")
282
+ for b in bindings or []:
283
+ ip = b.get("HostIp") or ""
284
+ args += ["-p", f"{ip + ':' if ip else ''}{b.get('HostPort', '')}:{target}"]
285
+ for m in info.get("Mounts") or []:
286
+ source = m.get("Name") if m.get("Type") == "volume" else m.get("Source")
287
+ if source and m.get("Type") in ("volume", "bind"):
288
+ args += ["-v", f"{source}:{m['Destination']}" + ("" if m.get("RW", True) else ":ro")]
289
+ default_env = set(base.get("Env") or [])
290
+ for entry in config.get("Env") or []:
291
+ if entry not in default_env: # only what was set for this container; the new image brings its own PATH
292
+ args += ["-e", entry]
293
+ for request in host.get("DeviceRequests") or []:
294
+ if ["gpu"] in (request.get("Capabilities") or []):
295
+ ids = request.get("DeviceIDs") or []
296
+ count = request.get("Count") or 0
297
+ args += ["--gpus", f"device={','.join(ids)}" if ids else ("all" if count == -1 else str(count))]
298
+ for device in host.get("Devices") or []:
299
+ args += ["--device", f"{device['PathOnHost']}:{device['PathInContainer']}"]
300
+ if host.get("Runtime") not in (None, "", "runc"):
301
+ args += ["--runtime", host["Runtime"]]
302
+ network = host.get("NetworkMode") or "default"
303
+ if network not in ("default", "bridge") and not network.startswith("container:"):
304
+ args += ["--network", network]
305
+ endpoint = ((info.get("NetworkSettings") or {}).get("Networks") or {}).get(network) or {}
306
+ name, short_id = info["Name"].lstrip("/"), info["Id"][:12]
307
+ for alias in endpoint.get("Aliases") or []:
308
+ if alias not in (name, short_id):
309
+ args += ["--network-alias", alias]
310
+ for host_entry in host.get("ExtraHosts") or []:
311
+ args += ["--add-host", host_entry]
312
+ base_labels = base.get("Labels") or {}
313
+ for key, value in (config.get("Labels") or {}).items():
314
+ if base_labels.get(key) != value:
315
+ args += ["--label", f"{key}={value}"]
316
+ entrypoint, cmd = config.get("Entrypoint") or [], config.get("Cmd") or []
317
+ if entrypoint and entrypoint != (base.get("Entrypoint") or []):
318
+ args += ["--entrypoint", entrypoint[0]]
319
+ cmd = [*entrypoint[1:], *cmd]
320
+ elif cmd == (base.get("Cmd") or []):
321
+ cmd = []
322
+ return [*args, image, *cmd]
323
+
324
+
325
+ def wait_for_version(host: str, wanted: str, timeout: float = START_TIMEOUT) -> bool:
326
+ from lcode.ollama import Ollama, OllamaError
327
+
328
+ deadline = time.monotonic() + timeout
329
+ while time.monotonic() < deadline:
330
+ try:
331
+ if Ollama(host).version() == wanted.removesuffix("-rocm"):
332
+ return True
333
+ except OllamaError:
334
+ pass
335
+ time.sleep(2)
336
+ return False
337
+
338
+
339
+ def recreate(container: Container, image: str, host: str, say: Callable[[str], None]) -> None:
340
+ """Replace a plain container with one on `image`; put the old one back if that fails."""
341
+ image_info = json.loads(run(["docker", "image", "inspect", container.info["Image"]]).stdout or "[{}]")[0]
342
+ args = run_args(container.info, image_info, image)
343
+ backup = f"{container.name}-before-lcode-update"
344
+ for step in (["docker", "stop", container.name], ["docker", "rename", container.name, backup]):
345
+ r = run(step)
346
+ if r.returncode != 0:
347
+ raise UpdateError(f"{' '.join(step[:2])} failed: {(r.stderr or r.stdout).strip()}")
348
+ say(f"$ {' '.join(args)}")
349
+ started = run(args)
350
+ if started.returncode == 0 and wait_for_version(host, image.rsplit(":", 1)[1]):
351
+ run(["docker", "rm", backup])
352
+ return
353
+ problem = (started.stderr or started.stdout).strip() or "it didn't answer with the new version"
354
+ run(["docker", "rm", "-f", container.name])
355
+ run(["docker", "rename", backup, container.name])
356
+ run(["docker", "start", container.name])
357
+ raise UpdateError(f"the new container didn't start ({problem[:300]}); the old one is running again")
358
+
359
+
360
+ def recreate_with_compose(container: Container, image: str, host: str, say: Callable[[str], None]) -> None:
361
+ command, project_dir = compose_up(container.compose or {})
362
+ say(f"$ {' '.join(command)}")
363
+ if stream(command, cwd=project_dir) != 0:
364
+ raise UpdateError("docker compose up failed")
365
+ if not wait_for_version(host, image.rsplit(":", 1)[1]):
366
+ raise UpdateError(f"Ollama didn't answer with version {image.rsplit(':', 1)[1]} after the restart")
367
+
368
+
369
+ def compose_up(compose: dict) -> tuple[list[str], Path]:
370
+ """`docker compose up` for just the Ollama service of its project, and where to run it."""
371
+ files = [Path(f) for f in compose["files"]]
372
+ project_dir = Path(compose["workdir"]) if compose.get("workdir") else files[0].parent
373
+ command = ["docker", "compose", "-p", compose["project"], "--project-directory", str(project_dir)]
374
+ for f in files:
375
+ command += ["-f", str(f)]
376
+ return [*command, "up", "-d", "--no-deps", compose["service"]], project_dir
377
+
378
+
379
+ def compose_change(container: Container, image: str) -> tuple[Path, str, str]:
380
+ """Which compose file defines the service's image, and its new text."""
381
+ compose = container.compose or {}
382
+ problems = []
383
+ for path in reversed([Path(f) for f in compose.get("files") or []]): # later files override earlier ones
384
+ try:
385
+ text, old = set_compose_image(path.read_text(), compose["service"], image)
386
+ return path, text, old
387
+ except (OSError, UpdateError) as e:
388
+ problems.append(f"{path}: {e}")
389
+ raise UpdateError("couldn't find the image to change: " + "; ".join(problems))
390
+
391
+
392
+ # ----------------------------------------------------------------------------- lcode update
393
+
394
+
395
+ def command(console, what: str = "", check: bool = False, yes: bool = False) -> int:
396
+ """Update lcode and Ollama (or `what`: "lcode" or "ollama"). Returns the exit code."""
397
+ from rich.prompt import Confirm
398
+
399
+ def ask(question: str) -> bool:
400
+ if yes:
401
+ return True
402
+ if not sys.stdin.isatty():
403
+ console.print("[dim]Not asking without a terminal: run lcode update -y to go ahead.[/]")
404
+ return False
405
+ return Confirm.ask(question, default=True, console=console)
406
+
407
+ failed = False
408
+ for part, step in (("lcode", update_lcode), ("ollama", update_ollama)):
409
+ if what and what != part:
410
+ continue
411
+ try:
412
+ step(console, check, ask)
413
+ except UpdateError as e:
414
+ console.print(f"[red]✗ {escape(str(e))}[/]")
415
+ failed = True
416
+ console.print()
417
+ return 1 if failed else 0
418
+
419
+
420
+ def escape(text: str) -> str:
421
+ from rich.markup import escape as rich_escape
422
+
423
+ return rich_escape(text)
424
+
425
+
426
+ def update_lcode(console, check: bool, ask: Callable[[str], bool]) -> None:
427
+ install = lcode_install()
428
+ if install.kind == "checkout" and install.path is not None:
429
+ state = checkout_state(install.path)
430
+ where = f"lcode {__version__} · {install.describe()}, branch {state.branch}"
431
+ if not state.upstream:
432
+ console.print(f"{where}: no upstream branch to update from.")
433
+ elif not state.behind:
434
+ console.print(f"[green]✓[/] {where}: up to date with {state.upstream}.")
435
+ elif check:
436
+ console.print(f"{where}: {state.behind} new commit(s) on {state.upstream}.")
437
+ elif state.dirty or state.branch not in ("main", "master"):
438
+ why = "has uncommitted changes" if state.dirty else f"is on branch {state.branch}"
439
+ console.print(
440
+ f"{where}: {state.behind} new commit(s) on {state.upstream}, but the checkout {why}. Update it yourself with git pull."
441
+ )
442
+ elif ask(f"Pull {state.behind} new commit(s) into {install.path}?"):
443
+ receipt = (Path(sys.prefix) / "uv-receipt.toml").exists()
444
+ console.print(f"[green]✓[/] lcode updated: {update_checkout(install.path, receipt)}.")
445
+ return
446
+ latest = latest_lcode()
447
+ if version_key(latest) <= version_key(__version__):
448
+ console.print(f"[green]✓[/] lcode {__version__} is the newest version ({install.describe()}).")
449
+ return
450
+ if install.command is None:
451
+ console.print(
452
+ f"lcode {__version__} → {latest} is out. You run lcode with uvx: use uvx --refresh --from lcode-cli lcode"
453
+ )
454
+ return
455
+ shown = " ".join(install.command)
456
+ if check:
457
+ console.print(f"lcode {__version__} → {latest} is out ({install.describe()}): {shown}")
458
+ return
459
+ if not ask(f"Update lcode {__version__} → {latest} with `{shown}`?"):
460
+ return
461
+ if stream(install.command) != 0:
462
+ raise UpdateError(f"{shown} failed")
463
+ console.print("[green]✓[/] lcode updated; the new version runs from the next `lcode`.")
464
+ if install.kind == "brew":
465
+ console.print(
466
+ "[dim]Homebrew's formula follows a release by up to a day: if it's still the old version, try again later.[/]"
467
+ )
468
+
469
+
470
+ def update_ollama(console, check: bool, ask: Callable[[str], bool]) -> None:
471
+ from lcode import config
472
+ from lcode.hardware import detect
473
+ from lcode.ollama import Ollama, OllamaError
474
+
475
+ cfg = config.load()
476
+ if cfg["backend"] != "ollama":
477
+ console.print(f"lcode uses another model server (backend = {cfg['backend']}): update it there.")
478
+ return
479
+ host = cfg["ollama_host"]
480
+ try:
481
+ current = Ollama(host).version()
482
+ except OllamaError:
483
+ current = ""
484
+ container = find_container(host) if docker_available() else None
485
+ if container is None:
486
+ if not current:
487
+ raise UpdateError(f"Ollama doesn't answer at {host}: start it, then run lcode update again")
488
+ latest = latest_ollama()
489
+ local = urlsplit(host).hostname in ("localhost", "127.0.0.1", "::1", "0.0.0.0", None)
490
+ if version_key(latest) <= version_key(current):
491
+ console.print(f"[green]✓[/] Ollama {current} is the newest version.")
492
+ elif not local:
493
+ console.print(f"Ollama {current} → {latest} is out. It runs on {host}: update it on that machine.")
494
+ else:
495
+ hint = NATIVE.get(detect().os, NATIVE["linux"])
496
+ console.print(
497
+ f"Ollama {current} → {latest} is out. Ollama isn't in a Docker container here, so update it with:\n {hint}"
498
+ )
499
+ return
500
+ latest = latest_ollama(image_flavor(container.image))
501
+ image = f"{IMAGE}:{latest}"
502
+ compose = container.compose
503
+ where = f"container {container.name}, image {container.image}" + (
504
+ f", Docker Compose project {compose['project']}" if compose else ""
505
+ )
506
+ console.print(f"Ollama {current or '?'} · {where}. Newest on Docker Hub: {latest}.")
507
+ if container.image == image and current == latest.removesuffix("-rocm"):
508
+ console.print(f"[green]✓[/] Ollama is up to date, on {image}.")
509
+ return
510
+ if current == latest.removesuffix("-rocm"):
511
+ console.print(
512
+ f"[dim]It already runs {current}, but as {container.image}: pinning it to {image} shows the version in docker ps.[/]"
513
+ )
514
+ change = compose_change(container, image) if compose else None
515
+ plan = [f"docker pull {image}"]
516
+ if change:
517
+ path, _, old = change
518
+ plan.append(f"in {path}, service {compose['service']}: image {old} → {image}") # type: ignore[index]
519
+ plan.append(f"docker compose up -d --no-deps {compose['service']}") # type: ignore[index]
520
+ else:
521
+ plan.append(
522
+ f"recreate container {container.name} on {image}, with the same name, ports, volumes, environment, GPUs, network and restart policy (the old one is kept until the new one answers)"
523
+ )
524
+ console.print("\n".join(f" {i}. {escape(step)}" for i, step in enumerate(plan, 1)))
525
+ if check:
526
+ return
527
+ console.print("[dim]Ollama restarts: requests running now stop, and models load again on their next request.[/]")
528
+ if not ask("Update Ollama?"):
529
+ return
530
+ if stream(["docker", "pull", image]) != 0:
531
+ raise UpdateError(f"docker pull {image} failed")
532
+
533
+ def say(text: str) -> None:
534
+ console.print(f"[dim]{escape(text)}[/]")
535
+
536
+ if change:
537
+ path, text, _ = change
538
+ original = path.read_text()
539
+ path.write_text(text)
540
+ try:
541
+ recreate_with_compose(container, image, host, say)
542
+ except UpdateError:
543
+ path.write_text(original)
544
+ say(f"put {path} back the way it was")
545
+ command, workdir = compose_up(compose) # type: ignore[arg-type]
546
+ stream(command, cwd=workdir)
547
+ raise
548
+ else:
549
+ recreate(container, image, host, say)
550
+ console.print(f"[green]✓[/] Ollama {current or '?'} → {latest.removesuffix('-rocm')}, running as {image}.")
551
+ old_image = container.info.get("Image", "")
552
+ if old_image and not run(["docker", "ps", "-aq", "--filter", f"ancestor={old_image}"]).stdout.strip():
553
+ size = run(["docker", "image", "inspect", old_image, "--format", "{{.Size}}"]).stdout.strip()
554
+ gb = f" ({int(size) / 1e9:.1f} GB)" if size.isdigit() else ""
555
+ console.print(
556
+ f"[dim]The previous image is still on disk{gb}. To remove it: docker image rm {old_image[7:19]}[/]"
557
+ )
@@ -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
@@ -1,37 +1,40 @@
1
- lcode/__init__.py,sha256=7yjbvkgWF_Vh6y72kETMa3SVkh4RP81rLJzvo11gIV8,473
1
+ lcode/__init__.py,sha256=CcNLwxNc-qPLNMDrdZMe5h6rRYH_9ISij5uyXwc5PBA,473
2
2
  lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
- lcode/acp.py,sha256=M_CdTVqRSmLn33QD56Le4238Vz1ZxffnHCfjmajSv34,28940
3
+ lcode/acp.py,sha256=kRD_uuDDdGXt7xwvXZ8OqePK1HprEV76wWAWkV_gcak,28987
4
4
  lcode/action.py,sha256=1r3abjheYFYQ-EUArvotls8pLmE3YjNMGHAy_iF0Z70,16198
5
- lcode/agent.py,sha256=pB7rDZIbWiRrc8Ye4rA_LzHVqJ36yoTUN6nFsUeg428,49664
6
- lcode/api.py,sha256=kXlzXhGwkNdW9sWpxgOvL9h5pPzhzKRLXKrL1-eN3XY,15110
5
+ lcode/agent.py,sha256=FgZJpGD1a46qaa1nixZYXS3rPaFaJgnGpfoUi7ikhGY,51106
6
+ lcode/api.py,sha256=rOpcA2-axzUj_DppH3EelanVnwd-2O4MgN91OXGJePY,15250
7
7
  lcode/backends.py,sha256=dtJHrC6ys73bRDg64z0tQz1PavmeAkiODZu1fOO9ExQ,20635
8
8
  lcode/bench.py,sha256=XVdk0hDxmRcjgROOCgwwOsExcUhIxgh0B2vnxtGnA3o,40742
9
9
  lcode/bench_backend.py,sha256=X2fo-zRX92Xp3KMxSsFlWioc2vRupBTtowj5kLME8mw,14287
10
10
  lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
11
11
  lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
12
- lcode/cli.py,sha256=zo2QTHrdxVaJwIjCEOsfR99byPqeCkKHE5p_Xh8kfb8,39471
12
+ lcode/cli.py,sha256=ue5wsoFxraXXVu7X32S4tbKPLKYhgeI6zpK2i5nXXlU,40180
13
13
  lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
14
- lcode/config.py,sha256=nFljzjHTFaCTTbagycagbh2X_PpLtsUs-yRODiy4QcU,10065
14
+ lcode/config.py,sha256=eDEvOAA1plsSjvihzHEwGjNVgvcz3Pb-AR5DY5JmXnM,10255
15
15
  lcode/context.py,sha256=qJymLE3eYeaF6qVOzlUwDURqlky__8cskl_6vXeU3AA,10701
16
16
  lcode/extensions.py,sha256=W0HFDl0BvROzr7i_qtabHdIbacBgP5o5qIJLzoM2oNE,16806
17
17
  lcode/frontmatter.py,sha256=Bx0n4vLPhp7ZCWx-g7xqzqNy5VoKBxyeeikh5UFB_gA,2441
18
18
  lcode/gitflow.py,sha256=y78dqqh80is-MiDTnSHnK9vjx0vFovor_SZ8hW1NJaU,27167
19
19
  lcode/hardware.py,sha256=2VbknZCxTZJwJUD8YWdQ7ryEQo_LcjtoOBOHOU9Y0fw,3085
20
20
  lcode/hooks.py,sha256=K0itOiAuH4rxndJ9gne_uCrqby6oWAzFPRrfB3tCq-E,9324
21
+ lcode/jobs.py,sha256=P1P3FqI6EqL4LgdMX9YGZwCW0Bh5BRXFA_FnQxpHNjE,5618
21
22
  lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
22
23
  lcode/lsp.py,sha256=NtSW7xapv7fg2HAQLuYK1nwIqT7nG0LK1hiTpOmla_4,26086
23
24
  lcode/memory.py,sha256=WZEHTp-lVxAwS-yyLQR2xB3TMZ9sejmX2Ivg7T-tl3w,21949
24
25
  lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
26
+ lcode/notify.py,sha256=uieDeXe7XwuVHjOk4tqVyZEW_85XWkoWQmFOWEf77oc,1538
25
27
  lcode/ollama.py,sha256=u5tnvL28cfaFS4GhmiEsHhTQe6w3Ng_Tej05UUzLQTQ,7715
26
28
  lcode/permissions.py,sha256=i04YLtqYCxwApgawomPp4d35uk9HAJtpKY__gfw8j9s,6347
27
- lcode/planning.py,sha256=mGXMU-khHyGHE8IHxxrsQc-FFOzbdRa4MWHMU19UR7o,6439
29
+ lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
28
30
  lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
29
- lcode/repl.py,sha256=zJ2hj1c-tx35Sms8V-1W4XsyfFZ9omu9bZgGpEgBYi8,47655
31
+ lcode/repl.py,sha256=pQAPePNsBxJfcuif2Cvyxr0FSeO_DVGv_T5cL9BrWu4,48470
30
32
  lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
31
33
  lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
32
34
  lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
33
- lcode/subagents.py,sha256=S_ZBpqnKFxRKhe-lE-I8SHc3yMOhe0zW-SD2K-Gwn28,28832
34
- lcode/tools.py,sha256=Y4XmQsoLIA32S255DnCwOwebGPgQay1k7H_A9R5DINA,43145
35
+ lcode/subagents.py,sha256=rD7lnIioodirh8eVnXvRWdZx79J_rscPLssD2Gt7KIo,28914
36
+ lcode/tools.py,sha256=pOdA4ihQHSs8yWUzfxhiLw3GajF5Dr1edxVX0Akr6CU,47257
37
+ lcode/update.py,sha256=i92bnW252VneYh0vD6djxmXsDOa5MXP20bjlLZUue_c,25236
35
38
  lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
36
39
  lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
37
40
  lcode/mcp/__init__.py,sha256=XujtAmRoXW9S0uq1XPf_CAeCkHh_UvA8FCmoAKP9j1Y,171
@@ -49,8 +52,8 @@ lcode/mcp/stdio.py,sha256=kC6KHwzMMDjBr-yCZLmyVKS7PZXU299WpZdYRoX5eRQ,6292
49
52
  lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
50
53
  lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
51
54
  lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
52
- lcode_cli-0.19.0.dist-info/METADATA,sha256=cUJGqerCzNU65yzxUbn48STtIOP3fjvVbItArdo1vJ4,12642
53
- lcode_cli-0.19.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
54
- lcode_cli-0.19.0.dist-info/entry_points.txt,sha256=pMz2Byj2QRyt-ZZQYZvmWUtmKPCeE7f8iCvu-sjZPIo,133
55
- lcode_cli-0.19.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
56
- lcode_cli-0.19.0.dist-info/RECORD,,
55
+ lcode_cli-0.21.0.dist-info/METADATA,sha256=5G93iKQq-92dbzpKJxamXKr9giXtXiVm0AsqhcJf4NU,12527
56
+ lcode_cli-0.21.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
57
+ lcode_cli-0.21.0.dist-info/entry_points.txt,sha256=pMz2Byj2QRyt-ZZQYZvmWUtmKPCeE7f8iCvu-sjZPIo,133
58
+ lcode_cli-0.21.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
59
+ lcode_cli-0.21.0.dist-info/RECORD,,