lcode-cli 0.35.0__py3-none-any.whl → 0.36.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.35.0"
6
+ __version__ = "0.36.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
lcode/agent.py CHANGED
@@ -51,7 +51,7 @@ from lcode.mcp import McpManager
51
51
  from lcode.ollama import Ollama, OllamaError
52
52
  from lcode.permissions import LEADING_CD, Permissions
53
53
  from lcode.render import MarkdownStreamer
54
- from lcode.repeats import Repeats
54
+ from lcode.repeats import Circles, Repeats
55
55
  from lcode.sandbox import Sandbox, SandboxError, project_root
56
56
  from lcode.steer import NOTE_INTRO, Steering
57
57
  from lcode.tools import (
@@ -71,6 +71,7 @@ from lcode.tools import (
71
71
  tree,
72
72
  truncate,
73
73
  )
74
+ from lcode.unreachable import NOTE_AT, Sites
74
75
 
75
76
  GPU_MEMORY_ERRORS = (
76
77
  "out of memory",
@@ -80,6 +81,27 @@ GPU_MEMORY_ERRORS = (
80
81
  )
81
82
  SAFE_NUM_BATCH = 512 # Ollama's default prompt batch
82
83
  MAX_STEPS_PER_TURN = 150
84
+ MAX_STALLS = 2 # replies that go round in circles or say nothing, per request, before lcode stops it
85
+ STALLED = {
86
+ "circles": "the reply went round in circles, so lcode stopped it",
87
+ "cut_off": "the reply filled the context before it said anything",
88
+ "": "the reply was empty",
89
+ }
90
+
91
+
92
+ def stall_note(trouble: str) -> str:
93
+ """What the model is told after a reply that went round in circles or ended without a word."""
94
+ first = {
95
+ "circles": "Your last reply went round in circles (the same thoughts over and over), so lcode stopped it.",
96
+ "cut_off": "Your last reply filled the rest of the context before you said anything.",
97
+ "": "Your last reply was empty.",
98
+ }[trouble]
99
+ return (
100
+ f"[lcode] {first} Don't deliberate at length now. Either make your next tool call, or, if you're stuck, "
101
+ "tell the user in a few lines what you did, what fails, and what they could do."
102
+ )
103
+
104
+
83
105
  EVENT_OUTPUT = 4000 # characters of a tool's output in an event
84
106
  # How a tool result starts when the call failed or wasn't allowed (an "error" in events and results).
85
107
  REFUSED = ("Error:", "A deny rule", "The user denied", "This needs the user's permission")
@@ -319,6 +341,9 @@ class Agent:
319
341
  self.ollama = ollama
320
342
  self.settings = settings
321
343
  self.cwd = cwd.resolve()
344
+ # The folder lcode works in: where it started, or where /cd or a resumed session took it. A shell
345
+ # command may move the working directory around its project (self.cwd), but not out of it.
346
+ self.workspace = self.cwd
322
347
  self.perms = Permissions(self.console, settings.permission_mode)
323
348
  self.tools = Toolbox(self)
324
349
  self.session_id = self.new_session_id()
@@ -377,8 +402,10 @@ class Agent:
377
402
  self.escalate_approved: bool | None = None # decided in advance (lcode -p --escalate, the benchmark)
378
403
  self.sent_chars = 0 # characters of the conversation the model had already read
379
404
  self.counted_chars: int | None = None # characters that ctx_used covers (None: all of them)
380
- self.turn_status = "" # how the last request ended: success, max_steps, loop
405
+ self.turn_status = "" # how the last request ended: success, max_steps, loop, blocked
381
406
  self.repeats = Repeats(settings.repeat_limit) # notices when the model repeats itself
407
+ self.sites = Sites(NOTE_AT if settings.repeat_limit else 0) # notices a site this machine can't reach
408
+ self.reply_trouble = "" # what went wrong with the last reply: "circles", "cut_off" or ""
382
409
  self.response = None # the streaming response, so another thread can abort it
383
410
  self.ctx_used = 0
384
411
  self.last_speed = 0.0
@@ -648,7 +675,7 @@ class Agent:
648
675
  f = self.session_file()
649
676
  f.parent.mkdir(parents=True, exist_ok=True)
650
677
  data = {
651
- "cwd": str(self.cwd),
678
+ "cwd": str(self.workspace), # where `lcode -c` there finds it, wherever the shell moved
652
679
  "model": self.settings.model,
653
680
  "name": self.session_name,
654
681
  "title": self.session_title,
@@ -687,7 +714,7 @@ class Agent:
687
714
  saved_cwd = Path(info.cwd) if info.cwd else self.cwd
688
715
  if saved_cwd != self.cwd:
689
716
  if saved_cwd.is_dir():
690
- self.cwd = saved_cwd.resolve()
717
+ self.cwd = self.workspace = saved_cwd.resolve()
691
718
  note = f"Working directory is now {self.cwd}"
692
719
  else:
693
720
  note = f"The session's directory {saved_cwd} no longer exists; staying in {self.cwd}"
@@ -707,7 +734,7 @@ class Agent:
707
734
  return note
708
735
 
709
736
  def load_latest(self) -> bool:
710
- latest = sessions.list_sessions(self.cwd, limit=1)
737
+ latest = sessions.list_sessions(self.workspace, limit=1)
711
738
  if latest:
712
739
  self.load(latest[0])
713
740
  return bool(latest)
@@ -805,8 +832,11 @@ class Agent:
805
832
  status = None
806
833
 
807
834
  start_spinner()
835
+ circles = Circles()
836
+ self.reply_trouble = ""
837
+ stream = self.chat(self.messages, self.tool_schemas(), self.settings.think)
808
838
  try:
809
- for chunk in self.chat(self.messages, self.tool_schemas(), self.settings.think):
839
+ for chunk in stream:
810
840
  if self.cancel is not None and self.cancel.is_set():
811
841
  raise KeyboardInterrupt
812
842
  msg = chunk.get("message", {})
@@ -822,6 +852,9 @@ class Agent:
822
852
  stop_spinner() # raw text is printed as it streams
823
853
  self.console.print(Text(msg["thinking"], style="dim italic"), end="")
824
854
  printed_thinking = True
855
+ if self.settings.repeat_limit and circles.feed(msg["thinking"]):
856
+ self.reply_trouble = "circles"
857
+ break # closing the stream below stops the model
825
858
  if msg.get("content"):
826
859
  if printed_thinking:
827
860
  self.console.print("\n")
@@ -846,9 +879,12 @@ class Agent:
846
879
  self.messages.append({"role": "assistant", "content": content + "\n[interrupted by user]"})
847
880
  raise
848
881
  finally:
882
+ stream.close()
849
883
  stop_spinner()
850
884
  if watch is not None:
851
885
  watch.stop()
886
+ if final.get("done_reason") == "length" and not self.reply_trouble:
887
+ self.reply_trouble = "cut_off" # it filled the context (or hit the output limit) mid-reply
852
888
  if printed_thinking:
853
889
  self.console.print()
854
890
  md.flush()
@@ -918,6 +954,12 @@ class Agent:
918
954
  "is final, and gives you the failures to fix. Run them yourself while you work, too."
919
955
  )
920
956
 
957
+ def unrun_scripts(self, commands: list[str]) -> list[str]:
958
+ """Shell scripts the model wrote in this request and never ran (only where the checks would run)."""
959
+ if self.allowed_tools is not None or self.planning() or self.no_changes or self.settings.checks == "off":
960
+ return []
961
+ return checks.unrun_scripts(project_root_or_cwd(self.cwd), list(self.tools.before), commands)
962
+
921
963
  def project_check(self) -> checks.Check | None:
922
964
  """The project's check command, for the main agent outside plan mode and reviews (None otherwise)."""
923
965
  if self.allowed_tools is not None or self.planning() or self.no_changes or self.settings.checks == "off":
@@ -1106,6 +1148,7 @@ class Agent:
1106
1148
  self.messages.append({"role": "user", "content": self.expand_mentions(user_text) + note})
1107
1149
  self.turn_requests = [user_text.strip()]
1108
1150
  self.repeats.reset()
1151
+ self.sites.reset()
1109
1152
  self.check_results = []
1110
1153
  self.review_results = None
1111
1154
  self.escalations = []
@@ -1116,6 +1159,10 @@ class Agent:
1116
1159
  check_rounds = 0
1117
1160
  malformed = 0
1118
1161
  looping = "" # the call the model kept repeating, when lcode stopped the request for it
1162
+ blocked = None # the site that couldn't be reached, when lcode stopped the request for it
1163
+ stalls = 0 # replies that went round in circles or ended without a word
1164
+ commands: list[str] = [] # shell commands run in this request
1165
+ scripts_noted = False # the model was told about scripts it wrote and never ran
1119
1166
  for step in range(self.max_steps):
1120
1167
  if self.cancel is not None and self.cancel.is_set():
1121
1168
  raise KeyboardInterrupt
@@ -1165,9 +1212,46 @@ class Agent:
1165
1212
  }
1166
1213
  )
