lcode-cli 0.38.0__py3-none-any.whl → 0.40.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.38.0"
6
+ __version__ = "0.40.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
lcode/agent.py CHANGED
@@ -304,6 +304,10 @@ class Progress:
304
304
  class Settings:
305
305
  model: str # Ollama model name to call
306
306
  context: int
307
+ mode: str = "code" # what lcode makes: code, image or video (lcode.modes)
308
+ comfyui_url: str = "http://127.0.0.1:8188"
309
+ comfyui_path: str | None = None # a ComfyUI install lcode doesn't find by itself (lcode.comfy)
310
+ media_dir: str = ".lcode/media" # where image and video mode save their files (lcode.media)
307
311
  num_batch: int | None = None
308
312
  keep_alive: str = "30m"
309
313
  think: bool = True
@@ -368,6 +372,8 @@ class Agent:
368
372
  self.session_name = ""
369
373
  self.session_title = ""
370
374
  self.interactive = True # False for `lcode -p`: no end-of-session questions
375
+ self.comfy_process = None # the ComfyUI lcode started for image and video mode, stopped when it exits
376
+ self.no_coding_model = "" # why there's no coding model, when lcode started in image or video mode without
371
377
  self.reflected = 0 # messages before this index were already checked for notes to remember
372
378
  self._memory: memory_notes.Memory | None = None
373
379
  self._agent_types: tuple[Path, dict[str, subagents.AgentType], list[str]] | None = None
@@ -680,6 +686,7 @@ class Agent:
680
686
  data = {
681
687
  "cwd": str(self.workspace), # where `lcode -c` there finds it, wherever the shell moved
682
688
  "model": self.settings.model,
689
+ "mode": self.settings.mode,
683
690
  "name": self.session_name,
684
691
  "title": self.session_title,
685
692
  "messages": self.messages,
@@ -726,6 +733,8 @@ class Agent:
726
733
  self.session_id = info.id
727
734
  self.checkpoints.load(info.id, data.get("checkpoints") or [])
728
735
  self.session_name = data.get("name", "")
736
+ if data.get("mode") in ("code", "image", "video"):
737
+ self.settings.mode = data["mode"] # a session belongs to the mode it was made in
729
738
  self.session_title = data.get("title") or sessions.title_from(self.messages)
730
739
  self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
731
740
  self.tools.todos = list(data.get("todos") or [])
lcode/api.py CHANGED
@@ -15,6 +15,7 @@ A session is one conversation: each `run` continues it. `stream` yields the same
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ import contextlib
18
19
  import io
19
20
  import queue
20
21
  import threading
@@ -54,6 +55,7 @@ class Options:
54
55
  model: str | None = None
55
56
  context: int | str | None = None
56
57
  permission_mode: str | None = None # ask, plan, auto-edit or yolo
58
+ mode: str | None = None # code, image or video
57
59
  allowed_tools: list[str] | None = None # only these tools (default: all)
58
60
  max_steps: int | None = None # model steps per request
59
61
  think: bool | None = None
@@ -129,6 +131,24 @@ def switch(wanted: bool | None, configured: str, default_on: str) -> str:
129
131
  return configured if configured != "off" else default_on
130
132
 
131
133
 
134
+ def coding_model(ollama, options: Options, cfg: dict, requested: int | None, hw: Hardware, console: Console):
135
+ """The coding model to use, its catalog entry and the context window; SetupError when there's none."""
136
+ from lcode import cli
137
+
138
+ check_server(ollama, hw)
139
+ try:
140
+ model, spec = cli.resolve_model(ollama, options.model or cfg["model"])
141
+ except cli.NotInstalled as e:
142
+ first_run = not options.model and cfg["model"] == config.DEFAULT_MODEL and not config.CONFIG_PATH.exists()
143
+ if first_run and not backends.is_openai(ollama):
144
+ raise SetupError("lcode isn't set up yet. Run: lcode setup") from e
145
+ raise SetupError(str(e)) from e
146
+ context, note = cli.choose_context(ollama, model, spec, requested, hw)
147
+ if note:
148
+ console.print(f"[yellow]Context {config.format_tokens(context)}: {note}[/]")
149
+ return model, spec, context
150
+
151
+
132
152
  def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | None = None) -> Agent:
133
153
  """An agent set up the way `lcode` sets up a session: model, context, hooks, language servers, MCP."""
134
154
  from lcode import catalog, cli, extensions, hooks
@@ -152,17 +172,14 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
152
172
  except OllamaError as e:
153
173
  raise SetupError(str(e)) from e
154
174
  hw = hw or detect()
155
- check_server(ollama, hw)
175
+ no_coding_model = "" # why, when image or video mode starts without one
156
176
  try:
