lcode-cli 0.4.1__py3-none-any.whl → 0.6.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
@@ -1,3 +1,3 @@
1
1
  """lcode — a local-first terminal coding agent powered by open-weight models via Ollama."""
2
2
 
3
- __version__ = "0.4.1"
3
+ __version__ = "0.6.0"
lcode/agent.py CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import base64
5
6
  import datetime as dt
6
7
  import json
7
8
  import platform
@@ -19,18 +20,21 @@ from rich.markup import escape
19
20
  from rich.panel import Panel
20
21
  from rich.text import Text
21
22
 
22
- from lcode import catalog, limits, sessions, web
23
+ from lcode import catalog, limits, sessions, vision, web
23
24
  from lcode.checkpoints import Checkpoints
24
25
  from lcode.config import format_tokens
25
26
  from lcode.mcp import McpManager
26
27
  from lcode.ollama import Ollama, OllamaError
27
28
  from lcode.permissions import Permissions
28
29
  from lcode.render import MarkdownStreamer
30
+ from lcode.sandbox import Sandbox, SandboxError, project_root
29
31
  from lcode.tools import (
30
32
  SCHEMAS,
33
+ VIEW_IMAGE_SCHEMA,
31
34
  WEB_FETCH_SCHEMA,
32
35
  WEB_SEARCH_SCHEMA,
33
36
  Toolbox,
37
+ ToolError,
34
38
  is_binary,
35
39
  parse_text_tool_calls,
36
40
  tree,
@@ -140,6 +144,10 @@ class Settings:
140
144
  search_backend: str = "auto"
141
145
  searxng_url: str | None = None
142
146
  checkpoints: bool = True # snapshot files before the model changes them, for /undo
147
+ sandbox: str = "off" # off, docker or podman: where the model's shell commands run
148
+ sandbox_image: str | None = None
149
+ sandbox_network: bool = False
150
+ vision_model: str = "auto" # auto, off or an Ollama model that can see images
143
151
 
144
152
 
145
153
  class Agent:
@@ -153,7 +161,13 @@ class Agent:
153
161
  self.session_id = self.new_session_id()
154
162
  self.checkpoints = Checkpoints(self.console, settings.checkpoints)
155
163
  self.mcp: McpManager | None = None # set by the CLI when MCP servers are configured
164
+ self.sandbox = (
165
+ Sandbox(settings.sandbox, settings.sandbox_image, settings.sandbox_network, self.console)
166
+ if settings.sandbox != "off"
167
+ else None
168
+ )
156
169
  self._mcp_prompt = "" # the MCP part at the end of the system prompt
170
+ self._vision: str | bool | None = False # the model that looks at images; False = not decided yet
157
171
  self.session_name = ""
158
172
  self.session_title = ""
159
173
  self.ctx_used = 0
@@ -195,10 +209,65 @@ class Agent:
195
209
  schemas = list(SCHEMAS)
196
210
  if self.settings.web != "off":
197
211
  schemas += [*([WEB_SEARCH_SCHEMA] if self.search_backend() else []), WEB_FETCH_SCHEMA]
212
+ if self.vision_model():
213
+ schemas.append(VIEW_IMAGE_SCHEMA)
198
214
  if self.mcp:
199
215
  schemas += self.mcp.schemas(self.settings.context)
200
216
  return schemas
201
217
 
218
+ def vision_model(self) -> str | None:
219
+ """The model that looks at images for this session, if any (see lcode.vision)."""
220
+ if self._vision is False:
221
+ self._vision = vision.pick_model(self.ollama, self.settings.model, self.settings.vision_model)
222
+ return self._vision or None
223
+
224
+ def free_gpu(self) -> list[str]:
225
+ """Unload lcode's models from Ollama so another program can use the GPU; the next request reloads.
226
+
227
+ Only lcode's own models: the session's and the one that looks at images.
228
+ """
229
+ mine = {self.settings.model, *([self._vision] if isinstance(self._vision, str) else [])}
230
+ freed = []
231
+ for entry in self.ollama.running():
232
+ name = entry.get("name") or entry.get("model") or ""
233
+ if name in mine or name.removesuffix(":latest") in mine:
234
+ try:
235
+ self.ollama.unload(name)
236
+ freed.append(name.removesuffix(":latest"))
237
+ except OllamaError:
238
+ pass
239
+ return freed
240
+
241
+ def look(self, path: Path, question: str = "") -> str:
242
+ """Describe an image with the vision model. Raises ToolError if it can't."""
243
+ model = self.vision_model()
244
+ if not model:
245
+ raise ToolError(f"can't look at {path.name}: {vision.INSTALL_HINT}")
246
+ try:
247
+ image = vision.read_image(path)
248
+ # The session's own model keeps its settings, so Ollama doesn't reload it.
249
+ options = self.options() if model == self.settings.model else None
250
+ loading = "" if model == self.settings.model else " (loads it; the next request reloads the main model)"
251
+ with self.console.status(f"Looking at {path.name} with {model}{loading}…", spinner="dots"):
252
+ return vision.describe(self.ollama, model, image, question, options, self.settings.keep_alive)
253
+ except vision.VisionError as e:
254
+ raise ToolError(str(e)) from e
255
+
256
+ def describe_image_data(self, data: str, mime: str) -> str:
257
+ """For images that tools return (e.g. an MCP browser's screenshots): a description, or a note."""
258
+ model = self.vision_model()
259
+ if not model:
260
+ return f"[{mime} image not shown: {vision.INSTALL_HINT}]"
261
+ options = self.options() if model == self.settings.model else None
262
+ try:
263
+ with self.console.status(f"Looking at the {mime} image with {model}…", spinner="dots"):
264
+ text = vision.describe(
265
+ self.ollama, model, base64.b64decode(data), "", options, self.settings.keep_alive
266
+ )
267
+ except (vision.VisionError, ValueError) as e:
268
+ return f"[{mime} image not shown: {e}]"
269
+ return f"[{mime} image, as described by {model}]\n{text}"
270
+
202
271
  def prepare_mcp(self) -> None:
203
272
  """Before a request: wait for MCP servers still starting and describe them in the system prompt."""
204
273
  if not self.mcp:
@@ -462,6 +531,15 @@ class Agent:
462
531
  finally:
463
532
  self.checkpoints.end_turn()
464
533
 
534
+ def sandbox_root(self) -> Path | None:
535
+ """The folder the model is limited to while the sandbox is on (None when it's off)."""
536
+ if not self.sandbox:
537
+ return None
538
+ try:
539
+ return project_root(self.cwd)
540
+ except SandboxError:
541
+ return self.cwd
542
+
465
543
  def checkpoint(self) -> None:
466
544
  """Called before the model changes files: snapshot them once per request, for /undo."""
467
545
  self.checkpoints.before_change(self.cwd)
@@ -541,7 +619,17 @@ class Agent:
541
619
  attached = []
542
620
  for ref in re.findall(r"(?<!\S)@([\w./~\-]+)", text):
543
621
  p = self.tools.resolve(ref)
544
- if p.is_file() and not is_binary(p) and p.stat().st_size < 200_000:
622
+ if p.is_file() and vision.is_image(p):
623
+ try:
624
+ description = self.look(p, re.sub(r"(?<!\S)@[\w./~\-]+", "", text))
625
+ who = f' described_by="{self.vision_model()}"'
626
+ except ToolError as e:
627
+ description, who = f"(lcode couldn't look at this image: {e})", ""
628
+ self.console.print(Text(f" ⎿ {e}", style="yellow"))
629
+ else:
630
+ self.console.print(Text(f" ⎿ looked at {self.tools.rel(p)}", style="dim"))
631
+ attached.append(f'<image path="{self.tools.rel(p)}"{who}>\n{description}\n</image>')
632
+ elif p.is_file() and not is_binary(p) and p.stat().st_size < 200_000:
545
633
  attached.append(f'<file path="{self.tools.rel(p)}">\n{p.read_text(errors="replace")}\n</file>')
546
634
  self.tools.read_mtimes[str(p)] = p.stat().st_mtime
547
635
  self.console.print(Text(f" ⎿ attached {self.tools.rel(p)}", style="dim"))
lcode/cli.py CHANGED
@@ -273,6 +273,17 @@ def cmd_doctor(args) -> None:
273
273
  if spec:
274
274
  detail += f" · ~{spec.memory_gib(ctx):.0f} GB needed, ~{hw.budget_gib:.0f} GB available"
275
275
  line("Context", detail + (f" ({note})" if note else ""), None if note else True)
276
+ from lcode import vision
277
+
278
+ seer = vision.pick_model(ollama, model, cfg["vision_model"])
279
+ if seer:
280
+ line(
281
+ "Vision",
282
+ f"{seer} looks at screenshots and images" + ("" if seer == model else " (loaded when needed)"),
283
+ True,
284
+ )
285
+ else:
286
+ line("Vision", "off" if cfg["vision_model"] == "off" else vision.INSTALL_HINT, None)
276
287
  if limits.get(model):
277
288
  line(
278
289
  "Limit",
@@ -321,6 +332,15 @@ def cmd_doctor(args) -> None:
321
332
  except ValueError as e:
322
333
  line("MCP", str(e), False)
323
334
  ok = False
335
+ if cfg["sandbox"] == "off":
336
+ line("Sandbox", "off (lcode config set sandbox docker)", True)
337
+ else:
338
+ from lcode.sandbox import engine_problem
339
+
340
+ problem = engine_problem(cfg["sandbox"])
341
+ network = "network on" if cfg["sandbox_network"] else "no network"
342
+ line("Sandbox", problem or f"{cfg['sandbox']} · {network}", problem is None)
343
+ ok &= problem is None
324
344
  node = shutil.which("npx")
325
345
  line("Node.js", "found" if node else "not found (some MCP servers need npx)", True if node else None)
326
346
  sys.exit(0 if ok else 1)
@@ -485,8 +505,21 @@ def cmd_chat(args) -> None:
485
505
  search_backend=cfg["search_backend"],
486
506
  searxng_url=cfg["searxng_url"],
487
507
  checkpoints=cfg["checkpoints"],
508
+ sandbox=(cfg["sandbox"] if cfg["sandbox"] != "off" else "docker") if args.sandbox else cfg["sandbox"],
509
+ sandbox_image=cfg["sandbox_image"],
510
+ sandbox_network=cfg["sandbox_network"],
511
+ vision_model=cfg["vision_model"],
488
512
  )
489
513
  agent = Agent(ollama, settings, cwd, console=console)
514
+ if agent.sandbox:
515
+ # Never run commands unsandboxed when the user asked for a sandbox: stop here instead.
516
+ from lcode.sandbox import SandboxError
517
+
518
+ try:
519
+ with console.status("Starting the sandbox…"):
520
+ agent.sandbox.ensure(cwd)
521
+ except SandboxError as e:
522
+ fail(f"the sandbox can't start: {e}\nTurn it off with: lcode config set sandbox off")
490
523
  if not args.no_mcp:
491
524
  from lcode.mcp.commands import start_session
492
525
 
@@ -496,6 +529,8 @@ def cmd_chat(args) -> None:
496
529
  finally:
497
530
  if agent.mcp:
498
531
  agent.mcp.close()
532
+ if agent.sandbox:
533
+ agent.sandbox.stop()
499
534
 
500
535
 
501
536
  def build_parser() -> argparse.ArgumentParser:
@@ -522,6 +557,7 @@ def build_parser() -> argparse.ArgumentParser:
522
557
  parser.add_argument("--no-think", action="store_true", help="disable model reasoning (faster, less accurate)")
523
558
  parser.add_argument("--no-web", action="store_true", help="don't let the model search or fetch web pages")
524
559
  parser.add_argument("--no-mcp", action="store_true", help="don't start MCP servers in this session")
560
+ parser.add_argument("--sandbox", action="store_true", help="run shell commands in a container (Docker or Podman)")
525
561
  parser.add_argument("--show-thinking", action="store_true", help="print the model's reasoning as it streams")
526
562
  parser.add_argument("-V", "--version", action="version", version=f"lcode {__version__}")
527
563
  return parser
lcode/config.py CHANGED
@@ -25,6 +25,7 @@ PERMISSION_MODES = ("ask", "auto-edit", "yolo")
25
25
  WEB_MODES = ("on", "ask", "off")
26
26
  SEARCH_BACKENDS = ("auto", "ollama", "brave", "tavily", "searxng")
27
27
  MCP_TOOL_MODES = ("auto", "direct", "search")
28
+ SANDBOX_ENGINES = ("off", "docker", "podman")
28
29
 
29
30
  # key -> (default, type, help)
30
31
  SETTINGS: dict[str, tuple[object, type, str]] = {
@@ -39,6 +40,10 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
39
40
  "search_backend": ("auto", str, "auto | ollama | brave | tavily | searxng (keys come from environment variables)"),
40
41
  "searxng_url": (None, str, "your SearXNG instance, e.g. http://localhost:8888"),
41
42
  "checkpoints": (True, bool, "snapshot files before the model changes them, so /undo can restore them"),
43
+ "sandbox": ("off", str, "run the model's shell commands in a container: off | docker | podman"),
44
+ "sandbox_image": (None, str, "container image for the sandbox (default: lcode's, built on first use)"),
45
+ "sandbox_network": (False, bool, "let commands in the sandbox use the network"),
46
+ "vision_model": ("auto", str, "model that looks at images: auto | off | an Ollama model with vision"),
42
47
  "mcp_tools": ("auto", str, "how MCP tools reach the model: auto | direct | search (on demand, saves context)"),
43
48
  }
44
49
  ENV_OVERRIDES = {
@@ -48,6 +53,7 @@ ENV_OVERRIDES = {
48
53
  "LCODE_KEEP_ALIVE": "keep_alive",
49
54
  "OLLAMA_HOST": "ollama_host",
50
55
  "LCODE_WEB": "web",
56
+ "LCODE_SANDBOX": "sandbox",
51
57
  }
52
58
 
53
59
 
@@ -99,6 +105,11 @@ def coerce(key: str, value: object) -> object:
99
105
  if value not in WEB_MODES:
100
106
  raise ConfigError(f"web must be one of {', '.join(WEB_MODES)}")
101
107
  return value
108
+ if key == "sandbox":
109
+ value = {"none": "off", "false": "off", "no": "off"}.get(str(value).lower(), str(value).lower())
110
+ if value not in SANDBOX_ENGINES:
111
+ raise ConfigError(f"sandbox must be one of {', '.join(SANDBOX_ENGINES)}")
112
+ return value
102
113
  if key == "mcp_tools" and value not in MCP_TOOL_MODES:
103
114
  raise ConfigError(f"mcp_tools must be one of {', '.join(MCP_TOOL_MODES)}")
104
115
  if key == "search_backend" and value not in SEARCH_BACKENDS:
lcode/mcp/catalog.toml CHANGED
@@ -130,6 +130,34 @@ requires = ["npx"]
130
130
  setup = "Runs a headless browser; the first use may download it."
131
131
  server = { command = "npx", args = ["-y", "@playwright/mcp@latest", "--headless"] }
132
132
 
133
+ [comfyui]
134
+ name = "ComfyUI"
135
+ description = "Generate and edit images with models you run locally in ComfyUI: FLUX, SDXL, SD 1.5, Qwen-Image"
136
+ homepage = "https://github.com/Comfy-Org/comfy-mcp"
137
+ requires = ["uvx"]
138
+ setup = """Needs ComfyUI on this machine (http://127.0.0.1:8188) with at least one image model:
139
+ uv tool install comfy-cli && comfy install
140
+ comfy launch --background -- --disable-smart-memory
141
+ --disable-smart-memory makes ComfyUI give the GPU back after each image. Before generating, lcode
142
+ unloads its own model so ComfyUI has the GPU; it reloads afterwards. Only the local tools are on;
143
+ Comfy Cloud's partner tools are left out, so nothing leaves this machine."""
144
+
145
+ [comfyui.server]
146
+ command = "uvx"
147
+ args = ["--python", "3.12", "--from", "comfy-mcp", "--with", "comfy-cli>=1.14.0", "comfy-mcp"]
148
+ env = { COMFYUI_URL = "${COMFYUI_URL}" }
149
+ tools = [
150
+ "server_info", "generate_image", "run_workflow", "run_template", "search_templates", "get_template", "fetch_template",
151
+ "list_workflow_slots", "set_workflow_slot", "job", "fetch_outputs", "upload_file", "search_models",
152
+ "download_model", "system_stats", "free_memory", "launch_comfyui",
153
+ ]
154
+ free_gpu = ["generate_image", "run_workflow", "run_template"]
155
+
156
+ [[comfyui.inputs]]
157
+ var = "COMFYUI_URL"
158
+ prompt = "ComfyUI address (Enter for this machine, 127.0.0.1:8188)"
159
+ optional = true
160
+
133
161
  [context7]
134
162
  name = "Context7"
135
163
  description = "Current documentation and code examples for thousands of libraries"
lcode/mcp/config.py CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  Both use the `mcpServers` format that most MCP servers document, so a snippet from a server's README
4
4
  can be pasted in as is. lcode adds a few optional keys per server: `disabled`, `tools` (only offer
5
- these tools to the model), `allow` (tools that don't need approval), `timeout` and `oauth`.
5
+ these tools to the model), `allow` (tools that don't need approval), `timeout`, `oauth` and
6
+ `free_gpu` (tools that need the GPU to themselves, so lcode unloads its model first).
6
7
  Values can refer to environment variables as ${NAME} or ${NAME:-default}.
7
8
  """
8
9
 
@@ -69,6 +70,7 @@ class ServerConfig:
69
70
  timeout: float = DEFAULT_TIMEOUT
70
71
  oauth: dict = field(default_factory=dict)
71
72
  disabled: bool = False
73
+ free_gpu: list[str] | bool = False # tools (or all, if True) that need lcode's model off the GPU
72
74
  error: str = "" # a problem with the settings; the server can't start
73
75
 
74
76
  @property
@@ -121,6 +123,8 @@ def parse(name: str, raw: dict, source: str = "user") -> ServerConfig:
121
123
  cfg.error = f"the environment variable {e} isn't set"
122
124
  tools = raw.get("tools")
123
125
  cfg.tools = [str(t) for t in tools] if isinstance(tools, list) else None
126
+ free_gpu = raw.get("free_gpu", False)
127
+ cfg.free_gpu = [str(t) for t in free_gpu] if isinstance(free_gpu, list) else bool(free_gpu)
124
128
  cfg.allow = [str(t) for t in raw.get("allow") or []] if isinstance(raw.get("allow"), list) else []
125
129
  try:
126
130
  cfg.timeout = float(raw.get("timeout", DEFAULT_TIMEOUT))
lcode/mcp/manager.py CHANGED
@@ -317,15 +317,20 @@ class McpManager:
317
317
  raise McpError("`arguments` must be a JSON object")
318
318
  return found[0], found[1], arguments
319
319
 
320
+ def needs_gpu(self, state: ServerState, tool: dict) -> bool:
321
+ """Whether lcode should free the GPU (unload its model) before this tool runs."""
322
+ wanted = state.cfg.free_gpu
323
+ return wanted is True or (isinstance(wanted, list) and tool["name"] in wanted)
324
+
320
325
  def allowed(self, state: ServerState, tool: dict) -> bool:
321
326
  """Tools the user allowed in mcp.json run without asking."""
322
327
  return "*" in state.cfg.allow or tool["name"] in state.cfg.allow
323
328
 
324
- def call(self, state: ServerState, tool: dict, arguments: dict) -> str:
329
+ def call(self, state: ServerState, tool: dict, arguments: dict, image_text=None) -> str:
325
330
  try:
326
331
  if state.conn is None:
327
332
  raise TransportError("the server isn't connected")
328
- return result_text(state.conn.call_tool(tool, arguments, state.cfg.timeout))
333
+ return result_text(state.conn.call_tool(tool, arguments, state.cfg.timeout), image_text)
329
334
  except AuthRequired:
330
335
  state.status = "login"
331
336
  state.error = f"sign-in expired: run /mcp login {state.name}"
@@ -336,7 +341,7 @@ class McpManager:
336
341
  self.restart(state.name)
337
342
  if state.status == "ready" and state.conn:
338
343
  try:
339
- return result_text(state.conn.call_tool(tool, arguments, state.cfg.timeout))
344
+ return result_text(state.conn.call_tool(tool, arguments, state.cfg.timeout), image_text)
340
345
  except McpError as again:
341
346
  return f"Error: {again}"
342
347
  return f"Error: {state.name}: {e}"
lcode/mcp/protocol.py CHANGED
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
  import base64
11
11
  import json
12
12
  import re
13
+ from collections.abc import Callable
13
14
 
14
15
  from lcode import __version__
15
16
 
@@ -99,8 +100,8 @@ def param_headers(schema: dict, arguments: dict) -> dict[str, str] | None:
99
100
  return headers if walk(schema, arguments) else None
100
101
 
101
102
 
102
- def result_text(result: dict) -> str:
103
- """Turn a tools/call result into text for the model."""
103
+ def result_text(result: dict, image_text: Callable[[str, str], str] | None = None) -> str:
104
+ """Turn a tools/call result into text for the model. `image_text(data, mime)` describes images."""
104
105
  parts = []
105
106
  for item in result.get("content") or []:
106
107
  if not isinstance(item, dict):
@@ -108,6 +109,8 @@ def result_text(result: dict) -> str:
108
109
  kind = item.get("type")
109
110
  if kind == "text":
110
111
  parts.append(str(item.get("text", "")))
112
+ elif kind == "image" and image_text and item.get("data"):
113
+ parts.append(image_text(item["data"], item.get("mimeType", "image/png")))
111
114
  elif kind in ("image", "audio"):
112
115
  parts.append(f"[{kind} ({item.get('mimeType', 'unknown type')}) not shown]")
113
116
  elif kind == "resource_link":
lcode/ollama.py CHANGED
@@ -68,6 +68,10 @@ class Ollama:
68
68
  names.add(m["name"][: -len(":latest")])
69
69
  return names
70
70
 
71
+ def chat(self, payload: dict, timeout: float = 900) -> dict:
72
+ """One chat request without streaming."""
73
+ return self._post("/api/chat", {**payload, "stream": False}, timeout=timeout)
74
+
71
75
  def show(self, model: str) -> dict:
72
76
  return self._post("/api/show", {"model": model})
73
77
 
lcode/repl.py CHANGED
@@ -39,6 +39,7 @@ COMMANDS = {
39
39
  "/undo": "Undo the file changes of the last request (lcode saves a checkpoint before changing files)",
40
40
  "/rewind": "Go back to before an earlier request: its files, and optionally the conversation",
41
41
  "/checkpoints": "List the requests that changed files, and which files",
42
+ "/sandbox": "Shell-command sandbox: status, or /sandbox network on|off",
42
43
  "/mcp": "MCP servers and their tools: /mcp, /mcp tools NAME, /mcp login NAME, /mcp restart NAME",
43
44
  "/compact": "Summarize the conversation to free context (optional: what to focus on)",
44
45
  "/context": "Show context usage and change the window size: pick from a list, or /context 128k",
@@ -143,6 +144,11 @@ def banner(agent: Agent) -> None:
143
144
  f"[dim]mode[/] {agent.perms.mode} [dim](Shift+Tab to cycle)[/]\n"
144
145
  f"[dim]web[/] {web_status(agent)}\n"
145
146
  + (f"[dim]mcp[/] {escape(', '.join(agent.mcp.servers))} [dim](/mcp)[/]\n" if agent.mcp else "")
147
+ + (
148
+ f"[dim]sandbox[/] {escape(agent.sandbox.describe())} [dim](/sandbox)[/]\n"
149
+ if agent.sandbox
150
+ else ""
151
+ )
146
152
  + "\n"
147
153
  "[dim]/help for commands · @file to attach · Esc+Enter for a newline[/]"
148
154
  ),
@@ -240,6 +246,31 @@ def context_command(agent: Agent, arg: str, hardware: Hardware) -> None:
240
246
  apply_context(agent, size, hardware)
241
247
 
242
248
 
249
+ def sandbox_command(agent: Agent, arg: str) -> None:
250
+ c, sandbox = agent.console, agent.sandbox
251
+ if sandbox is None:
252
+ c.print(
253
+ "The sandbox is off: shell commands run directly on this machine. Start lcode with --sandbox, or "
254
+ "turn it on for every session with: lcode config set sandbox docker"
255
+ )
256
+ return
257
+ words = arg.split()
258
+ if len(words) == 2 and words[0] == "network" and words[1] in ("on", "off"):
259
+ sandbox.network = words[1] == "on"
260
+ sandbox.stop() # the next command starts a container with the new setting
261
+ c.print(f"Network access in the sandbox is {words[1]} from the next command.")
262
+ return
263
+ if arg:
264
+ c.print("[yellow]Use /sandbox or /sandbox network on|off.[/]")
265
+ return
266
+ where = f" · container {sandbox.container}, mounting {sandbox.root}" if sandbox.container else ""
267
+ c.print(f"Sandbox: {escape(sandbox.describe())}{escape(where)}")
268
+ c.print(
269
+ "[dim]Shell commands run in the container and can only see this project; file tools are limited to it "
270
+ "too. In auto-edit mode, commands run without asking.[/]"
271
+ )
272
+
273
+
243
274
  def mcp_command(agent: Agent, arg: str) -> None:
244
275
  from lcode.mcp.commands import status_table
245
276
  from lcode.mcp.protocol import McpError
@@ -515,6 +546,8 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
515
546
  print_checkpoints(agent)
516
547
  elif cmd == "/mcp":
517
548
  mcp_command(agent, arg)
549
+ elif cmd == "/sandbox":
550
+ sandbox_command(agent, arg)
518
551
  elif cmd == "/compact":
519
552
  agent.compact(arg)
520
553
  elif cmd in ("/context", "/ctx"):
lcode/sandbox.py ADDED
@@ -0,0 +1,204 @@
1
+ """An optional sandbox: the model's shell commands run in a container that only sees the project.
2
+
3
+ One container per session. The project (the git repository around the working directory, or the
4
+ working directory itself) is mounted at the same path, so paths in command output stay valid and
5
+ lcode's file tools, which run on the host, see the same files. Commands run as the host user, with
6
+ no network unless it's allowed, no Linux capabilities, a process limit, and nothing else from the
7
+ host: not the home folder, not the Docker socket.
8
+
9
+ The default image is built locally on first use from the Dockerfile below. Any image with bash and
10
+ setsid (util-linux or busybox) can be used instead (`sandbox_image`).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import os
17
+ import platform
18
+ import secrets
19
+ import shutil
20
+ import subprocess
21
+ from pathlib import Path
22
+
23
+ from rich.console import Console
24
+
25
+ DOCKERFILE = """\
26
+ FROM python:3.12-slim-bookworm
27
+ RUN apt-get update \\
28
+ && apt-get install -y --no-install-recommends \\
29
+ git curl ca-certificates build-essential ripgrep jq unzip less procps nodejs npm \\
30
+ && rm -rf /var/lib/apt/lists/*
31
+ """
32
+ DEFAULT_IMAGE = f"lcode-sandbox:{hashlib.sha256(DOCKERFILE.encode()).hexdigest()[:12]}"
33
+ LABEL = "dev.lcode.sandbox"
34
+ HOME = "/tmp/lcode-home"
35
+ ENGINES = ("docker", "podman")
36
+
37
+
38
+ class SandboxError(Exception):
39
+ """The sandbox can't be used; the message says how to fix it."""
40
+
41
+
42
+ def engine_problem(engine: str) -> str | None:
43
+ """Why `engine` can't run containers here, or None if it can."""
44
+ if not shutil.which(engine):
45
+ if engine == "podman":
46
+ return "podman isn't installed (https://podman.io/docs/installation)"
47
+ if platform.system() == "Darwin":
48
+ return "docker isn't installed: install Docker Desktop, OrbStack or colima"
49
+ return "docker isn't installed (https://docs.docker.com/engine/install/)"
50
+ try:
51
+ r = subprocess.run(
52
+ [engine, "version", "--format", "{{.Server.Version}}"], capture_output=True, text=True, timeout=20
53
+ )
54
+ except (OSError, subprocess.SubprocessError) as e:
55
+ return f"{engine} doesn't respond: {e}"
56
+ if r.returncode != 0:
57
+ detail = (r.stderr.strip().splitlines() or [""])[-1]
58
+ if "permission denied" in detail.lower():
59
+ return (
60
+ f"{engine} needs permission: add yourself to the docker group "
61
+ "(sudo usermod -aG docker $USER, then log in again)"
62
+ )
63
+ if platform.system() == "Darwin":
64
+ return f"{engine} isn't running: start Docker Desktop, OrbStack or colima ({detail})"
65
+ return f"{engine} isn't running: start it (sudo systemctl start {engine}) ({detail})"
66
+ return None
67
+
68
+
69
+ def remove_orphans(engine: str) -> None:
70
+ """Remove sandboxes left behind by lcode sessions that ended without cleaning up."""
71
+ try:
72
+ r = subprocess.run(
73
+ [engine, "ps", "-a", "--filter", f"label={LABEL}", "--format", '{{.ID}} {{.Label "dev.lcode.pid"}}'],
74
+ capture_output=True,
75
+ text=True,
76
+ timeout=20,
77
+ )
78
+ except (OSError, subprocess.SubprocessError):
79
+ return
80
+ stale = []
81
+ for line in r.stdout.split("\n"):
82
+ parts = line.split()
83
+ if len(parts) == 2 and parts[1].isdigit() and not _alive(int(parts[1])):
84
+ stale.append(parts[0])
85
+ if stale:
86
+ subprocess.run([engine, "rm", "-f", *stale], capture_output=True, timeout=60)
87
+
88
+
89
+ def _alive(pid: int) -> bool:
90
+ try:
91
+ os.kill(pid, 0)
92
+ except ProcessLookupError:
93
+ return False
94
+ except PermissionError:
95
+ return True
96
+ return True
97
+
98
+
99
+ class Sandbox:
100
+ def __init__(self, engine: str, image: str | None = None, network: bool = False, console: Console | None = None):
101
+ if engine not in ENGINES:
102
+ raise SandboxError(f"unknown sandbox {engine!r}; use docker or podman")
103
+ self.engine = engine
104
+ self.image = image or DEFAULT_IMAGE
105
+ self.network = network
106
+ self.console = console or Console()
107
+ self.container: str | None = None
108
+ self.root: Path | None = None
109
+
110
+ # -- lifecycle
111
+ def check(self) -> None:
112
+ problem = engine_problem(self.engine)
113
+ if problem:
114
+ raise SandboxError(problem)
115
+
116
+ def contains(self, path: Path) -> bool:
117
+ return self.root is not None and (path == self.root or self.root in path.parents)
118
+
119
+ def ensure(self, cwd: Path) -> None:
120
+ """Have a container running whose mounted folder includes `cwd`."""
121
+ if self.container and self.contains(cwd):
122
+ return
123
+ self.stop()
124
+ self.check()
125
+ remove_orphans(self.engine)
126
+ if self.image == DEFAULT_IMAGE:
127
+ self._build_default_image()
128
+ root = project_root(cwd)
129
+ name = f"lcode-sandbox-{os.getpid()}-{secrets.token_hex(3)}"
130
+ command = [
131
+ self.engine, "run", "--detach", "--init", "--name", name,
132
+ "--label", LABEL, "--label", f"dev.lcode.pid={os.getpid()}",
133
+ "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--pids-limit", "1024",
134
+ "--volume", f"{root}:{root}", "--workdir", str(cwd),
135
+ "--env", f"HOME={HOME}", "--tmpfs", f"{HOME}:exec,mode=1777",
136
+ ] # fmt: skip
137
+ if not self.network:
138
+ command += ["--network", "none"]
139
+ if self.engine == "podman":
140
+ command += ["--userns", "keep-id"] # rootless podman: files belong to the host user
141
+ else:
142
+ command += ["--user", f"{os.getuid()}:{os.getgid()}"]
143
+ command += [self.image, "sleep", "infinity"]
144
+ r = subprocess.run(command, capture_output=True, text=True, timeout=300)
145
+ if r.returncode != 0:
146
+ raise SandboxError(f"couldn't start the sandbox: {(r.stderr.strip().splitlines() or ['?'])[-1]}")
147
+ self.container, self.root = name, root
148
+
149
+ def _build_default_image(self) -> None:
150
+ exists = subprocess.run([self.engine, "image", "inspect", self.image], capture_output=True, timeout=30)
151
+ if exists.returncode == 0:
152
+ return
153
+ with self.console.status("Building the sandbox image (once; a minute or two)…"):
154
+ r = subprocess.run(
155
+ [self.engine, "build", "--tag", self.image, "-"],
156
+ input=DOCKERFILE,
157
+ capture_output=True,
158
+ text=True,
159
+ timeout=1800,
160
+ )
161
+ if r.returncode != 0:
162
+ tail = "\n".join(r.stderr.strip().splitlines()[-5:])
163
+ raise SandboxError(f"couldn't build the sandbox image:\n{tail}")
164
+
165
+ def exec_argv(self, cwd: Path, script: str) -> tuple[list[str], str]:
166
+ """The command that runs `script` with bash inside the container, and a token for kill()."""
167
+ assert self.container is not None
168
+ token = secrets.token_hex(6)
169
+ # Its own session, so a timeout or Ctrl+C can stop everything the command started.
170
+ script = f"echo $$ > /tmp/.lcode-{token}.pgid\n{script}"
171
+ argv = [self.engine, "exec", "--interactive", "--workdir", str(cwd), self.container]
172
+ return [*argv, "setsid", "--wait", "bash", "-c", script], token
173
+
174
+ def kill(self, token: str) -> None:
175
+ """Stop a command and everything it started (after a timeout or Ctrl+C); the container stays."""
176
+ if self.container:
177
+ pgid = f"$(cat /tmp/.lcode-{token}.pgid 2>/dev/null)"
178
+ subprocess.run(
179
+ # bash, not sh: dash's kill doesn't accept "--" before a negative (process group) id
180
+ [self.engine, "exec", self.container, "bash", "-c", f'[ -n "{pgid}" ] && kill -KILL -- -{pgid}'],
181
+ capture_output=True,
182
+ timeout=30,
183
+ )
184
+
185
+ def stop(self) -> None:
186
+ if self.container:
187
+ subprocess.run([self.engine, "rm", "--force", self.container], capture_output=True, timeout=60)
188
+ self.container, self.root = None, None
189
+
190
+ def describe(self) -> str:
191
+ image = "lcode's image" if self.image == DEFAULT_IMAGE else self.image
192
+ network = "network on" if self.network else "no network"
193
+ return f"{self.engine} · {image} · {network}"
194
+
195
+
196
+ def project_root(cwd: Path) -> Path:
197
+ """The folder the sandbox mounts: the git repository around `cwd`, or `cwd` itself."""
198
+ from lcode.checkpoints import work_tree_for
199
+
200
+ root = work_tree_for(cwd)
201
+ home = Path.home().resolve()
202
+ if root == home or root in home.parents or root == Path("/"):
203
+ raise SandboxError(f"won't mount {root} into the sandbox; start lcode in a project folder")
204
+ return root
lcode/tools.py CHANGED
@@ -9,11 +9,10 @@ import json
9
9
  import os
10
10
  import queue
11
11
  import re
12
- import shlex
12
+ import secrets
13
13
  import shutil
14
14
  import signal
15
15
  import subprocess
16
- import tempfile
17
16
  import threading
18
17
  import time
19
18
  from pathlib import Path
@@ -25,10 +24,16 @@ from rich.text import Text
25
24
 
26
25
  from lcode import web
27
26
  from lcode.permissions import bash_key, is_read_only
27
+ from lcode.sandbox import SandboxError
28
+ from lcode.vision import is_image
28
29
 
29
30
  if TYPE_CHECKING:
30
31
  from lcode.agent import Agent
31
32
 
33
+ NO_NETWORK = re.compile(
34
+ r"Network is unreachable|Temporary failure in name resolution|Could not resolve host|getaddrinfo|ENOTFOUND|"
35
+ r"EAI_AGAIN|Name or service not known|network is unreachable|No route to host"
36
+ )
32
37
  MAX_TOOL_OUTPUT = 30_000 # characters returned to the model per tool call
33
38
  IGNORE_DIRS = {
34
39
  ".git", "node_modules", "__pycache__", ".venv", "venv", "env", ".mypy_cache", ".pytest_cache",
@@ -158,7 +163,17 @@ WEB_FETCH_SCHEMA = _fn(
158
163
  },
159
164
  ["url"],
160
165
  )
161
- TOOL_NAMES = {s["function"]["name"] for s in [*SCHEMAS, WEB_SEARCH_SCHEMA, WEB_FETCH_SCHEMA]}
166
+ VIEW_IMAGE_SCHEMA = _fn(
167
+ "view_image",
168
+ "Look at an image file (screenshot, diagram, photo, mockup): returns a detailed description with all visible "
169
+ "text transcribed. Ask a specific question to focus it.",
170
+ {
171
+ "path": {"type": "string", "description": "Image file: png, jpg, webp or gif"},
172
+ "question": {"type": "string", "description": "What you need to know from the image (optional)"},
173
+ },
174
+ ["path"],
175
+ )
176
+ TOOL_NAMES = {s["function"]["name"] for s in [*SCHEMAS, WEB_SEARCH_SCHEMA, WEB_FETCH_SCHEMA, VIEW_IMAGE_SCHEMA]}
162
177
 
163
178
 
164
179
  # ----------------------------------------------------------------------------- helpers
@@ -277,6 +292,14 @@ class Toolbox:
277
292
  p = Path(os.path.expanduser(path or "."))
278
293
  return (p if p.is_absolute() else self.agent.cwd / p).resolve()
279
294
 
295
+ def path(self, path: str | None) -> Path:
296
+ """Resolve a path from the model; with the sandbox on, it must be inside the project."""
297
+ p = self.resolve(path)
298
+ root = self.agent.sandbox_root()
299
+ if root is not None and p != root and root not in p.parents:
300
+ raise ToolError(f"{path} is outside the project ({root}); with the sandbox on, that's all you can use")
301
+ return p
302
+
280
303
  def rel(self, p: Path) -> str:
281
304
  try:
282
305
  return str(p.relative_to(self.agent.cwd)) or "."
@@ -317,6 +340,11 @@ class Toolbox:
317
340
  valid = ", ".join(p if params[p].default is inspect.Parameter.empty else f"{p} (optional)" for p in params)
318
341
  return f"{'; '.join(parts)}. Valid arguments: {valid}."
319
342
 
343
+ # -- images
344
+ def t_view_image(self, path: str, question: str = "") -> str:
345
+ p = self.path(path)
346
+ return f"[{self.rel(p)}, as described by {self.agent.vision_model()}]\n" + self.agent.look(p, question)
347
+
320
348
  # -- MCP servers
321
349
  def _mcp(self, name: str, args: dict) -> str:
322
350
  mcp = self.agent.mcp
@@ -336,7 +364,13 @@ class Toolbox:
336
364
  ok, feedback = self.agent.perms.request(f"mcp:{state.name}:{tool['name']}", "mcp", title, body)
337
365
  if not ok:
338
366
  return feedback
339
- result = mcp.call(state, tool, arguments)
367
+ if mcp.needs_gpu(state, tool):
368
+ freed = self.agent.free_gpu()
369
+ if freed:
370
+ self.console.print(
371
+ Text(f" ⎿ freed the GPU for {state.name} ({', '.join(freed)} reloads afterwards)", style="dim")
372
+ )
373
+ result = mcp.call(state, tool, arguments, image_text=self.agent.describe_image_data)
340
374
  if result.startswith("Error:"):
341
375
  return result
342
376
  self.console.print(Text(f" ⎿ {result.count(chr(10)) + 1} line(s) from {state.name}", style="dim"))
@@ -344,11 +378,13 @@ class Toolbox:
344
378
 
345
379
  # -- read-only
346
380
  def t_read_file(self, path: str, offset: int = 1, limit: int = 2000) -> str:
347
- p = self.resolve(path)
381
+ p = self.path(path)
348
382
  if not p.exists():
349
383
  raise ToolError(f"{path} does not exist")
350
384
  if p.is_dir():
351
385
  raise ToolError(f"{path} is a directory; use list_dir")
386
+ if is_image(p):
387
+ raise ToolError(f"{path} is an image; look at it with view_image")
352
388
  if is_binary(p):
353
389
  raise ToolError(f"{path} is a binary file ({p.stat().st_size} bytes)")
354
390
  lines = p.read_text(errors="replace").splitlines()
@@ -364,13 +400,13 @@ class Toolbox:
364
400
  return truncate(out, 120_000)
365
401
 
366
402
  def t_list_dir(self, path: str = ".", depth: int = 2) -> str:
367
- p = self.resolve(path)
403
+ p = self.path(path)
368
404
  if not p.is_dir():
369
405
  raise ToolError(f"{path} is not a directory")
370
406
  return tree(p, depth=max(1, min(int(depth or 2), 6)))
371
407
 
372
408
  def t_glob(self, pattern: str, path: str = ".") -> str:
373
- base = self.resolve(path)
409
+ base = self.path(path)
374
410
  pattern = pattern.removeprefix("./")
375
411
  patterns = [pattern] + ([pattern[3:]] if pattern.startswith("**/") else [])
376
412
  hits = [
@@ -385,7 +421,7 @@ class Toolbox:
385
421
  def t_grep(
386
422
  self, pattern: str, path: str = ".", glob: str | None = None, ignore_case: bool = False, context: int = 0
387
423
  ) -> str:
388
- p = self.resolve(path)
424
+ p = self.path(path)
389
425
  if shutil.which("rg"):
390
426
  cmd = ["rg", "--line-number", "--no-heading", "--with-filename", "--hidden", "--color", "never"]
391
427
  cmd += ["--max-columns", "400"]
@@ -450,7 +486,7 @@ class Toolbox:
450
486
  self.read_mtimes[str(p)] = p.stat().st_mtime
451
487
 
452
488
  def t_write_file(self, path: str, content: str) -> str:
453
- p = self.resolve(path)
489
+ p = self.path(path)
454
490
  exists = p.exists()
455
491
  if exists:
456
492
  if p.is_dir():
@@ -472,7 +508,7 @@ class Toolbox:
472
508
  return f"{'Overwrote' if exists else 'Created'} {self.rel(p)} ({n} lines)."
473
509
 
474
510
  def t_edit_file(self, path: str, old_string: str, new_string: str, replace_all: bool = False) -> str:
475
- p = self.resolve(path)
511
+ p = self.path(path)
476
512
  if not p.exists():
477
513
  raise ToolError(f"{path} does not exist (use write_file to create it)")
478
514
  self._check_fresh(p)
@@ -515,20 +551,30 @@ class Toolbox:
515
551
 
516
552
  # -- shell
517
553
  def t_bash(self, command: str, timeout: int = 180) -> str:
518
- if not is_read_only(command):
554
+ sandbox = self.agent.sandbox
555
+ if not is_read_only(command) and not (sandbox and self.agent.perms.mode == "auto-edit"):
519
556
  body = Syntax(command, "bash", theme="monokai", word_wrap=True)
520
- ok, feedback = self.agent.perms.request(
521
- bash_key(command), "bash", f"Run command (in {self.agent.cwd})", body
522
- )
557
+ where = "in the sandbox" if sandbox else f"in {self.agent.cwd}"
558
+ ok, feedback = self.agent.perms.request(bash_key(command), "bash", f"Run command ({where})", body)
523
559
  if not ok:
524
560
  return feedback
561
+ if not is_read_only(command):
525
562
  self.agent.checkpoint()
526
563
  self.console.print(Text(f" $ {command}", style="bold cyan"))
527
- fd, cwd_file = tempfile.mkstemp(prefix="lcode_cwd_")
528
- os.close(fd)
529
- script = f"{command}\n__lcode_ec=$?\npwd -P > {shlex.quote(cwd_file)}\nexit $__lcode_ec\n"
564
+ marker = f"__lcode_cwd_{secrets.token_hex(8)}__"
565
+ script = f"{command}\n__lcode_ec=$?\nprintf '\\n{marker}%s\\n' \"$(pwd -P)\"\nexit $__lcode_ec\n"
566
+ token = ""
567
+ if sandbox:
568
+ try:
569
+ with self.console.status("Starting the sandbox…"):
570
+ sandbox.ensure(self.agent.cwd)
571
+ except SandboxError as e:
572
+ return f"Error: the sandbox can't start, so the command didn't run: {e}"
573
+ argv, token = sandbox.exec_argv(self.agent.cwd, script)
574
+ else:
575
+ argv = ["bash", "-c", script]
530
576
  proc = subprocess.Popen(
531
- ["bash", "-c", script],
577
+ argv,
532
578
  cwd=self.agent.cwd,
533
579
  stdout=subprocess.PIPE,
534
580
  stderr=subprocess.STDOUT,
@@ -546,9 +592,14 @@ class Toolbox:
546
592
  lines.put(line)
547
593
  lines.put(None)
548
594
 
595
+ def stop() -> None:
596
+ if sandbox and token:
597
+ sandbox.kill(token) # the command runs in the container, not under our process
598
+ os.killpg(proc.pid, signal.SIGKILL)
599
+
549
600
  threading.Thread(target=pump, daemon=True).start()
550
601
  out: list[str] = []
551
- shown, status = 0, ""
602
+ shown, status, recorded = 0, "", ""
552
603
  deadline = time.time() + int(timeout or 180)
553
604
  try:
554
605
  while True:
@@ -556,30 +607,37 @@ class Toolbox:
556
607
  line = lines.get(timeout=0.2)
557
608
  except queue.Empty:
558
609
  if time.time() > deadline:
559
- os.killpg(proc.pid, signal.SIGKILL)
610
+ stop()
560
611
  status = f"\n[Command timed out after {timeout}s and was killed]"
561
612
  break
562
613
  continue
563
614
  if line is None:
564
615
  break
616
+ if line.startswith(marker):
617
+ recorded = line[len(marker) :].strip()
618
+ continue
565
619
  out.append(line)
566
620
  if shown <= 40:
567
621
  msg = " ... (more output hidden)" if shown == 40 else " " + line.rstrip("\n")[:300]
568
622
  self.console.print(Text(msg, style="dim"))
569
623
  shown += 1
570
624
  except KeyboardInterrupt:
571
- os.killpg(proc.pid, signal.SIGKILL)
625
+ stop()
572
626
  raise
573
627
  code = proc.wait()
574
- try:
575
- recorded = Path(cwd_file).read_text().strip()
576
- if recorded and Path(recorded).is_dir() and Path(recorded).resolve() != self.agent.cwd:
628
+ if out and out[-1] == "\n":
629
+ out.pop() # the blank line printed before the marker
630
+ if recorded and Path(recorded).is_dir() and Path(recorded).resolve() != self.agent.cwd:
631
+ if sandbox and not sandbox.contains(Path(recorded).resolve()):
632
+ status += "\n[That directory is outside the project; the working directory stays the same]"
633
+ else:
577
634
  self.agent.cwd = Path(recorded).resolve()
578
635
  status += f"\n[Working directory is now {self.agent.cwd}]"
579
- except OSError:
580
- pass
581
- finally:
582
- Path(cwd_file).unlink(missing_ok=True)
636
+ if sandbox and code != 0 and not sandbox.network and NO_NETWORK.search("".join(out[-40:])):
637
+ status += (
638
+ "\n[The sandbox has no network access. If this needs the network, ask the user to allow it "
639
+ "with /sandbox network on]"
640
+ )
583
641
  self.console.print(Text(f" exit code {code}", style="green" if code == 0 else "red"))
584
642
  return truncate("".join(out)) + status + f"\n[exit code: {code}]"
585
643
 
lcode/vision.py ADDED
@@ -0,0 +1,109 @@
1
+ """Looking at images: screenshots, diagrams and photos the user attaches or the model opens.
2
+
3
+ The model that runs the conversation often can't see: lcode's text-only variants leave out the
4
+ vision projector to save GPU memory. So lcode asks a model that can see to describe the image in
5
+ detail (all text transcribed, layout, colors), with the user's question in mind, and gives the
6
+ description to the conversation. Which model looks (`vision_model = "auto"`):
7
+
8
+ 1. the session's model, if it has vision;
9
+ 2. otherwise the original Ollama model behind a text-only catalog variant (the same weights plus
10
+ the vision projector; `lcode setup` already downloaded it), e.g. qwen3.6:35b-a3b-coding;
11
+ 3. otherwise none, unless `vision_model` names one (for example qwen3-vl:8b).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ from pathlib import Path
18
+
19
+ from lcode import catalog
20
+ from lcode.ollama import Ollama, OllamaError
21
+
22
+ IMAGE_TYPES = {
23
+ ".png": "image/png",
24
+ ".jpg": "image/jpeg",
25
+ ".jpeg": "image/jpeg",
26
+ ".webp": "image/webp",
27
+ ".gif": "image/gif",
28
+ }
29
+ MAX_IMAGE_BYTES = 20 * 1024 * 1024
30
+ DESCRIBE_CONTEXT = 16384 # enough for one image and a long description
31
+
32
+ PROMPT = """You are the eyes of a coding assistant that cannot see images. Describe this image so the \
33
+ assistant can act on it without seeing it.
34
+ - Transcribe all visible text exactly: code, error messages, terminal output, labels, menu items, numbers.
35
+ - Describe the layout and every important element: what it is, where it is, its size, color and state \
36
+ (enabled, selected, highlighted, misaligned, overlapping, cut off).
37
+ - For diagrams, charts and architecture drawings, describe the parts and how they connect.
38
+ - Say what looks wrong or unusual, if anything.
39
+ Don't guess at what isn't visible.{question}"""
40
+
41
+ INSTALL_HINT = (
42
+ "no model that can see images is available: run `ollama pull qwen3-vl:8b` and "
43
+ "`lcode config set vision_model qwen3-vl:8b`, or use a catalog model (lcode setup)"
44
+ )
45
+
46
+
47
+ class VisionError(Exception):
48
+ pass
49
+
50
+
51
+ def is_image(path: Path) -> bool:
52
+ return path.suffix.lower() in IMAGE_TYPES
53
+
54
+
55
+ def can_see(ollama: Ollama, model: str) -> bool:
56
+ try:
57
+ return "vision" in (ollama.show(model).get("capabilities") or [])
58
+ except (OllamaError, AttributeError, TypeError):
59
+ return False
60
+
61
+
62
+ def pick_model(ollama: Ollama, model: str, setting: str) -> str | None:
63
+ """The model that looks at images for a session running `model` (see the module docstring)."""
64
+ if setting == "off":
65
+ return None
66
+ if setting != "auto":
67
+ return setting
68
+ if can_see(ollama, model):
69
+ return model
70
+ spec = catalog.find(model)
71
+ if spec and spec.tag != model and can_see(ollama, spec.tag):
72
+ return spec.tag
73
+ return None
74
+
75
+
76
+ def read_image(path: Path) -> bytes:
77
+ if not path.is_file():
78
+ raise VisionError(f"{path} doesn't exist")
79
+ if not is_image(path):
80
+ raise VisionError(f"{path.name} isn't an image lcode can read ({', '.join(sorted(IMAGE_TYPES))})")
81
+ size = path.stat().st_size
82
+ if size > MAX_IMAGE_BYTES:
83
+ raise VisionError(f"{path.name} is {size // (1024 * 1024)} MB; images up to 20 MB are supported")
84
+ return path.read_bytes()
85
+
86
+
87
+ def describe(ollama: Ollama, model: str, image: bytes, question: str = "", options: dict | None = None,
88
+ keep_alive: str = "30m") -> str: # fmt: skip
89
+ """A detailed description of the image, focused on the question if there is one."""
90
+ focus = (
91
+ f"\n\nThe user asks: {question.strip()}\nPay special attention to what that needs." if question.strip() else ""
92
+ )
93
+ payload = {
94
+ "model": model,
95
+ "messages": [
96
+ {"role": "user", "content": PROMPT.format(question=focus), "images": [base64.b64encode(image).decode()]}
97
+ ],
98
+ "think": False,
99
+ "keep_alive": keep_alive,
100
+ "options": options or {"num_ctx": DESCRIBE_CONTEXT},
101
+ }
102
+ try:
103
+ reply = ollama.chat(payload)
104
+ except OllamaError as e:
105
+ raise VisionError(f"{model} couldn't look at the image: {e}") from e
106
+ text = ((reply.get("message") or {}).get("content") or "").strip()
107
+ if not text:
108
+ raise VisionError(f"{model} returned no description")
109
+ return text
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.4.1
3
+ Version: 0.6.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/
@@ -78,13 +78,18 @@ through [Ollama](https://ollama.com), and when it needs current information it c
78
78
  real coding tasks on your machine.
79
79
  - **Safe by default.** Every edit is shown as a diff and every command that isn't read-only needs
80
80
  your approval. `auto-edit` and `yolo` modes when you want speed.
81
+ - **Sandbox (optional).** `lcode --sandbox` runs the model's shell commands in a container (Docker or
82
+ Podman) that only sees your project, without network access unless you allow it.
81
83
  - **Undo.** lcode saves a checkpoint before the model changes files; `/undo` takes back the last
82
84
  request's edits, new files and shell-command changes, without touching your git history.
83
85
  - **Bring your own model.** Eight curated open-weight models, all tested end to end, or any Ollama model
84
86
  with tool calling.
85
87
  - **MCP servers, ready to go.** Connect Jira and Confluence, GitHub, AWS, Google Drive, Grafana,
86
88
  Google Cloud, Sentry, Linear, Notion, Postgres, Kubernetes and more with one command
87
- (`lcode mcp add atlassian`), or any other MCP server. Browser sign-in (OAuth) is built in.
89
+ (`lcode mcp add atlassian`), image generation with ComfyUI, or any other MCP server. Browser sign-in (OAuth) is built in.
90
+ - **Sees images.** Attach a screenshot or mockup with `@path` and lcode looks at it, using the
91
+ vision part of your model. With ComfyUI (`lcode mcp add comfyui`) it can also generate and edit
92
+ images with local models such as FLUX, SDXL and Qwen-Image.
88
93
  - **Web search when needed.** Looks up the latest versions, docs and error messages with Ollama web
89
94
  search, Brave, Tavily or your own SearXNG, and reads pages as clean text.
90
95
  - **Private.** The model runs on your machine, lcode never uploads your files and has no telemetry.
@@ -184,7 +189,6 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
184
189
 
185
190
  See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
186
191
 
187
- - An [optional sandbox for shell commands](https://github.com/nasser1941/lcode/issues/9)
188
192
  - More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
189
193
 
190
194
  ## Contributing
@@ -1,34 +1,36 @@
1
- lcode/__init__.py,sha256=1t5InCerqpe0xlveEpLyHmEy2nEXzOrj4qbGJyWN6WQ,117
1
+ lcode/__init__.py,sha256=FWZV2PnebwvlC4fL-VRqLfpH2njKjqCUWaGd0s-R-5c,117
2
2
  lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
- lcode/agent.py,sha256=dxW07COUQo8UZnIwNfegCOSX_ZStorYsL93jhZFkvN8,27753
3
+ lcode/agent.py,sha256=AhUf1l0OEjpizU74HI7yfdRz0q_UAnqFddbyQwdmFx0,32256
4
4
  lcode/bench.py,sha256=FCn94Gs7L8bpq4hzf6ILoQG8dKrbbd5WC4BRwrTp4LM,37950
5
5
  lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
6
6
  lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
7
- lcode/cli.py,sha256=ICyUQ1DQmUhy0cNBrRFYvFqyqvLxdcLWn-21-Q6qSWk,25845
8
- lcode/config.py,sha256=i7EYhhTPaIFxSWyqHiffIEoL4PgHjkBBpF20gc--MtQ,6306
7
+ lcode/cli.py,sha256=53MucKCHe9NreYXwDaMEwfHtPS-26kxj7Xg3Bl4yoR8,27506
8
+ lcode/config.py,sha256=VyRHq3ElCGmTQqTdtcGRHGuselm2U8-04ZC3tnEQ6L8,7063
9
9
  lcode/hardware.py,sha256=2VbknZCxTZJwJUD8YWdQ7ryEQo_LcjtoOBOHOU9Y0fw,3085
10
10
  lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
11
11
  lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
12
- lcode/ollama.py,sha256=-EB_8DKh87larG7iV0neyndPRom1Hxh4y8AAfI1Ebq8,6690
12
+ lcode/ollama.py,sha256=8zxJcMhcxKF3L0vdgrzcoXAj_4MvbybVDSRPbLek0QE,6892
13
13
  lcode/permissions.py,sha256=OcWUDLrSoohUWHDUQ3UuBE3zI2JnBnOPizLE3bIgqyU,3982
14
14
  lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
15
- lcode/repl.py,sha256=vvKndnzchza2FNkluZTOCJ4qZOF5Dhm-Hhj6N_PfFrI,28814
15
+ lcode/repl.py,sha256=2p6UG1xCmn85YEYcWUtgfoLJc3rHLaGLb_VV-RvzYvw,30332
16
+ lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
16
17
  lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
17
- lcode/tools.py,sha256=fK7OOe6IagBrj9aCF3z4faJTZO3jAamzEPF3AaaH5m0,27234
18
+ lcode/tools.py,sha256=iwt7UINg7xkwz1k_BAXFM-siA6KxC6LHuAjaomBHoRU,30287
19
+ lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
18
20
  lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
19
21
  lcode/mcp/__init__.py,sha256=XujtAmRoXW9S0uq1XPf_CAeCkHh_UvA8FCmoAKP9j1Y,171
20
22
  lcode/mcp/auth.py,sha256=vyH3oolgIBNJLYabr2h5Soui99NTY1QxjQEgtczfF6A,16655
21
23
  lcode/mcp/catalog.py,sha256=GnIF1fyeHSut9h2y_VJNAHc2FCJT81EIhLFM40V780k,3504
22
- lcode/mcp/catalog.toml,sha256=rb9Q6NgenYVzlsf5Ej-8yJbFwBMLuLCMegzrU5Rs_kc,7891
24
+ lcode/mcp/catalog.toml,sha256=_kFzKD2FD78KD2URuE5B-kcQehW9Y3Zi2xf5tu3v4Xc,9230
23
25
  lcode/mcp/client.py,sha256=3Gen7zRvWehngXn_dAMOAdKqFkFkQZIQW96hQcW4N4k,7119
24
26
  lcode/mcp/commands.py,sha256=Rlnc-LFydRjHTiW-CSxjmQqX0d2MSiEViLF9kczYIi4,16420
25
- lcode/mcp/config.py,sha256=iSjjucG4VuP9u7aJQrTZsPA9k9oeRyMmz4hV1GE5ljw,8454
27
+ lcode/mcp/config.py,sha256=Ej6WX2b_55a84H5WdAwSk9DzERKoq5ifUBQ9uDgSG_o,8780
26
28
  lcode/mcp/http.py,sha256=sG9MajwU4_6sqvZhwD4WfyzRKzaR_Lc8C02jwePz18E,7380
27
- lcode/mcp/manager.py,sha256=UPpY97jwW_KIi2G4hR860Iq4zgfqNFHjsyeu48aEa44,14784
28
- lcode/mcp/protocol.py,sha256=8uhyzzDQmaXr6pdt9bWD5ccvWZ93rvxd4v3Z5J84vKw,4938
29
+ lcode/mcp/manager.py,sha256=79nNWoT5pxgoQD_Pv4ZH0obHIm7rQ-LyB8OmJnQgc6k,15104
30
+ lcode/mcp/protocol.py,sha256=-xwhA2Tz-vzR2zdSdbPx4IjTu29uRrXMaePg0vEz_Vc,5223
29
31
  lcode/mcp/stdio.py,sha256=kC6KHwzMMDjBr-yCZLmyVKS7PZXU299WpZdYRoX5eRQ,6292
30
- lcode_cli-0.4.1.dist-info/METADATA,sha256=jGUe-zgy_zUWIaqhz9-h6J03eZmXhy9RWwt2BZrgyi0,9869
31
- lcode_cli-0.4.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
32
- lcode_cli-0.4.1.dist-info/entry_points.txt,sha256=pZ_uDQ98UzVAHUQeXn5HeyQWKGSH3r3T5aPHuSB89eI,41
33
- lcode_cli-0.4.1.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
34
- lcode_cli-0.4.1.dist-info/RECORD,,
32
+ lcode_cli-0.6.0.dist-info/METADATA,sha256=oMYnPQJlwjwLZls7GP4mxJkTRLmWM0LaVzAsyQdcqiM,10251
33
+ lcode_cli-0.6.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
34
+ lcode_cli-0.6.0.dist-info/entry_points.txt,sha256=pZ_uDQ98UzVAHUQeXn5HeyQWKGSH3r3T5aPHuSB89eI,41
35
+ lcode_cli-0.6.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
36
+ lcode_cli-0.6.0.dist-info/RECORD,,