1167
1214
  continue
1215
+ if not calls and (self.reply_trouble == "circles" or not (reply.get("content") or "").strip()):
1216
+ stalls += 1
1217
+ self.messages.pop() # nothing to keep: no answer, and circles would only fill the context
1218
+ if stalls < MAX_STALLS:
1219
+ self.console.print(
1220
+ Text(f" ⎿ {STALLED[self.reply_trouble]}: asking for a short answer", style="yellow")
1221
+ )
1222
+ self.messages.append({"role": "user", "content": stall_note(self.reply_trouble)})
1223
+ continue
1224
+ why = "its replies went round in circles or ended without an answer, twice in this request"
1225
+ if not escalated and self.can_escalate():
1226
+ escalated = True
1227
+ advice = escalate.run(self, why)
1228
+ if advice:
1229
+ self.messages.append({"role": "user", "content": advice})
1230
+ stalls = 0
1231
+ continue
1232
+ self.turn_status = "loop"
1233
+ self.console.print(
1234
+ Text(
1235
+ "Stopped: the model's replies went round in circles or ended without an answer, twice in "
1236
+ "this request. Try again with a smaller step, or tell it what to do differently.",
1237
+ style="yellow",
1238
+ )
1239
+ )
1240
+ return
1168
1241
  if not calls:
1242
+ if self.reply_trouble == "cut_off":
1243
+ self.say(
1244
+ "The answer was cut off: the context window is full. /compact or a larger /context helps.",
1245
+ warning=True,
1246
+ )
1169
1247
  if self.steering is not None and self.steering.waiting():
1170
1248
  continue # the user added a note while the model wrote its answer: it reads the note first
1249
+ unrun = [] if scripts_noted else self.unrun_scripts(commands)
1250
+ if unrun:
1251
+ scripts_noted = True
1252
+ self.console.print(Text(f" ⎿ never ran {', '.join(unrun)}: asking the model to", style="yellow"))
1253
+ self.messages.append({"role": "user", "content": checks.unrun_note(unrun)})
1254
+ continue
1171
1255
  check = self.project_check() if changed and not verified else None
1172
1256
  if check is not None and check_rounds < checks.MAX_ROUNDS:
1173
1257
  check_rounds += 1
@@ -1247,6 +1331,21 @@ class Agent:
1247
1331
  )
1248
1332
  if verdict.stop:
1249
1333
  looping = self.describe_call(name, args)
1334
+ out_of_reach = self.sites.check(name, args, result)
1335
+ if out_of_reach.note and not looping:
1336
+ result += out_of_reach.note
1337
+ what = "stopping the request" if out_of_reach.stop else "told the model"
1338
+ self.console.print(
1339
+ Text(
1340
+ f" ⎿ {out_of_reach.calls} calls to {out_of_reach.site} failed "
1341
+ f"({out_of_reach.what}), none worked: {what}",
1342
+ style="yellow",
1343
+ )
1344
+ )
1345
+ if out_of_reach.stop:
1346
+ blocked = out_of_reach
1347
+ if name == "bash":
1348
+ commands.append(str(args.get("command", "")))
1250
1349
  if name in ("edit_file", "write_file", "apply_patch") and not result.startswith(REFUSED):
1251
1350
  changed, verified = True, False
1252
1351
  elif name == "bash" and changed and self.model_ran_checks(str(args.get("command", "")), result):
@@ -1264,7 +1363,7 @@ class Agent:
1264
1363
  "output": result[:EVENT_OUTPUT] + ("…" if len(result) > EVENT_OUTPUT else ""),
1265
1364
  }
1266
1365
  )
1267
- if looping:
1366
+ if looping or blocked:
1268
1367
  for rest in calls[i + 1 :]:
1269
1368
  skipped = {
1270
1369
  "role": "tool",
@@ -1283,6 +1382,18 @@ class Agent:
1283
1382
  self.repeats.reset()
1284
1383
  looping = ""
1285
1384
  continue
1385
+ if blocked:
1386
+ self.turn_status = "blocked"
1387
+ self.console.print(
1388
+ Text(
1389
+ f"Stopped: every request to {blocked.site} failed ({blocked.calls} calls: {blocked.what}) and "
1390
+ "none worked, so this machine probably can't reach it: a firewall, a proxy, a regional block "
1391
+ "or no internet. Check the connection (another network or a VPN, a proxy setting), or tell "
1392
+ "the model another way to get what it needs.",
1393
+ style="yellow",
1394
+ )
1395
+ )
1396
+ return
1286
1397
  if looping:
1287
1398
  self.turn_status = "loop"
1288
1399
  self.console.print(
lcode/api.py CHANGED
@@ -285,7 +285,7 @@ def run_request(agent: Agent, prompt: str) -> Result:
285
285
 
286
286
  try:
287
287
  agent.run_turn(prompt)
288
- status = agent.turn_status if agent.turn_status in ("max_steps", "loop") else "success"
288
+ status = agent.turn_status if agent.turn_status in ("max_steps", "loop", "blocked") else "success"
289
289
  except KeyboardInterrupt:
290
290
  status, error = "interrupted", "interrupted before the request was done"
291
291
  except ModelError as e:
lcode/backends.py CHANGED
@@ -302,6 +302,7 @@ class OpenAICompatible:
302
302
  calls: dict[int, dict] = {}
303
303
  usage: dict = {}
304
304
  timings: dict = {} # llama-server measures prompt and generation time itself
305
+ finished = "" # why the reply ended, as Ollama's done_reason: "length" when it was cut off
305
306
  splitter = ThinkSplitter()
306
307
  first_token = None
307
308
  with r:
@@ -323,6 +324,7 @@ class OpenAICompatible:
323
324
  usage = chunk.get("usage") or usage
324
325
  timings = chunk.get("timings") or timings
325
326
  for choice in chunk.get("choices") or []:
327
+ finished = choice.get("finish_reason") or finished
326
328
  delta = choice.get("delta") or {}
327
329
  thinking = delta.get("reasoning_content") or delta.get("reasoning")
328
330
  if thinking:
@@ -356,6 +358,7 @@ class OpenAICompatible:
356
358
  "prompt_eval_duration": first_token - started,
357
359
  "eval_count": int(usage.get("completion_tokens") or 0),
358
360
  "eval_duration": max(1, ended - first_token),
361
+ "done_reason": "length" if finished == "length" else "stop",
359
362
  }
360
363
  if timings.get("prompt_ms") is not None and timings.get("predicted_ms"):
361
364
  # The whole prompt counts (it's what fills the context), cached tokens included.
lcode/bench.py CHANGED
@@ -904,7 +904,7 @@ def run_task(
904
904
  if agent is None:
905
905
  agent = Agent(ollama, settings, folder, console=log)
906
906
  else: # the same conversation moves on to this task's folder, as with /cd
907
- agent.cwd = folder.resolve()
907
+ agent.cwd = agent.workspace = folder.resolve()
908
908
  agent.messages[0]["content"] = agent.system_prompt()
909
909
  if settings.embed_model != "off": # --code-search: index the task's folder first, outside the timer
910
910
  from lcode import codesearch
lcode/checks.py CHANGED
@@ -158,3 +158,74 @@ def missing(check: Check, result: str) -> bool:
158
158
  return True
159
159
  head = result.splitlines()[:5] # a missing program says so first; test failures look different
160
160
  return code not in (0, None) and any(MISSING.search(line) for line in head)
161
+
162
+
163
+ # ----------------------------------------------------------------------------- scripts written, never run
164
+
165
+ SCRIPT_SUFFIXES = (".sh", ".bash", ".zsh")
166
+ SHELL_SHEBANG = re.compile(r"^#!\S*?(?:/|env\s+)(?:ba|z|da|k)?sh\b")
167
+ RUNNERS = frozenset({"bash", "sh", "zsh", "dash", "ksh", "source", ".", "exec"})
168
+ PREFIXES = frozenset({"sudo", "time", "nohup", "env", "command", "nice"})
169
+ SEGMENTS = re.compile(r"&&|\|\||[;|\n&()]")
170
+
171
+
172
+ def unrun_scripts(root: Path, changed: list[str], commands: list[str]) -> list[str]:
173
+ """Shell scripts written in this request that no command in it ran.
174
+
175
+ Small models write a build or install script and call it done without running it once; the first thing
176
+ the user then sees is its first error. Only shell scripts count (a .sh file, or a shell shebang): a Python
177
+ file with a shebang is as likely an app or a server that never ends.
178
+ """
179
+ unrun = []
180
+ for rel in changed:
181
+ path = root / rel
182
+ if not is_shell_script(path):
183
+ continue
184
+ if not any(runs(command, path.name) for command in commands):
185
+ unrun.append(rel)
186
+ return unrun
187
+
188
+
189
+ def is_shell_script(path: Path) -> bool:
190
+ if not path.is_file():
191
+ return False
192
+ if path.suffix in SCRIPT_SUFFIXES:
193
+ return True
194
+ if path.suffix:
195
+ return False
196
+ try:
197
+ with path.open("rb") as f:
198
+ first = f.readline(200).decode("utf-8", "replace")
199
+ except OSError:
200
+ return False
201
+ return bool(SHELL_SHEBANG.match(first))
202
+
203
+
204
+ def runs(command: str, name: str) -> bool:
205
+ """Whether a shell command runs the script called `name` (not just chmods, cats or commits it)."""
206
+ for segment in SEGMENTS.split(command):
207
+ words = segment.split()
208
+ while words and (("=" in words[0] and not words[0].startswith("=")) or words[0] in PREFIXES):
209
+ words.pop(0)
210
+ if words and words[0] == "timeout":
211
+ words = words[2:] if len(words) > 2 else []
212
+ if not words:
213
+ continue
214
+ program = words[0].strip("'\"")
215
+ if program in RUNNERS:
216
+ rest = [w.strip("'\"") for w in words[1:] if not w.startswith("-")]
217
+ program = rest[0] if rest else ""
218
+ if Path(program).name == name:
219
+ return True
220
+ return False
221
+
222
+
223
+ def unrun_note(scripts: list[str]) -> str:
224
+ names = ", ".join(scripts)
225
+ it = "it" if len(scripts) == 1 else "them"
226
+ return (
227
+ f"[lcode] You wrote {names} in this request but never ran {it}. Run {it} now and fix what fails, so you know "
228
+ f"{it} works before you say so. If running {it} would change things outside the project (install packages, "
229
+ f"deploy, delete data) or needs something you don't have, don't: tell the user plainly that {it} is untested "
230
+ "and why."
231
+ )
lcode/cli.py CHANGED
@@ -113,44 +113,96 @@ def choose_context(
113
113
  # ----------------------------------------------------------------------------- lcode models
114
114
 
115
115
 
116
- def print_served(client: backends.OpenAICompatible, current: str | None) -> None:
116
+ def print_served(
117
+ client: backends.OpenAICompatible, current: str | None, numbered: bool = False, out: Console | None = None
118
+ ) -> list[str]:
119
+ out = out or console
117
120
  try:
118
121
  served = client.chat_models()
119
122
  current = backends.pick_model(client, current) if current else None
120
123
  except OllamaError as e:
121
124
  if not isinstance(e, backends.BackendError) or "doesn't serve" not in str(e):
122
- console.print(f"[red]{e}[/]")
123
- return
124
- console.print(f"[bold]Models served by {escape(client.describe())}[/]")
125
- for name in served:
126
- console.print(f" {escape(name)}" + (" [bold]current[/]" if name == current else ""))
125
+ out.print(f"[red]{e}[/]")
126
+ return []
127
+ served = []
128
+ out.print(f"[bold]Models served by {escape(client.describe())}[/]")
129
+ for i, name in enumerate(served, 1):
130
+ number = f"[cyan]{i:>3}[/] " if numbered else " "
131
+ out.print(f"{number}{escape(name)}" + (" [bold]current[/]" if name == current else ""))
127
132
  if not served:
128
- console.print(" none listed")
129
- console.print(
130
- "[dim]Models are downloaded and loaded in the server itself. Choose one with [/]lcode --model <id>[dim] "
131
- "or [/]lcode config set model <id>[dim].[/]"
132
- )
133
+ out.print(" none listed")
134
+ if not numbered:
135
+ out.print(
136
+ "[dim]Models are downloaded and loaded in the server itself. Choose one with [/]lcode --model <id>[dim] "
137
+ "or [/]lcode config set model <id>[dim].[/]"
138
+ )
139
+ return served
133
140
 
134
141
 
135
- def print_models(ollama: Ollama | None, hw: Hardware, current: str | None = None) -> None:
142
+ def other_installed(ollama: Ollama, installed: set[str]) -> list[str]:
143
+ """Installed Ollama models outside the catalog that can chat and call tools (so lcode can use them), by name.
144
+ Embedding models are left out: Ollama may list `tools` for them too, but not `completion`."""
145
+ known = {name for spec in catalog.load() for name in (spec.tag, spec.local_name)}
146
+ usable = []
147
+ for name in sorted({n.removesuffix(":latest") for n in installed} - known):
148
+ try:
149
+ capabilities = ollama.show(name).get("capabilities") or []
150
+ if "completion" in capabilities and "tools" in capabilities:
151
+ usable.append(name)
152
+ except OllamaError:
153
+ continue
154
+ return usable
155
+
156
+
157
+ def model_choices(ollama: Ollama | None) -> list[str]:
158
+ """What `/models` numbers, in its order: the catalog's keys, then other installed models that call tools.
159
+ For an OpenAI-compatible server, the models it serves."""
136
160
  if ollama is not None and backends.is_openai(ollama):
137
- print_served(ollama, current) # type: ignore[arg-type]
138
- return
161
+ try:
162
+ return ollama.chat_models() # type: ignore[union-attr]
163
+ except OllamaError:
164
+ return []
165
+ try:
166
+ installed = ollama.installed_names() if ollama else set()
167
+ except OllamaError:
168
+ installed = set()
169
+ return [spec.key for spec in catalog.load()] + (other_installed(ollama, installed) if ollama else [])
170
+
171
+
172
+ def print_models(
173
+ ollama: Ollama | None,
174
+ hw: Hardware,
175
+ current: str | None = None,
176
+ numbered: bool = False,
177
+ out: Console | None = None,
178
+ ) -> list[str]:
179
+ """The models table; with `numbered`, a number on each row to pick it by. Returns the rows' names in order."""
180
+ out = out or console
181
+ if ollama is not None and backends.is_openai(ollama):
182
+ return print_served(ollama, current, numbered, out) # type: ignore[arg-type]
139
183
  try:
140
184
  installed = ollama.installed_names() if ollama else set()
141
185
  except OllamaError:
142
186
  installed = set()
143
187
  rec = catalog.recommend(hw)
144
- wide = console.width >= 130 # the descriptive columns don't fit an 80-column terminal
188
+ wide = out.width >= 130 # the descriptive columns don't fit an 80-column terminal
145
189
  columns = ["Key", "Model", "Type", "Size", "Max ctx", "Fits here", "Speed", "Status"]
146
190
  if not wide:
147
191
  columns = [c for c in columns if c not in ("Model", "Type")]
192
+ if numbered:
193
+ columns.insert(0, "#")
148
194
  table = Table(title=f"Models for this machine: {hw.describe()}", title_justify="left", header_style="bold")
149
195
  for col in columns:
150
196
  table.add_column(
151
- col, no_wrap=col in ("Key", "Size", "Max ctx", "Fits here"), min_width=11 if col == "Status" else None
197
+ col,
198
+ no_wrap=col in ("#", "Key", "Size", "Max ctx", "Fits here"),
199
+ min_width=11 if col == "Status" else None,
200
+ justify="right" if col == "#" else "left",
201
+ style="cyan" if col == "#" else None,
152
202
  )
203
+ choices: list[str] = []
153
204
  for spec in catalog.load():
205
+ choices.append(spec.key)
154
206
  ctx, speed = spec.fit(hw)
155
207
  marks = []
156
208
  if spec.local_name in installed or spec.tag in installed:
@@ -161,6 +213,7 @@ def print_models(ollama: Ollama | None, hw: Hardware, current: str | None = None
161
213
  marks.append("[bold]current[/]")
162
214
  marks.append("tested" if spec.tested else "[dim]untested[/]")
163
215
  row = {
216
+ "#": str(len(choices)),
164
217
  "Key": spec.key,
165
218
  "Model": spec.name,
166
219
  "Type": spec.params,
@@ -171,11 +224,18 @@ def print_models(ollama: Ollama | None, hw: Hardware, current: str | None = None
171
224
  "Status": ", ".join(marks) if wide else "\n".join(marks),
172
225
  }
173
226
  table.add_row(*(row[c] for c in columns))
174
- console.print(table)
175
- console.print(
176
- "[dim]Install one with [/]lcode setup <key>[dim]. 'Fits here' is the largest context that fits in memory; "
177
- "choose another with --context. Any other Ollama model with tool calling also works: lcode --model <tag>[/]"
178
- )
227
+ for name in other_installed(ollama, installed) if ollama else []:
228
+ choices.append(name)
229
+ marks = ["[green]installed[/]"] + (["[bold]current[/]"] if name == current else []) + ["[dim]not in catalog[/]"]
230
+ row = {"#": str(len(choices)), "Key": escape(name), "Status": ", ".join(marks) if wide else "\n".join(marks)}
231
+ table.add_row(*(row.get(c, "") for c in columns))
232
+ out.print(table)
233
+ if not numbered:
234
+ out.print(
235
+ "[dim]Install one with [/]lcode setup <key>[dim]. 'Fits here' is the largest context that fits in memory; "
236
+ "choose another with --context. Any other Ollama model with tool calling also works: lcode --model <tag>[/]"
237
+ )
238
+ return choices
179
239
 
180
240
 
181
241
  def cmd_models(args) -> None:
lcode/repeats.py CHANGED
@@ -16,6 +16,8 @@ from __future__ import annotations
16
16
 
17
17
  import hashlib
18
18
  import json
19
+ import re
20
+ from collections import deque
19
21
  from dataclasses import dataclass
20
22
 
21
23
  from lcode.permissions import is_read_only
@@ -102,3 +104,38 @@ def changes_things(name: str, args: dict, result: str) -> bool:
102
104
  if name == "bash":
103
105
  return not is_read_only(str(args.get("command", "")))
104
106
  return True # other tools (MCP, subagents…) might
107
+
108
+
109
+ class Circles:
110
+ """Notice a reply that goes round in circles while it streams: the same sentences over and over.
111
+
112
+ Small models sometimes fall into this while they think: a few paragraphs, repeated until the context is
113
+ full. It takes minutes behind the spinner and ends without an answer. Sentences shorter than MIN_SENTENCE
114
+ don't count (they repeat legitimately). The text is going round in circles when, of the last WINDOW
115
+ sentences, LOOPING or more were each seen REPEATS times or more.
116
+ """
117
+
118
+ MIN_SENTENCE = 20
119
+ WINDOW = 20
120
+ REPEATS = 4
121
+ LOOPING = 16
122
+ SPLIT = re.compile(r"\n|(?<=[.!?])\s+")
123
+
124
+ def __init__(self) -> None:
125
+ self.partial = ""
126
+ self.seen: dict[str, int] = {}
127
+ self.recent: deque[str] = deque(maxlen=self.WINDOW)
128
+
129
+ def feed(self, text: str) -> bool:
130
+ """Add streamed text; whether it's now going round in circles."""
131
+ parts = self.SPLIT.split(self.partial + text)
132
+ self.partial = parts.pop()
133
+ for part in parts:
134
+ sentence = " ".join(part.split()).lower()
135
+ if len(sentence) < self.MIN_SENTENCE:
136
+ continue
137
+ self.seen[sentence] = self.seen.get(sentence, 0) + 1
138
+ self.recent.append(sentence)
139
+ if len(self.recent) < self.WINDOW:
140
+ return False
141
+ return sum(self.seen[s] >= self.REPEATS for s in self.recent) >= self.LOOPING
lcode/repl.py CHANGED
@@ -68,8 +68,8 @@ COMMANDS = {
68
68
  "/compact": "Summarize the conversation to free context (optional: what to focus on)",
69
69
  "/context": "Show context usage and change the window size: pick from a list, or /context 128k",
70
70
  "/ctx": "Shortcut for /context",
71
- "/model": "Show or switch model, e.g. /model qwen3.5-9b",
72
- "/models": "List models and how they fit this machine",
71
+ "/model": "Switch model: pick a number from the list, or /model 2, /model qwen3.5-9b",
72
+ "/models": "List models and how they fit this machine; type a number to switch",
73
73
  "/think": "Toggle model reasoning on/off",
74
74
  "/verbose": "Toggle showing the model's reasoning text",
75
75
  "/plan": "Plan before changing anything: /plan <request>, or /plan to show the approved plan",
@@ -243,14 +243,20 @@ def banner(agent: Agent) -> None:
243
243
  CONTEXT_CHOICES = (16384, 32768, 65536, 131072, 262144, 524288, 1048576)
244
244
 
245
245
 
246
+ def model_limit(agent: Agent) -> int | None:
247
+ """The largest context the model supports (what Ollama reports, else the catalog's), if known."""
248
+ spec = catalog.find(agent.settings.model)
249
+ try:
250
+ return agent.ollama.max_context(agent.settings.model) or (spec.max_context if spec else None)
251
+ except OllamaError:
252
+ return spec.max_context if spec else None
253
+
254
+
246
255
  def context_options(agent: Agent, hardware: Hardware) -> list[dict]:
247
256
  """The context sizes this model supports, with how each one fits this machine."""
248
257
  s = agent.settings
249
258
  spec = catalog.find(s.model)
250
- try:
251
- max_ctx = agent.ollama.max_context(s.model) or (spec.max_context if spec else None)
252
- except OllamaError:
253
- max_ctx = spec.max_context if spec else None
259
+ max_ctx = model_limit(agent)
254
260
  sizes = {c for c in CONTEXT_CHOICES if not max_ctx or c <= max_ctx} | {s.context}
255
261
  if max_ctx:
256
262
  sizes.add(max_ctx)
@@ -317,6 +323,15 @@ def context_command(agent: Agent, arg: str, hardware: Hardware) -> None:
317
323
  marks.append("[cyan]recommended[/]")
318
324
  table.add_row(str(i), format_tokens(option["size"]), option["note"], ", ".join(marks))
319
325
  c.print(table)
326
+ limit = model_limit(agent)
327
+ beyond = [size for size in CONTEXT_CHOICES if limit and size > limit]
328
+ if beyond:
329
+ sizes = " and ".join(format_tokens(size) for size in beyond[:2])
330
+ c.print(
331
+ f"[dim]Not offered: {sizes}{' and more' if len(beyond) > 2 else ''}. {s.model} was trained on up to "
332
+ f"{format_tokens(limit)} tokens, and Ollama caps the window there whatever the memory, so a larger "
333
+ "setting would only look larger.[/]"
334
+ )
320
335
  try:
321
336
  answer = input(f" Choose a number, a size like 96k, or press Enter to keep {format_tokens(s.context)}: ")
322
337
  except EOFError:
@@ -809,6 +824,63 @@ def restore_checkpoint(agent: Agent, target: Checkpoint, rewind: bool) -> None:
809
824
  agent.save()
810
825
 
811
826
 
827
+ def model_command(agent: Agent, arg: str, hardware: Hardware) -> None:
828
+ """/models and /model: the numbered list to pick from, or straight to a number or a name (/model 2)."""
829
+ from lcode.cli import model_choices, print_models
830
+
831
+ c, s = agent.console, agent.settings
832
+ if arg and not arg.isdigit():
833
+ switch_model(agent, arg, hardware)
834
+ return
835
+ if arg:
836
+ choices = model_choices(agent.ollama)
837
+ else:
838
+ choices = print_models(agent.ollama, hardware, s.model, numbered=True, out=c)
839
+ if not choices:
840
+ return
841
+ try:
842
+ answer = input(f" Switch to which model? (number or name, Enter to keep {s.model}): ").strip()
843
+ except EOFError:
844
+ return
845
+ if not answer:
846
+ c.print(f"Keeping {s.model}.")
847
+ return
848
+ arg = answer
849
+ if arg.isdigit():
850
+ if not 1 <= int(arg) <= len(choices):
851
+ c.print(f"[red]There's no model {arg} in the list: choose 1 to {len(choices)}.[/]")
852
+ return
853
+ arg = choices[int(arg) - 1]
854
+ switch_model(agent, arg, hardware)
855
+
856
+
857
+ def switch_model(agent: Agent, wanted: str, hardware: Hardware) -> None:
858
+ """Switch to a catalog key or an installed model, and size the context for it."""
859
+ from lcode.cli import NotInstalled, resolve_model
860
+
861
+ c, s = agent.console, agent.settings
862
+ try:
863
+ name, spec = resolve_model(agent.ollama, wanted)
864
+ except (NotInstalled, OllamaError) as e:
865
+ c.print(f"[red]{e}[/]")
866
+ return
867
+ if name == s.model:
868
+ c.print(f"Already using {name} (context {format_tokens(s.context)}).")
869
+ return
870
+ s.model = name
871
+ s.num_batch = spec.num_batch if spec and name == spec.local_name else None
872
+ if spec: # size the context for the new model: largest window that fits this machine
873
+ s.context = limits.cap(name, spec.fit(hardware)[0] or MIN_USEFUL_CONTEXT)
874
+ elif backends.is_openai(agent.ollama):
875
+ s.context = backends.context_for(agent.ollama, name, None)[0] # the server's window
876
+ agent._vision = False # the model that looks at images goes with the model: decide again
877
+ agent.messages[0]["content"] = agent.system_prompt()
878
+ c.print(f"[green]Switched to {name}[/] (context {format_tokens(s.context)}).")
879
+ warning = residency.dense_warning(agent.ollama, name, s.context, hardware)
880
+ if warning:
881
+ agent.say(warning, warning=True)
882
+
883
+
812
884
  def apply_context(agent: Agent, size: int, hardware: Hardware) -> None:
813
885
  c, s = agent.console, agent.settings
814
886
  if size == s.context:
@@ -956,8 +1028,6 @@ def session_start(agent: Agent) -> None:
956
1028
 
957
1029
  def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
958
1030
  """Run a slash command. Returns False when the REPL should exit."""
959
- from lcode.cli import NotInstalled, print_models, resolve_model
960
-
961
1031
  cmd, _, arg = line.partition(" ")
962
1032
  arg = arg.strip()
963
1033
  c, s = agent.console, agent.settings
@@ -1006,29 +1076,8 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
1006
1076
  agent.compact(arg)
1007
1077
  elif cmd in ("/context", "/ctx"):
1008
1078
  context_command(agent, arg, hardware)
1009
- elif cmd == "/models":
1010
- print_models(agent.ollama, hardware, s.model)
1011
- elif cmd == "/model":
1012
- if not arg:
1013
- c.print(f"Model: {s.model}. Switch with /model <name>; list with /models.")
1014
- else:
1015
- try:
1016
- name, spec = resolve_model(agent.ollama, arg)
1017
- except (NotInstalled, OllamaError) as e:
1018
- c.print(f"[red]{e}[/]")
1019
- return True
1020
- s.model = name
1021
- s.num_batch = spec.num_batch if spec and name == spec.local_name else None
1022
- if spec: # size the context for the new model: largest window that fits this machine
1023
- s.context = limits.cap(name, spec.fit(hardware)[0] or MIN_USEFUL_CONTEXT)
1024
- elif backends.is_openai(agent.ollama):
1025
- s.context = backends.context_for(agent.ollama, name, None)[0] # the server's window
1026
- agent._vision = False # the model that looks at images goes with the model: decide again
1027
- agent.messages[0]["content"] = agent.system_prompt()
1028
- c.print(f"[green]Switched to {name}[/] (context {format_tokens(s.context)}).")
1029
- warning = residency.dense_warning(agent.ollama, name, s.context, hardware)
1030
- if warning:
1031
- agent.say(warning, warning=True)
1079
+ elif cmd in ("/models", "/model"):
1080
+ model_command(agent, arg, hardware)
1032
1081
  elif cmd == "/think":
1033
1082
  s.think = (arg == "on") if arg in ("on", "off") else not s.think
1034
1083
  c.print(f"Reasoning {'on' if s.think else 'off'}.")
@@ -1059,7 +1108,7 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
1059
1108
  p = Path(os.path.expanduser(arg or "~"))
1060
1109
  p = (p if p.is_absolute() else agent.cwd / p).resolve()
1061
1110
  if p.is_dir():
1062
- agent.cwd = p
1111
+ agent.cwd = agent.workspace = p
1063
1112
  agent.messages[0]["content"] = agent.system_prompt()
1064
1113
  c.print(f"Working directory: {p}")
1065
1114
  else:
lcode/subagents.py CHANGED
@@ -461,6 +461,8 @@ class Run:
461
461
  show_thinking=False,
462
462
  )
463
463
  child = Agent(parent.ollama, settings, cwd, console=Console(file=io.StringIO(), width=120))
464
+ if not self.worktree:
465
+ child.workspace = parent.workspace # it may cd around the same project, not out of it
464
466
  child.perms = self.relay # type: ignore[assignment]
465
467
  child.sandbox = parent.sandbox
466
468
  child.jobs = parent.jobs # its background commands belong to the session
@@ -487,11 +489,10 @@ class Run:
487
489
  last = child.messages[-1]
488
490
  if last.get("role") != "assistant" or last.get("tool_calls") or not last.get("content", "").strip():
489
491
  # It ran out of steps, or went round in circles: ask for what it has so far, without tools.
490
- why = (
491
- "You kept making the same call, so lcode stopped you."
492
- if child.turn_status == "loop"
493
- else "You've used all your steps."
494
- )
492
+ why = {
493
+ "loop": "You went round in circles, so lcode stopped you.",
494
+ "blocked": "A site you needed can't be reached from this machine, so lcode stopped you.",
495
+ }.get(child.turn_status, "You've used all your steps.")
495
496
  child.messages.append(
496
497
  {
497
498
  "role": "user",
lcode/tools.py CHANGED
@@ -1283,10 +1283,18 @@ class Toolbox:
1283
1283
  out.add(last)
1284
1284
  recent.append(last)
1285
1285
  if recorded and Path(recorded).is_dir() and Path(recorded).resolve() != self.agent.cwd:
1286
- if sandbox and not sandbox.contains(Path(recorded).resolve()):
1286
+ from lcode.checkpoints import work_tree_for
1287
+
1288
+ moved = Path(recorded).resolve()
1289
+ if sandbox and not sandbox.contains(moved):
1287
1290
  status += "\n[That directory is outside the project; the working directory stays the same]"
1291
+ elif not sandbox and not moved.is_relative_to(work_tree_for(self.agent.workspace)):
1292
+ status += (
1293
+ f"\n[{moved} is outside the project, so the working directory stays {self.agent.cwd}: the next "
1294
+ "command starts there. To run something elsewhere, cd in the same command]"
1295
+ )
1288
1296
  else:
1289
- self.agent.cwd = Path(recorded).resolve()
1297
+ self.agent.cwd = moved
1290
1298
  status += f"\n[Working directory is now {self.agent.cwd}]"
1291
1299
  if sandbox and code != 0 and not sandbox.network and NO_NETWORK.search("".join(recent)):
1292
1300
  status += (
lcode/unreachable.py ADDED
@@ -0,0 +1,186 @@
1
+ """Notice when the network, not the model, is in the way.
2
+
3
+ A download that fails however its address is written usually means this machine can't reach the site: a
4
+ firewall, a proxy, a regional block, or no internet. Nothing the model writes fixes that, yet a small model
5
+ will try address after address for an hour (and lcode's repeat check never fires: every address differs).
6
+ So within one request lcode keeps track, per site, of the calls that failed to reach it (an HTTP error, a
7
+ name that doesn't resolve, a refused or timed-out connection…) and of the ones that worked. When `NOTE_AT`
8
+ different calls failed for a site and none worked, the result gets a note: check one address that must
9
+ exist, and if that fails too, tell the user. If `STOP_AFTER_NOTE` more fail, lcode ends the request.
10
+
11
+ Which site a failure belongs to is read from the output: the address on the failing line, else the last
12
+ address printed before it, else the command's address when the command names only one site. A call that
13
+ names several sites and prints a bare status line counts for none of them. A site counts as reached when a
14
+ call got an answer from it: a success status, a page from web_fetch, or a call naming only that site that
15
+ printed plenty without an error. Local addresses (localhost, private networks, names without a dot) aren't
16
+ tracked: they aren't "the internet".
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import ipaddress
23
+ import json
24
+ import re
25
+ from dataclasses import dataclass
26
+ from urllib.parse import urlsplit
27
+
28
+ NOTE_AT = 3 # different calls that failed for a site, and none worked: the note
29
+ STOP_AFTER_NOTE = 2 # more such calls after the note: the request ends
30
+ PLENTY = 400 # characters of error-free output from a call naming one site: it was reached
31
+ URL = re.compile(r"\b(?:https?|ftp)://[^\s'\"<>`|]+", re.I)
32
+ OK_STATUS = re.compile(
33
+ r"\bHTTP/\d(?:\.\d)?\s+[23]\d\d\b|awaiting response\.\.\. [23]\d\d\b"
34
+ r"|\b(?:status(?:[ _]code)?|http_code)[:= ]+[23]\d\d\b",
35
+ re.I,
36
+ )
37
+ FAILED_STATUS = re.compile(
38
+ r"\bHTTP/\d(?:\.\d)?\s+([45]\d\d)\b" # curl -I / -v, wget -S
39
+ r"|\bHTTP Error ([45]\d\d)\b" # Python's urllib
40
+ r"|\breturned HTTP ([45]\d\d)\b" # lcode's web_fetch
41
+ r"|\breturned error: ([45]\d\d)\b" # curl --fail
42
+ r"|\bERROR ([45]\d\d):" # wget
43
+ r"|\b([45]\d\d) (?:Client|Server) Error\b" # requests
44
+ r"|\b(?:status(?:[ _]code)?|http_code)[:= ]+([45]\d\d)\b" # what a model's own loop prints
45
+ r"|\b([45]\d\d):? \(?(?:Not Found|Forbidden|Unauthorized|Bad Gateway|Service Unavailable|Gateway Time-?out|"
46
+ r"Too Many Requests)\b", # npm, wget, error pages
47
+ re.I,
48
+ )
49
+ NETWORK_ERRORS = (
50
+ (
51
+ "the name doesn't resolve",
52
+ re.compile(
53
+ r"Could not resolve host|Temporary failure in name resolution|Name or service not known|"
54
+ r"nodename nor servname|getaddrinfo|ENOTFOUND|EAI_AGAIN|NameResolutionError|Failed to resolve",
55
+ re.I,
56
+ ),
57
+ ),
58
+ ("connection refused", re.compile(r"Connection refused|ECONNREFUSED", re.I)),
59
+ ("timed out", re.compile(r"Connection timed out|ETIMEDOUT|Operation timed out|Read timed out", re.I)),
60
+ ("connection reset", re.compile(r"Connection reset|ECONNRESET", re.I)),
61
+ ("no route to it", re.compile(r"Network is unreachable|No route to host|ENETUNREACH", re.I)),
62
+ ("a TLS/SSL error", re.compile(r"SSL certificate problem|certificate verify failed|SSLError", re.I)),
63
+ ("couldn't connect", re.compile(r"Failed to connect to|Max retries exceeded", re.I)),
64
+ )
65
+ LOCAL_SUFFIXES = (".local", ".lan", ".home", ".internal", ".localhost", ".test", ".example", ".invalid")
66
+
67
+
68
+ @dataclass
69
+ class Verdict:
70
+ site: str = ""
71
+ calls: int = 0 # different calls that failed for the site
72
+ what: str = "" # how they failed, e.g. "HTTP 404"
73
+ note: str = "" # to add to the tool result
74
+ stop: bool = False # end the request
75
+
76
+
77
+ class Sites:
78
+ def __init__(self, note_at: int = NOTE_AT) -> None:
79
+ self.note_at = note_at # 0: off
80
+ self.failed: dict[str, dict[str, str]] = {} # site -> {call key: how it failed}
81
+ self.reached: set[str] = set()
82
+
83
+ def reset(self) -> None:
84
+ """A new request."""
85
+ self.failed.clear()
86
+ self.reached.clear()
87
+
88
+ def check(self, name: str, args: dict, result: str) -> Verdict:
89
+ """Count what this call reached and failed to reach; a note when a site looks out of reach."""
90
+ if self.note_at <= 0:
91
+ return Verdict()
92
+ failed, reached = outcomes(name, args, result)
93
+ self.reached |= reached
94
+ key = hashlib.sha256(json.dumps([name, args], sort_keys=True, default=str).encode()).hexdigest()
95
+ verdict = Verdict()
96
+ for site, how in failed.items():
97
+ if site in self.reached:
98
+ continue
99
+ calls = self.failed.setdefault(site, {})
100
+ calls[key] = how
101
+ n = len(calls)
102
+ if n < self.note_at or n < verdict.calls:
103
+ continue
104
+ what = ", ".join(sorted(set(calls.values())))
105
+ if n >= self.note_at + STOP_AFTER_NOTE:
106
+ note = (
107
+ f"\n\n[lcode] {n} different calls to {site} failed ({what}) and none worked, so lcode stopped "
108
+ "the request here: this machine can't reach that site."
109
+ )
110
+ verdict = Verdict(site, n, what, note, stop=True)
111
+ elif not verdict.stop:
112
+ verdict = Verdict(site, n, what, advice(site, n, what))
113
+ return verdict
114
+
115
+
116
+ def advice(site: str, n: int, what: str) -> str:
117
+ return (
118
+ f"\n\n[lcode] Every request to {site} in this request has failed ({n} different calls: {what}) and none "
119
+ "worked. When a site fails however the address is written, the usual cause is the network, not the "
120
+ "address: a firewall, a proxy, a regional block, or no internet on this machine. Check that once: request "
121
+ f"an address on {site} that must exist (one the site's own pages link to). If that fails too, stop trying "
122
+ "addresses and tell the user what fails and what they could do: use another network or a VPN, set a "
123
+ f"proxy (HTTPS_PROXY), use a mirror, or run this step on a machine that can reach {site}, such as CI."
124
+ )
125
+
126
+
127
+ def site_of(url: str) -> str:
128
+ """The host to track for a URL, or "" for local and private addresses (and anything unparseable)."""
129
+ try:
130
+ host = (urlsplit(url).hostname or "").lower().rstrip(".")
131
+ except ValueError:
132
+ return ""
133
+ if not host or "$" in host or "{" in host or host == "localhost" or host.endswith(LOCAL_SUFFIXES):
134
+ return ""
135
+ try:
136
+ ip = ipaddress.ip_address(host.strip("[]"))
137
+ except ValueError:
138
+ return host if "." in host else ""
139
+ return "" if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_unspecified else host
140
+
141
+
142
+ def failure(line: str) -> str:
143
+ """How a line of output says a request failed ("HTTP 404", "the name doesn't resolve"…), or ""."""
144
+ m = FAILED_STATUS.search(line)
145
+ if m:
146
+ return f"HTTP {next(g for g in m.groups() if g)}"
147
+ for what, pattern in NETWORK_ERRORS:
148
+ if pattern.search(line):
149
+ return what
150
+ return ""
151
+
152
+
153
+ def outcomes(name: str, args: dict, result: str) -> tuple[dict[str, str], set[str]]:
154
+ """The sites a call failed to reach (with how) and the ones it reached."""
155
+ if name == "web_fetch":
156
+ site = site_of(str(args.get("url", "")))
157
+ if not site:
158
+ return {}, set()
159
+ if result.startswith("Error:"):
160
+ how = failure(result)
161
+ return ({site: how} if how else {}), set()
162
+ return {}, {site}
163
+ if name != "bash":
164
+ return {}, set()
165
+ named = {site_of(url) for url in URL.findall(str(args.get("command", "")))}
166
+ current = next(iter(named)) if len(named) == 1 else ""
167
+ failed: dict[str, str] = {}
168
+ reached: set[str] = set()
169
+ for line in result.splitlines():
170
+ if line.startswith("[lcode") or line.startswith("[exit code"):
171
+ continue
172
+ urls = URL.findall(line)
173
+ if urls:
174
+ current = site_of(urls[-1])
175
+ if not current:
176
+ continue
177
+ how = failure(line)
178
+ if how:
179
+ failed.setdefault(current, how)
180
+ elif OK_STATUS.search(line):
181
+ reached.add(current)
182
+ if len(named) == 1 and (site := next(iter(named))) and not failed and site not in reached:
183
+ printed = "".join(line for line in result.splitlines() if not line.startswith(("[lcode", "[exit code")))
184
+ if result.rstrip().endswith("[exit code: 0]") and len(printed) >= PLENTY:
185
+ reached.add(site)
186
+ return failed, reached
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.35.0
3
+ Version: 0.36.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/
@@ -1,17 +1,17 @@
1
- lcode/__init__.py,sha256=gk-TBYXXl_EmM4X0Si03ubfRw1OFZ-QjmkNP_Rj4jfc,473
1
+ lcode/__init__.py,sha256=yGzk6CSXaFMumjYybvLVXP6ECcm-RN4YJ77pJlK1FWw,473
2
2
  lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
3
  lcode/acp.py,sha256=d97VV3sbqXdWKhPQQNbYRaDTm8WrUk0mGbDLiOyX7jE,29058
4
4
  lcode/action.py,sha256=wuT5F9EJCdEligPeytzmEi8FpahWBhXer528tpuTj_k,16711
5
- lcode/agent.py,sha256=hLnVWTzXjqgXMn5JgkR-VXPZj4GDri1lIU7xYB5YBBs,78221
6
- lcode/api.py,sha256=FahtmNRjdgrVbImGFZhnKjMyeHHXGAMHv6-7NR9vm9I,16153
7
- lcode/backends.py,sha256=Tq8B1XzZflb56N-j7qtzgIws4lodWXIyWJkIqB1wSO4,20916
8
- lcode/bench.py,sha256=cBchBoDIqq5iA8Otevbf9BKF6mi6BzTEmmdxdG6MpCI,43078
5
+ lcode/agent.py,sha256=m_q7Q-uKWRUru6rUXXEhmUvJeDQQR1EMADigw06MGq4,84599
6
+ lcode/api.py,sha256=mIfmiN3jDv8jg-HOJtqfr-MQ3cOI8nfFmlYYRZR1r1Y,16164
7
+ lcode/backends.py,sha256=343alwyDZin-bEhb5-Es4f8ek1oBQUzEvV6RFWR1VZo,21160
8
+ lcode/bench.py,sha256=HxeUfy9Kv_0Rug1HAlDLyHYfYd9dw2bMgbXsvUy9nd4,43096
9
9
  lcode/bench_backend.py,sha256=X2fo-zRX92Xp3KMxSsFlWioc2vRupBTtowj5kLME8mw,14287
10
10
  lcode/bench_runtime.py,sha256=EjzNgIvUkdbT8cbSy1TpmlyeIk6e-ipjlDFpM7Dnqx0,6835
11
11
  lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
12
12
  lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
13
- lcode/checks.py,sha256=FOD8DOxPWXY1RMtrL9ONJsOxsV10BLgzPvzIJMK5NrU,6582
14
- lcode/cli.py,sha256=nPvGnL0gZiUkbTniCoTAAyItiPEXZ-f6rHLYYUOKSiU,42151
13
+ lcode/checks.py,sha256=iPaNaGOGo7zHc_mue41ELEHSNq0VIH_xVYL_bHkYLXU,9373
14
+ lcode/cli.py,sha256=vtrGb1A_Jnxulu-L0nac01ARdQ8dyxZI4fapb-0G2N4,44703
15
15
  lcode/clipboard.py,sha256=nTSdJyCXPdtjH947RjvB3QvRqnL5HZsS9KB8EkC42cc,5138
16
16
  lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
17
17
  lcode/config.py,sha256=hAjuHg7plRxwFgdD4gOGnDH8-FwyT7NRV0YI5grd0qE,12604
@@ -33,8 +33,8 @@ lcode/ollama.py,sha256=u5tnvL28cfaFS4GhmiEsHhTQe6w3Ng_Tej05UUzLQTQ,7715
33
33
  lcode/permissions.py,sha256=q7CjDsc_Xrblw7t3_BbxA5LUjxFSswGs1RiJmtMi0po,12288
34
34
  lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
35
35
  lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
36
- lcode/repeats.py,sha256=-vcbj363nFcIyso7yFJgsbHz9C6VOu2EOg3Svs4eFyE,4091
37
- lcode/repl.py,sha256=zEXyfmIW-V8NqvfXmoqqeiMiM8Il4jb8bUFAA1SnF94,54628
36
+ lcode/repeats.py,sha256=vpXR5pzKX9GsTzQONGS30V-rEU3gunaj_hBzJxg43Q4,5542
37
+ lcode/repl.py,sha256=2i_EDAsXxQ9OxXyWiuPGtIY4pLO3lQW4_SulITcThkM,56548
38
38
  lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
39
39
  lcode/residency.py,sha256=bDB2J0SZULDOz6wYuMCnkjacYk7pWi5f75iY83ycAXQ,6912
40
40
  lcode/review.py,sha256=0QBIS-R1B2b3zqx5TcC1qZptpKBbrDhYvMKSNCaDAUo,4686
@@ -43,8 +43,9 @@ lcode/screenshots.py,sha256=bcWSXS4xTsBk8HhpjBy5jvu705uH9qV8bXANr--Mf2M,46405
43
43
  lcode/secretscan.py,sha256=6Euvfk2gCtK7lZbuQD84zgWuSECi6OnSw8V5rFu1tfU,13575
44
44
  lcode/sessions.py,sha256=ojkpP453JyIll8_iFeJgW4BG4Zz2FKMRsLiij6spiTo,4185
45
45
  lcode/steer.py,sha256=0vaqLxgRKgC0yGp8xoQfV1f9VEOzH3U4rCIb74eFnZ8,12127
46
- lcode/subagents.py,sha256=GGn2E7QUwEsShJEHXaMhG53leyGFUZwJku_n33gJguU,29157
47
- lcode/tools.py,sha256=2aZtQ1dot9p6Y6pcympP8nyiCdlba8gMbCqREwPMtPI,67586
46
+ lcode/subagents.py,sha256=thR2ZzFZ1DeoAf0Ctr_oeKjYajhvd3uRs2kGdw6LlJM,29349
47
+ lcode/tools.py,sha256=glp18sbnxaK5iugDxrCFlDaT0SEbO-GTOL_-O3Afp44,68005
48
+ lcode/unreachable.py,sha256=TXrv41ljwX_amxdcf4CY6qGF2YkWetORvPUSvwceJXI,8685
48
49
  lcode/update.py,sha256=i92bnW252VneYh0vD6djxmXsDOa5MXP20bjlLZUue_c,25236
49
50
  lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
50
51
  lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
@@ -64,8 +65,8 @@ lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
64
65
  lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
65
66
  lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
66
67
  lcode/servers/xai.py,sha256=h2emAvHSnkuB9WmJInlynsDhvxJwNVD2AHFX7vqMXNc,13627
67
- lcode_cli-0.35.0.dist-info/METADATA,sha256=FanOqu5FsF_g3lvnsVZ9ciVjq11BOHYFeBYTFkqm4G4,13141
68
- lcode_cli-0.35.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
69
- lcode_cli-0.35.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
70
- lcode_cli-0.35.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
71
- lcode_cli-0.35.0.dist-info/RECORD,,
68
+ lcode_cli-0.36.0.dist-info/METADATA,sha256=kp6gDSBL_DtXIWYn7FRO6CpzOf8t-dQPMOwpCQ_4trU,13141
69
+ lcode_cli-0.36.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
70
+ lcode_cli-0.36.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
71
+ lcode_cli-0.36.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
72
+ lcode_cli-0.36.0.dist-info/RECORD,,