157
- model, spec = cli.resolve_model(ollama, options.model or cfg["model"])
158
- except cli.NotInstalled as e:
159
- first_run = not options.model and cfg["model"] == config.DEFAULT_MODEL and not config.CONFIG_PATH.exists()
160
- if first_run and not backends.is_openai(ollama):
161
- raise SetupError("lcode isn't set up yet. Run: lcode setup") from e
162
- raise SetupError(str(e)) from e
163
- context, note = cli.choose_context(ollama, model, spec, requested, hw)
164
- if note:
165
- console.print(f"[yellow]Context {config.format_tokens(context)}: {note}[/]")
177
+ model, spec, context = coding_model(ollama, options, cfg, requested, hw, console)
178
+ except SetupError as e:
179
+ if (options.mode or cfg["mode"]) == "code":
180
+ raise
181
+ # Image and video mode don't need the coding model: start without it, and say so on /mode code.
182
+ model, spec, context, no_coding_model = options.model or cfg["model"], None, requested or 32768, str(e)
166
183
  num_batch = cfg["num_batch"]
167
184
  if num_batch is None and spec and spec.num_batch:
168
185
  if model == spec.local_name:
@@ -179,6 +196,10 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
179
196
  think=cfg["think"] if options.think is None else options.think,
180
197
  show_thinking=options.show_thinking,
181
198
  permission_mode=options.permission_mode or cfg["permission_mode"],
199
+ mode=options.mode or cfg["mode"],
200
+ comfyui_url=cfg["comfyui_url"],
201
+ comfyui_path=cfg["comfyui_path"],
202
+ media_dir=cfg["media_dir"],
182
203
  web=switch(options.web, cfg["web"], "on"),
183
204
  search_backend=cfg["search_backend"],
184
205
  searxng_url=cfg["searxng_url"],
@@ -208,6 +229,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
208
229
  )
209
230
  agent = Agent(ollama, settings, cwd, console=console)
210
231
  agent.hardware = hw
232
+ agent.no_coding_model = no_coding_model
211
233
  agent.autosave = True
212
234
  agent.interactive = options.interactive
213
235
  if options.max_steps:
@@ -247,15 +269,25 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
247
269
 
248
270
 
249
271
  def close_agent(agent: Agent) -> None:
272
+ """Stop what lcode started (background jobs, servers, the sandbox, ComfyUI), then say so: the terminal may be
273
+ gone already (closed), and saying it then fails."""
274
+ from lcode import media
275
+
276
+ said = []
250
277
  stopped = agent.jobs.stop_all()
251
278
  if stopped:
252
- agent.console.print(f"[dim]Stopped {stopped} background job(s).[/]")
279
+ said.append(f"Stopped {stopped} background job(s).")
280
+ if media.stop_engine(agent):
281
+ said.append("Stopped ComfyUI.")
253
282
  if agent.lsp is not None:
254
283
  agent.lsp.close()
255
284
  if agent.mcp:
256
285
  agent.mcp.close()
257
286
  if agent.sandbox:
258
287
  agent.sandbox.stop()
288
+ for line in said:
289
+ with contextlib.suppress(OSError):
290
+ agent.console.print(f"[dim]{line}[/]")
259
291
 
260
292
 
261
293
  # ----------------------------------------------------------------------------- running
lcode/cli.py CHANGED
@@ -3,9 +3,11 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import contextlib
6
7
  import platform
7
8
  import re
8
9
  import shutil
10
+ import signal
9
11
  import sys
10
12
  import tempfile
11
13
  import time
@@ -466,6 +468,7 @@ def cmd_doctor(args) -> None:
466
468
  except ValueError as e:
467
469
  line("MCP", str(e), False)
468
470
  ok = False
471
+ line("ComfyUI", *comfyui_status(cfg))
469
472
  if cfg["sandbox"] == "off":
470
473
  line("Sandbox", "off (lcode config set sandbox docker)", True)
471
474
  else:
@@ -714,6 +717,7 @@ def cmd_chat(args) -> None:
714
717
  model=args.model,
715
718
  context=args.context,
716
719
  permission_mode=mode,
720
+ mode=args.mode,
717
721
  allowed_tools=tools,
718
722
  max_steps=args.max_steps,
719
723
  think=False if args.no_think else None,
@@ -732,7 +736,10 @@ def cmd_chat(args) -> None:
732
736
  print(json.dumps({"type": "result", "status": "error", "error": str(e)}), flush=True)
733
737
  sys.exit(1)
734
738
  fail(str(e))
739
+ exit_on_hangup()
735
740
  exit_code = 0
741
+ if args.prompt and agent.settings.mode != "code":
742
+ media_request(agent, args.prompt, out, quiet) # makes the image or video, then exits
736
743
  if args.escalate and args.prompt:
737
744
  agent.escalate_approved = True # the flag is the user's OK for this run
738
745
  try:
@@ -754,6 +761,83 @@ def cmd_chat(args) -> None:
754
761
  sys.exit(exit_code)
755
762
 
756
763
 
764
+ def exit_on_hangup() -> None:
765
+ """A closed terminal (SIGHUP) or a plain kill (SIGTERM) ends lcode the usual way: it saves the session and stops
766
+ what it started (ComfyUI, MCP servers, background jobs) instead of leaving them running."""
767
+
768
+ def leave(signum, frame):
769
+ raise SystemExit(128 + signum)
770
+
771
+ for name in ("SIGHUP", "SIGTERM"):
772
+ sig = getattr(signal, name, None)
773
+ if sig is not None:
774
+ with contextlib.suppress(ValueError, OSError): # only the main thread can
775
+ signal.signal(sig, leave)
776
+
777
+
778
+ def comfyui_status(cfg: dict) -> tuple[str, bool | None]:
779
+ """lcode doctor's ComfyUI line: running or found, and whether the image model is there."""
780
+ from lcode import comfy, media
781
+
782
+ workflow = comfy.load_workflow(media.IMAGE_WORKFLOW)
783
+ server = comfy.Server(cfg["comfyui_url"])
784
+ stats = server.stats()
785
+ install = None if stats else comfy.find_install(cfg["comfyui_path"])
786
+ if not stats and install is None:
787
+ return "not found: image mode offers to install it (or lcode config set comfyui_path <folder>)", None
788
+ if stats:
789
+ where = f"{(stats.get('system') or {}).get('comfyui_version', '?')} running at {server.url}"
790
+ installed = {folder: set(server.models(folder)) for folder in workflow.models}
791
+ else:
792
+ where = f"{install.path} (lcode starts it in image mode)"
793
+ installed = {folder: set(comfy.local_models(install, folder)) for folder in workflow.models}
794
+ missing = [f"{f}/{names[0]}" for f, names in workflow.models.items() if not installed[f] & set(names)]
795
+ if missing:
796
+ return f"{where} · {workflow.name} not installed (missing {', '.join(missing)})", None
797
+ return f"{where} · image model: {workflow.name}", True
798
+
799
+
800
+ def media_request(agent, prompt: str, out: Console, quiet: bool) -> None:
801
+ """`lcode --mode image -p "…"`: make the image, say where it went (or, with --output json, a result object)."""
802
+ import json
803
+
804
+ from lcode import api, media, modes
805
+
806
+ if agent.settings.mode == "video":
807
+ message = modes.COMING["video"]
808
+ if quiet:
809
+ print(json.dumps({"type": "result", "status": "error", "error": message}), flush=True)
810
+ else:
811
+ out.print(f"[yellow]{escape(message)}[/]")
812
+ api.close_agent(agent)
813
+ sys.exit(2)
814
+ try:
815
+ made = media.generate_image(agent, prompt)
816
+ except (media.MediaError, KeyboardInterrupt) as e:
817
+ message = str(e) or "Cancelled."
818
+ if quiet:
819
+ print(json.dumps({"type": "result", "status": "error", "error": message}), flush=True)
820
+ else:
821
+ out.print(f"[red]{escape(message)}[/]")
822
+ api.close_agent(agent)
823
+ sys.exit(1)
824
+ if quiet:
825
+ result = {
826
+ "type": "result",
827
+ "status": "success",
828
+ "mode": "image",
829
+ "files": [str(path) for path in made.files],
830
+ "model": made.model,
831
+ "seed": made.seed,
832
+ "width": made.width,
833
+ "height": made.height,
834
+ "seconds": round(made.seconds, 1),
835
+ }
836
+ print(json.dumps(result, ensure_ascii=False), flush=True)
837
+ api.close_agent(agent)
838
+ sys.exit(0)
839
+
840
+
757
841
  def compact_tokens(value: str) -> int:
758
842
  """`--compact-at`: a token count such as 64k, or 0."""
759
843
  try:
@@ -798,6 +882,7 @@ def build_parser() -> argparse.ArgumentParser:
798
882
  help="work in a git worktree of its own, on branch NAME (new, or continue one); removed if nothing changed",
799
883
  )
800
884
  parser.add_argument("--plan", action="store_true", help="start in plan mode: agree on a plan before any change")
885
+ parser.add_argument("--mode", choices=config.WORK_MODES, help="what lcode makes: code (default), image or video")
801
886
  parser.add_argument("--auto-edit", action="store_true", help="apply file edits without asking")
802
887
  parser.add_argument("--yolo", action="store_true", help="never ask for permission (edits and commands)")
803
888
  parser.add_argument(