nanocode-cli 0.6.6__tar.gz → 0.7.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nanocode-cli
3
- Version: 0.6.6
3
+ Version: 0.7.0
4
4
  Summary: A small terminal coding agent written in Python
5
5
  Author-email: hit9 <hit9@icloud.com>
6
6
  License-Expression: BSD-3-Clause
@@ -22,13 +22,14 @@ Requires-Python: >=3.11
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
24
  Requires-Dist: anthropic>=0.64.0
25
- Requires-Dist: code-symbol-index>=0.3.0
25
+ Requires-Dist: code-symbol-index>=0.3.1
26
26
  Requires-Dist: json-repair
27
- Requires-Dist: fastmcp<4,>=3
27
+ Requires-Dist: fastmcp-slim[client]<4,>=3
28
28
  Requires-Dist: openai>=2.37.0
29
29
  Requires-Dist: prompt-toolkit>=3.0
30
30
  Requires-Dist: rich>=13.0
31
31
  Requires-Dist: socksio>=1.0.0
32
+ Requires-Dist: websockets>=15.0.1
32
33
  Provides-Extra: dev
33
34
  Requires-Dist: pytest>=8.0; extra == "dev"
34
35
  Requires-Dist: ruff>=0.4.0; extra == "dev"
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import codecs
6
7
  import difflib
7
8
  import asyncio
8
9
  import fnmatch
@@ -59,7 +60,7 @@ from rich.console import Console
59
60
  from rich.markdown import Markdown
60
61
  from rich.rule import Rule
61
62
 
62
- __version__ = "0.6.6"
63
+ __version__ = "0.7.0"
63
64
 
64
65
  Json = dict[str, Any]
65
66
  HTTP_USER_AGENT = "nanocode/" + __version__
@@ -236,6 +237,8 @@ class RuntimeSettings:
236
237
  check_updates: bool = True
237
238
  update_check_interval_hours: int = 24
238
239
  session_retention_days: int = 7
240
+ # Max read-only tool calls from one model batch to execute concurrently; 1 disables parallelism.
241
+ max_parallel_tools: int = 4
239
242
  mcp_selector: str = ""
240
243
  yolo: bool = False
241
244
  debug: bool = False
@@ -247,6 +250,7 @@ class RuntimeSettings:
247
250
  shell_timeout=Config.int(runtime, "shell_timeout", 60),
248
251
  max_steps=max(1, Config.int(runtime, "max_agent_steps", Config.int(runtime, "max_steps", 200))),
249
252
  max_context_tokens=max(1, Config.int(runtime, "max_context_tokens", DEFAULT_MAX_CONTEXT_TOKENS)),
253
+ max_parallel_tools=max(1, Config.int(runtime, "max_parallel_tools", 4)),
250
254
  check_updates=Config.bool(runtime, "check_updates", True),
251
255
  update_check_interval_hours=max(1, Config.int(runtime, "update_check_interval_hours", 24)),
252
256
  session_retention_days=max(0, Config.int(runtime, "session_retention_days", 7)),
@@ -357,6 +361,7 @@ data_dir = "~/.nanocode"
357
361
  shell_timeout = 60
358
362
  max_agent_steps = 200
359
363
  max_context_tokens = 128000
364
+ max_parallel_tools = 4
360
365
  check_updates = true
361
366
  update_check_interval_hours = 24
362
367
  session_retention_days = 7
@@ -441,10 +446,43 @@ class ModelUsage:
441
446
  self.last_cached_prompt_tokens = cached_tokens
442
447
 
443
448
 
449
+ @dataclass
450
+ class PlanItem:
451
+ STATUSES: ClassVar[tuple[str, ...]] = ("todo", "doing", "done", "blocked")
452
+ SYMBOLS: ClassVar[dict[str, str]] = {"todo": " ", "doing": "~", "done": "x", "blocked": "-"}
453
+ LEGACY_MARKERS: ClassVar[dict[str, str]] = {" ": "todo", "~": "doing", "x": "done", "X": "done", "-": "blocked"}
454
+
455
+ status: str
456
+ text: str
457
+
458
+ @classmethod
459
+ def parse(cls, value: Any) -> "PlanItem | None":
460
+ if isinstance(value, cls):
461
+ status, text = value.status, value.text
462
+ elif isinstance(value, dict):
463
+ status = str(value.get("status") or "todo").strip().lower()
464
+ text = str(value.get("text") or "").strip()
465
+ else:
466
+ raw = str(value).strip()
467
+ match = re.fullmatch(r"\[( |x|X|~|-)\]\s+(.+)", raw)
468
+ status = cls.LEGACY_MARKERS[match.group(1)] if match else "todo"
469
+ text = match.group(2).strip() if match else raw
470
+ if not text:
471
+ return None
472
+ return cls(status if status in cls.STATUSES else "todo", text)
473
+
474
+ def to_json(self) -> Json:
475
+ return {"status": self.status, "text": self.text}
476
+
477
+ def row(self, *, status: bool = False, style: str = "text") -> str:
478
+ prefix = f"[{self.SYMBOLS[self.status]}] " if status and style == "symbol" else f"{self.status}: " if status else ""
479
+ return "- " + prefix + self.text
480
+
481
+
444
482
  @dataclass
445
483
  class AgentState:
446
484
  goal: str = ""
447
- plan: list[str] = field(default_factory=list)
485
+ plan: list[PlanItem | Json | str] = field(default_factory=list)
448
486
  known: list[str] = field(default_factory=list)
449
487
  check: str = ""
450
488
  summary: str = ""
@@ -460,23 +498,23 @@ class AgentState:
460
498
  manual_model_retry_requested: bool = False
461
499
  model_retry_count: int = 0
462
500
 
463
- @staticmethod
464
- def plan_text(item: str) -> str:
465
- return re.sub(r"^\[(?: |x|X|~|-)\]\s+", "", item.strip()).strip()
501
+ def __post_init__(self) -> None:
502
+ self.plan = self.plan_items(self.plan)
466
503
 
467
504
  @classmethod
468
- def plan_rows_for(cls, items: list[str], *, status: bool = False) -> list[str]:
469
- rows = [
470
- f"- [{'~' if index == 0 else ' '}] {item}" if status else "- " + item
471
- for index, item in enumerate(filter(None, (cls.plan_text(str(item)) for item in items)))
472
- ]
473
- return rows or ["- (empty)"]
505
+ def plan_items(cls, items: list[Any]) -> list[PlanItem]:
506
+ return [item for raw in items if (item := PlanItem.parse(raw))]
474
507
 
475
- def plan_rows(self, *, status: bool = False) -> list[str]:
476
- return self.plan_rows_for(self.plan, status=status)
508
+ @classmethod
509
+ def plan_rows_for(cls, items: list[Any], *, status: bool = False, style: str = "text") -> list[str]:
510
+ rows = [item.row(status=status, style=style) for item in cls.plan_items(items)]
511
+ return rows or ["- (empty)"]
477
512
 
478
- def current_focus(self) -> str:
479
- return next((text for item in self.plan if (text := self.plan_text(item))), "")
513
+ @classmethod
514
+ def focus_text(cls, items: list[Any]) -> str:
515
+ plan = cls.plan_items(items)
516
+ current = next((item for item in plan if item.status == "doing"), None) or next((item for item in plan if item.status != "done"), None)
517
+ return current.text if current else ""
480
518
 
481
519
  def apply(self, data: Json) -> None:
482
520
  for attr in ("goal", "summary", "check"):
@@ -485,12 +523,12 @@ class AgentState:
485
523
  for attr in ("plan", "known"):
486
524
  value = data.get(attr)
487
525
  if isinstance(value, list):
488
- items = list(filter(None, (str(item).strip() for item in value)))
489
- setattr(self, attr, [self.plan_text(item) for item in items] if attr == "plan" else items)
526
+ items = list(filter(None, (str(item).strip() for item in value))) if attr == "known" else self.plan_items(value)
527
+ setattr(self, attr, items)
490
528
 
491
529
  def format(self) -> str:
492
530
  known = ["- " + item for item in self.known] or ["- (empty)"]
493
- return "\n".join(["Goal: " + (self.goal or "(empty)"), "Plan:", *self.plan_rows(), "Known:", *known, "Check: " + (self.check or "(empty)")])
531
+ return "\n".join(["Goal: " + (self.goal or "(empty)"), "Plan:", *self.plan_rows_for(self.plan), "Known:", *known, "Check: " + (self.check or "(empty)")])
494
532
 
495
533
 
496
534
  @dataclass
@@ -769,7 +807,7 @@ class SessionSnapshotCodec:
769
807
  def state(state: AgentState) -> Json:
770
808
  return {
771
809
  "goal": state.goal,
772
- "plan": state.plan,
810
+ "plan": [item.to_json() for item in AgentState.plan_items(state.plan)],
773
811
  "known": state.known,
774
812
  "check": state.check,
775
813
  "summary": state.summary,
@@ -1128,10 +1166,6 @@ class Session:
1128
1166
  def clean_expired_snapshots(self) -> int:
1129
1167
  return SessionSnapshotStore.clean_expired(self)
1130
1168
 
1131
- @classmethod
1132
- def _resolve_uid(cls, uid: str, data_dir: str = "~/.nanocode") -> str:
1133
- return SessionSnapshotStore.resolve_uid(uid, data_dir)
1134
-
1135
1169
  @classmethod
1136
1170
  def load_snapshot(cls, uid: str, config: Config | None = None, settings: RuntimeSettings | None = None) -> "Session":
1137
1171
  return SessionSnapshotStore.load(uid, config=config, settings=settings)
@@ -1334,7 +1368,7 @@ class Tool:
1334
1368
 
1335
1369
  class ReadTool(Tool):
1336
1370
  NAME = "Read"
1337
- DESCRIPTION = "Read UTF-8 file line ranges; returns file stat, total lines, line:hash text, and updates FILE STATE."
1371
+ DESCRIPTION = "Read UTF-8 file line ranges; returns file stat, total lines, anchor=line:hash(line_content) text, and updates FILE STATE."
1338
1372
  SIGNATURE = "Read(path,ranges=[[start,end],...]) or Read(files=[{path,ranges}]); lines are 0-based, end-exclusive"
1339
1373
  EXAMPLE = (
1340
1374
  'Read ranges. Example: {"path":"src/app.py","ranges":[[0,80],[120,180]]}',
@@ -1378,6 +1412,30 @@ class ReadTool(Tool):
1378
1412
  def line_hash(line: str) -> str:
1379
1413
  return Text.base36(int(hashlib.sha1(line.encode("utf-8")).hexdigest()[:6], 16)).rjust(5, "0")
1380
1414
 
1415
+ @classmethod
1416
+ def anchor(cls, index: int, line: str) -> str:
1417
+ return f"{index}:{cls.line_hash(line)}"
1418
+
1419
+ @classmethod
1420
+ def anchor_line(cls, index: int, line: str) -> str:
1421
+ return f"anchor={cls.anchor(index, line)} | {line.rstrip(chr(10))}"
1422
+
1423
+ @staticmethod
1424
+ def indexed_line_hash(line: str) -> str:
1425
+ return hashlib.sha256(line.rstrip("\n").encode("utf-8")).hexdigest()[:8]
1426
+
1427
+ @staticmethod
1428
+ def parse_anchor(value: str) -> tuple[int, str] | None:
1429
+ text = value.split("|", 1)[0].strip()
1430
+ if text.startswith("anchor="):
1431
+ text = text.removeprefix("anchor=").strip()
1432
+ match = re.fullmatch(r"(\d+):([0-9a-z]{5}|[0-9a-f]{8})", text)
1433
+ return (int(match.group(1)), match.group(2).lower()) if match else None
1434
+
1435
+ @classmethod
1436
+ def anchor_matches(cls, line: str, expected: str) -> bool:
1437
+ return expected == cls.line_hash(line) or expected == cls.indexed_line_hash(line)
1438
+
1381
1439
  def needs_confirmation(self) -> bool:
1382
1440
  return any(not self.session.in_cwd(path) for path, _ranges in self.targets())
1383
1441
 
@@ -1415,7 +1473,7 @@ class ReadTool(Tool):
1415
1473
  end = max(start, len(lines) if requested_end == 0 else min(len(lines), requested_end))
1416
1474
  out.append(f"<range>{start}:{end}</range>")
1417
1475
  out.append("<content hashline-numbered>")
1418
- out.append("".join(f"{i}:{self.line_hash(lines[i])}|{lines[i]}" for i in range(start, end)).rstrip("\n"))
1476
+ out.extend(self.anchor_line(i, lines[i]) for i in range(start, end))
1419
1477
  out.append("</content>")
1420
1478
  out.append("</Read>")
1421
1479
  return "\n".join(out)
@@ -1644,7 +1702,7 @@ class FindTool(Tool):
1644
1702
 
1645
1703
  class SearchTool(Tool):
1646
1704
  NAME = "Search"
1647
- DESCRIPTION = "Search UTF-8 text files with case-insensitive regex; skips binary/hidden/gitignored files and returns path:line:hash matches."
1705
+ DESCRIPTION = "Search UTF-8 text files with case-insensitive regex; skips binary/hidden/gitignored files and returns path anchor=line:hash matches."
1648
1706
  SIGNATURE = "Search(pattern,path?,glob?,context?) or Search(queries=[...]); pattern is regex, A|B|C is ok"
1649
1707
  EXAMPLE = (
1650
1708
  'Search source with context. Example: {"pattern":"class .*Tool","path":"src","glob":"*.py","context":2}',
@@ -1827,7 +1885,7 @@ class SearchTool(Tool):
1827
1885
  return rows
1828
1886
 
1829
1887
  def match_line(self, prefix: str, path: str, line_index: int, line: str) -> str:
1830
- return f"{prefix} {self.session.relpath(path)}:{line_index}:{ReadTool.line_hash(line)}|{line.rstrip()}"
1888
+ return f"{prefix} {self.session.relpath(path)} {ReadTool.anchor_line(line_index, line)}"
1831
1889
 
1832
1890
 
1833
1891
  class CodeIndex:
@@ -2079,7 +2137,7 @@ class InspectCodeTool(Tool):
2079
2137
  if mode == "find":
2080
2138
  return csi.search(target, limit=limit or csi.DEFAULT_SEARCH_LIMIT, **common)
2081
2139
  if mode == "inspect":
2082
- return csi.inspect(target, limit=limit or csi.DEFAULT_PAGE_LIMIT, anchors=True, **common)
2140
+ return csi.inspect(target, limit=limit or csi.DEFAULT_PAGE_LIMIT, anchors=True, anchor_format="explicit", **common)
2083
2141
  if mode == "refs":
2084
2142
  ref_kinds = options.get("ref_kind") or ("all" if options.get("all_kinds") else "behavioral")
2085
2143
  return csi.refs(target, limit=limit or csi.DEFAULT_MAX_REFERENCES, offset=int(options.get("offset") or 0), ref_kinds=ref_kinds, **common)
@@ -2319,7 +2377,7 @@ class EditTool(Tool):
2319
2377
  out.append("...")
2320
2378
  break
2321
2379
  line = lines[index]
2322
- out.append(f"{index}:{ReadTool.line_hash(line)}|{line.rstrip(chr(10))}")
2380
+ out.append(ReadTool.anchor_line(index, line))
2323
2381
  shown_lines += 1
2324
2382
  out.append("</target>")
2325
2383
  if shown_lines >= 12:
@@ -2337,7 +2395,7 @@ class EditTool(Tool):
2337
2395
  shown = lines[start:end]
2338
2396
  if shown:
2339
2397
  out.append("<content hashline-numbered>")
2340
- out.extend(f"{start + index}:{ReadTool.line_hash(line)}|{line.rstrip(chr(10))}" for index, line in enumerate(shown))
2398
+ out.extend(ReadTool.anchor_line(start + index, line) for index, line in enumerate(shown))
2341
2399
  out.append("</content>")
2342
2400
  return "\n".join(out)
2343
2401
 
@@ -2360,16 +2418,14 @@ class EditTool(Tool):
2360
2418
  return value.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\t", "\t") if "\n" not in value and "\\n" in value else value
2361
2419
 
2362
2420
  def resolve_anchor(self, lines: list[str], anchor: str) -> int:
2363
- match = re.fullmatch(r"(\d+):([0-9a-z]{5})", anchor.split("|", 1)[0].strip())
2364
- if not match:
2365
- raise ToolError('invalid anchor; use "line:hash" from Read or Search')
2366
- index = int(match.group(1))
2421
+ parsed = ReadTool.parse_anchor(anchor)
2422
+ if parsed is None:
2423
+ raise ToolError('invalid anchor; use the "anchor=line:hash" value from Read, Search, or InspectCode')
2424
+ index, expected = parsed
2367
2425
  if index >= len(lines):
2368
2426
  raise ToolError("anchor line out of range")
2369
- expected = match.group(2).lower()
2370
- actual = ReadTool.line_hash(lines[index])
2371
- if actual != expected:
2372
- current = f"{index}:{actual}|{lines[index].rstrip()}"
2427
+ if not ReadTool.anchor_matches(lines[index], expected):
2428
+ current = ReadTool.anchor_line(index, lines[index])
2373
2429
  raise ToolError(f"stale anchor {anchor}; current is {current}")
2374
2430
  return index
2375
2431
 
@@ -2427,6 +2483,9 @@ class BashTool(Tool):
2427
2483
  def stream_process(self, proc: subprocess.Popen[bytes]) -> str:
2428
2484
  stdout_parts: list[str] = []
2429
2485
  stderr_parts: list[str] = []
2486
+ # Per-stream incremental decoders so a multibyte UTF-8 character split across two 4096-byte
2487
+ # reads is decoded once it is complete, instead of being mangled into replacement chars.
2488
+ self._decoders = {"stdout": codecs.getincrementaldecoder("utf-8")("replace"), "stderr": codecs.getincrementaldecoder("utf-8")("replace")}
2430
2489
  selector = selectors.DefaultSelector()
2431
2490
  selector.register(proc.stdout, selectors.EVENT_READ, "stdout")
2432
2491
  selector.register(proc.stderr, selectors.EVENT_READ, "stderr")
@@ -2469,6 +2528,13 @@ class BashTool(Tool):
2469
2528
  data = os.read(key.fileobj.fileno(), 4096)
2470
2529
  except OSError:
2471
2530
  data = b""
2531
+ # final=True on EOF flushes any bytes still buffered in the decoder (e.g. a truncated
2532
+ # trailing character) so they are not silently dropped.
2533
+ text = self._decoders[key.data].decode(data, final=not data)
2534
+ if text:
2535
+ (stdout_parts if key.data == "stdout" else stderr_parts).append(text)
2536
+ if self.live_output is not None:
2537
+ self.live_output(str(key.data), text)
2472
2538
  if not data:
2473
2539
  try:
2474
2540
  selector.unregister(key.fileobj)
@@ -2479,10 +2545,6 @@ class BashTool(Tool):
2479
2545
  except Exception:
2480
2546
  pass
2481
2547
  return False
2482
- text = data.decode("utf-8", errors="replace")
2483
- (stdout_parts if key.data == "stdout" else stderr_parts).append(text)
2484
- if self.live_output is not None:
2485
- self.live_output(str(key.data), text)
2486
2548
  return True
2487
2549
 
2488
2550
  @staticmethod
@@ -2705,15 +2767,27 @@ class RecallTool(Tool):
2705
2767
 
2706
2768
  class NoteTool(Tool):
2707
2769
  NAME = "Note"
2708
- DESCRIPTION = "Maintain durable working notes; set_goal, replace_plan, and set_check replace current values, append_known appends, replace_known replaces all known facts."
2770
+ DESCRIPTION = "Maintain durable working notes; set_goal, replace_plan, and set_check replace current values, append_known appends, replace_known replaces all known facts. Plan items are objects with status todo|doing|done|blocked and text."
2709
2771
  SIGNATURE = "Note(set_goal?, replace_plan?, append_known?, replace_known?, set_check?)"
2710
- EXAMPLE = ('Set memory. Example: {"set_goal":"ship parser fix","replace_plan":["inspect parser","patch bug"],"append_known":["tests use pytest"],"set_check":"pytest -q passed"}',)
2772
+ EXAMPLE = (
2773
+ 'Set memory. Example: {"set_goal":"ship parser fix","replace_plan":[{"status":"doing","text":"inspect parser"},{"status":"todo","text":"patch bug"}],"append_known":["tests use pytest"],"set_check":"pytest -q passed"}',
2774
+ )
2711
2775
  STORES_RESULT = False
2712
2776
 
2713
2777
  @classmethod
2714
2778
  def params_schema(cls) -> Json:
2715
2779
  strings = {"type": "array", "items": {"type": "string"}}
2716
- return {"type": "object", "properties": {"set_goal": {"type": "string"}, "replace_plan": strings, "append_known": strings, "replace_known": strings, "set_check": {"type": "string"}}, "additionalProperties": False}
2780
+ plan_item = {
2781
+ "type": "object",
2782
+ "properties": {"status": {"type": "string", "enum": list(PlanItem.STATUSES)}, "text": {"type": "string"}},
2783
+ "required": ["status", "text"],
2784
+ "additionalProperties": False,
2785
+ }
2786
+ return {
2787
+ "type": "object",
2788
+ "properties": {"set_goal": {"type": "string"}, "replace_plan": {"type": "array", "items": plan_item}, "append_known": strings, "replace_known": strings, "set_check": {"type": "string"}},
2789
+ "additionalProperties": False,
2790
+ }
2717
2791
 
2718
2792
  @classmethod
2719
2793
  def payload_args(cls, payload: Json) -> list[Any]:
@@ -2738,8 +2812,14 @@ class NoteTool(Tool):
2738
2812
  changed.append("set_check")
2739
2813
  if "replace_plan" in data:
2740
2814
  if not isinstance(data["replace_plan"], list):
2741
- raise ToolError('Note replace_plan must be an array of strings, e.g. {"replace_plan":["inspect","patch"]}')
2742
- plan = [AgentState.plan_text(str(item)) for item in data["replace_plan"] if AgentState.plan_text(str(item))]
2815
+ raise ToolError('Note replace_plan must be an array of plan items, e.g. {"replace_plan":[{"status":"doing","text":"inspect"}]}')
2816
+ for item in data["replace_plan"]:
2817
+ if isinstance(item, dict):
2818
+ if str(item.get("status") or "").strip().lower() not in PlanItem.STATUSES:
2819
+ raise ToolError("Note replace_plan status must be one of: " + ", ".join(PlanItem.STATUSES))
2820
+ if not str(item.get("text") or "").strip():
2821
+ raise ToolError("Note replace_plan text is required")
2822
+ plan = AgentState.plan_items(data["replace_plan"])
2743
2823
  changed.append("replace_plan")
2744
2824
  if "append_known" in data:
2745
2825
  if not isinstance(data["append_known"], list):
@@ -2763,19 +2843,19 @@ class NoteTool(Tool):
2763
2843
  data = self.args[0] if self.args and isinstance(self.args[0], dict) else {}
2764
2844
  lines = []
2765
2845
  if goal := str(data.get("set_goal") or "").strip():
2766
- lines.append("set_goal -> " + Tool.compact(goal, 120))
2846
+ lines.append("goal: " + Tool.compact(goal, 120))
2767
2847
  if check := str(data.get("set_check") or "").strip():
2768
- lines.append("set_check -> " + Tool.compact(check, 120))
2848
+ lines.append("check: " + Tool.compact(check, 120))
2769
2849
  if isinstance(data.get("replace_plan"), list):
2770
- lines.extend(["replace_plan:", *(f" {row}" for row in AgentState.plan_rows_for(data["replace_plan"], status=True) if row != "- (empty)")])
2850
+ lines.extend(["plan:", *(f" {row}" for row in AgentState.plan_rows_for(data["replace_plan"], status=True, style="symbol") if row != "- (empty)")])
2771
2851
  if isinstance(data.get("append_known"), list):
2772
2852
  known = [Tool.compact(item, 120) for item in data["append_known"] if str(item).strip() and str(item).strip() not in self.session.state.known]
2773
2853
  if known:
2774
- lines.extend(["append_known:", *(f" + {item}" for item in known)])
2854
+ lines.extend(["known:", *(f" + {item}" for item in known)])
2775
2855
  if isinstance(data.get("replace_known"), list):
2776
2856
  known = [Tool.compact(item, 120) for item in data["replace_known"] if str(item).strip()]
2777
2857
  if known:
2778
- lines.extend(["replace_known:", *(f" {item}" for item in known)])
2858
+ lines.extend(["known:", *(f" {item}" for item in known)])
2779
2859
  return ["\n".join(lines) or "{}"]
2780
2860
 
2781
2861
 
@@ -3031,6 +3111,9 @@ class ToolCall:
3031
3111
  id: str
3032
3112
  name: str
3033
3113
  args: list[Any]
3114
+ # A malformed-argument error captured while parsing the call. Deferred so it surfaces as a
3115
+ # tool result the model can correct from, instead of aborting the whole turn at parse time.
3116
+ error: str = ""
3034
3117
 
3035
3118
 
3036
3119
  class ContextManager:
@@ -3147,7 +3230,7 @@ class ContextManager:
3147
3230
  index_usable = "yes" if index_status in {"synced", "ready", "stale"} else "no"
3148
3231
  rows = [
3149
3232
  "Goal: " + (self.session.state.goal or "(empty; use Note for multi-step work)"),
3150
- "Plan:\n" + "\n".join(self.session.state.plan_rows() or ["- (empty; use Note for a short plan)"]),
3233
+ "Plan:\n" + "\n".join(AgentState.plan_rows_for(self.session.state.plan, status=True)),
3151
3234
  "Known:\n" + "\n".join("- " + item for item in self.session.state.known or ["(empty)"]),
3152
3235
  "Check: " + (self.session.state.check or "(empty)"),
3153
3236
  f"Code index: {index_status} (InspectCode usable: {index_usable})",
@@ -3213,9 +3296,9 @@ class ContextManager:
3213
3296
  items.append(self.FileContextItem(order, 0, "clear", record.key, record.name, path, int(match.group(1)), int(match.group(2)), "", *stat))
3214
3297
  for match in re.finditer(r"(?s)<content hashline-numbered>\n(.*?)\n</content>", body):
3215
3298
  for line in match.group(1).splitlines():
3216
- line_match = re.match(r"(\d+):[0-9a-z]{5}\|", line)
3217
- if line_match:
3218
- items.append(self.FileContextItem(order, 1, "line", record.key, record.name, path, int(line_match.group(1)), 0, line, *stat))
3299
+ parsed = ReadTool.parse_anchor(line)
3300
+ if parsed is not None:
3301
+ items.append(self.FileContextItem(order, 1, "line", record.key, record.name, path, parsed[0], 0, line, *stat))
3219
3302
  return items
3220
3303
 
3221
3304
  def file_count(self) -> int:
@@ -3238,8 +3321,8 @@ class ContextManager:
3238
3321
  if item.path not in current_lines:
3239
3322
  current_lines[item.path] = self.read_lines(item.path, wanted.get(item.path, set()))
3240
3323
  lines = current_lines[item.path]
3241
- hash_match = re.match(r"\d+:([0-9a-z]{5})\|", item.line)
3242
- return bool(lines is not None and hash_match and item.start in lines and ReadTool.line_hash(lines[item.start]) == hash_match.group(1))
3324
+ parsed = ReadTool.parse_anchor(item.line)
3325
+ return bool(lines is not None and parsed is not None and item.start in lines and ReadTool.anchor_matches(lines[item.start], parsed[1]))
3243
3326
 
3244
3327
  def render_file_lines(self, lines_by_path: dict[str, dict[int, tuple[str, str, str]]], omitted: dict[str, dict[str, int]]) -> str:
3245
3328
  def recent(path: str) -> int:
@@ -3251,7 +3334,9 @@ class ContextManager:
3251
3334
  paths = sorted((path for path in lines_by_path if lines_by_path[path]), key=lambda path: (-recent(path), path))
3252
3335
  code_edits = self.recent_code_edits()
3253
3336
  check_status = self.check_status(code_edits)
3254
- focus, actions, errors = self.session.state.current_focus(), self.recent_file_actions(), self.recent_tool_errors()
3337
+ state = self.session.state
3338
+ focus = state.focus_text(state.plan)
3339
+ actions, errors = self.recent_file_actions(), self.recent_tool_errors()
3255
3340
  if not paths and not omitted and not focus and not actions and not code_edits and not check_status and not errors:
3256
3341
  return ""
3257
3342
  chunks = ["Read/Edit outputs update this section. Treat listed ranges as current file state."] if paths else []
@@ -3270,7 +3355,7 @@ class ContextManager:
3270
3355
  if errors:
3271
3356
  chunks.extend(["", "Recent tool errors:", *errors])
3272
3357
  if paths:
3273
- chunks.extend(["", "Content:", "Format: line:hash|text. Use line:hash as edit anchors."])
3358
+ chunks.extend(["", "Content:", "Format: anchor=line:hash | text, where hash = hash(line_content). Use the full line:hash value as Edit anchors."])
3274
3359
  for path in paths:
3275
3360
  for start, end, source, tool, segment_lines in self.segments(lines_by_path[path]):
3276
3361
  chunks.append(f"@@ {path} {start}:{end} current source={source} tool={tool}")
@@ -3681,18 +3766,18 @@ class EditBatchPlan:
3681
3766
  return changes
3682
3767
 
3683
3768
  def resolve_anchor(self, state: FileState, anchor: str) -> int:
3684
- match = re.fullmatch(r"(\d+):([0-9a-z]{5})", anchor.split("|", 1)[0].strip())
3685
- if not match:
3686
- raise ToolError('invalid anchor; use "line:hash" from Read or Search')
3687
- index, expected = int(match.group(1)), match.group(2).lower()
3688
- if index < len(state.lines) and ReadTool.line_hash(state.lines[index].text) == expected:
3769
+ parsed = ReadTool.parse_anchor(anchor)
3770
+ if parsed is None:
3771
+ raise ToolError('invalid anchor; use the "anchor=line:hash" value from Read, Search, or InspectCode')
3772
+ index, expected = parsed
3773
+ if index < len(state.lines) and ReadTool.anchor_matches(state.lines[index].text, expected):
3689
3774
  return index
3690
- if index < len(state.original) and ReadTool.line_hash(state.original[index]) == expected:
3775
+ if index < len(state.original) and ReadTool.anchor_matches(state.original[index], expected):
3691
3776
  current = state.current_origin(index)
3692
3777
  if current is not None:
3693
3778
  return current
3694
3779
  raise ToolError(f"stale anchor {anchor}; original line was changed in this batch")
3695
- current = f"{index}:{ReadTool.line_hash(state.lines[index].text)}|{state.lines[index].text.rstrip()}" if index < len(state.lines) else "out of range"
3780
+ current = ReadTool.anchor_line(index, state.lines[index].text) if index < len(state.lines) else "out of range"
3696
3781
  raise ToolError(f"stale anchor {anchor}; current is {current}")
3697
3782
 
3698
3783
 
@@ -3715,6 +3800,7 @@ class MCPManager:
3715
3800
  self.server_skips: dict[str, str] = {}
3716
3801
  self.lock = threading.Lock()
3717
3802
  self.discovery_status: str = "stale" # stale | discovering | ready | error
3803
+ self.index_truncated: bool = False # set by render_tools_index when even name-only overflows the cap
3718
3804
  self._configs_cache: list[MCPServerConfig] | None = None
3719
3805
  self._oauth_token_store = MCPFileTokenStore(self.session.data_path("mcp-oauth", "tokens.json"))
3720
3806
  self._loop: asyncio.AbstractEventLoop | None = None
@@ -3954,9 +4040,6 @@ class MCPManager:
3954
4040
  )
3955
4041
  return infos
3956
4042
 
3957
- def resource_info(self, server: str, uri: str) -> MCPResourceInfo | None:
3958
- return next((res for res in self.resources.get(server, []) if res.uri == uri), None)
3959
-
3960
4043
  @staticmethod
3961
4044
  def tool_annotations(tool: Any) -> Json:
3962
4045
  annotations = getattr(tool, "annotations", None)
@@ -4388,19 +4471,71 @@ class MCPManager:
4388
4471
  return "\n".join(lines)
4389
4472
 
4390
4473
  def render_tools_index(self) -> str:
4474
+ """Render the MCP tools block injected into every model turn (in the cached prefix).
4475
+
4476
+ The block is capped at INDEX_TOTAL_LIMIT so it cannot bloat each request. When it
4477
+ would overflow we degrade by shedding *detail*, never *entities*: the model can
4478
+ always re-fetch a dropped schema via `describe`, but it can never call a server or
4479
+ tool it was never told exists. So we try progressively cheaper renderings and emit
4480
+ the richest one that fits:
4481
+
4482
+ tier 1 "schema" — full per-tool JSON schemas inline (normal case)
4483
+ tier 2 "args" — schemas dropped, name + arg summary per tool
4484
+ tier 3 "names" — name-only, grouped per server
4485
+ tier 4 — hard truncate (only at thousands of tools, where 16KB
4486
+ physically cannot hold them); server headers come first so
4487
+ the model still sees most servers exist.
4488
+
4489
+ Tiers 1–3 keep every enabled server and tool name visible. See _index_body for how
4490
+ each detail level is rendered, and test_mcp.TestToolIndexBudget for the guarantees.
4491
+ """
4391
4492
  configs = [c for c in self.parse_configs() if c.enabled]
4392
4493
  if not configs:
4393
4494
  return ""
4394
4495
 
4395
- lines: list[str] = []
4396
- lines.append("--- MCP TOOLS ---")
4397
- lines.append('Use MCP(action="call", server, tool, arguments) for external MCP server tools.')
4398
- lines.append('Use MCP(action="describe", server, tool) for the full schema when one is truncated below; the result stays in the conversation, so do not describe the same tool again once its schema is shown — just call it.')
4399
- lines.append('Use MCP(action="read_resource", server, uri) to read a listed resource (e.g. docs describing how to build a tool\'s arguments). Read relevant resources before calling.')
4400
- lines.append("Format: server.tool(req: type; opt: type) - description")
4401
- lines.append(" schema: <JSON Schema for the arguments object>")
4402
- lines.append("")
4496
+ intro = [
4497
+ "--- MCP TOOLS ---",
4498
+ 'Use MCP(action="call", server, tool, arguments) for external MCP server tools.',
4499
+ 'Use MCP(action="describe", server, tool) for the full schema when one is truncated below; the result stays in the conversation, so do not describe the same tool again once its schema is shown — just call it.',
4500
+ 'Use MCP(action="read_resource", server, uri) to read a listed resource (e.g. docs describing how to build a tool\'s arguments). Read relevant resources before calling.',
4501
+ "Format: server.tool(req: type; opt: type) - description",
4502
+ " schema: <JSON Schema for the arguments object>",
4503
+ "",
4504
+ ]
4403
4505
 
4506
+ # A note tells the model what was shed (and that describe recovers it) so it does not
4507
+ # assume a tool is argument-less. Tier 1 ("schema") needs no note; tier 4 reuses the
4508
+ # last (tier 3) text below.
4509
+ notes = {
4510
+ "args": ['Schemas omitted to fit; use MCP(action="describe", server, tool) for a tool\'s arguments.', ""],
4511
+ "names": ['Only tool names shown to fit; use MCP(action="describe", server, tool) before calling.', ""],
4512
+ }
4513
+ for detail in ("schema", "args", "names"):
4514
+ body = self._index_body(configs, detail=detail)
4515
+ text = "\n".join(intro + notes.get(detail, []) + body)
4516
+ if len(text) <= self.INDEX_TOTAL_LIMIT:
4517
+ self.index_truncated = False
4518
+ return text
4519
+
4520
+ # Tier 4: even name-only overflows, so some tools are dropped entirely (not just
4521
+ # detail). Flag it so the CLI can warn the user — unlike tiers 1-3 these tools are
4522
+ # not callable until the index fits (fewer servers, or consult /mcp tools).
4523
+ self.index_truncated = True
4524
+ return text[: self.INDEX_TOTAL_LIMIT - 10] + "\n... MCP tools truncated; use /mcp tools for full list."
4525
+
4526
+ def _index_body(self, configs: list["MCPServerConfig"], *, detail: str = "schema") -> list[str]:
4527
+ """Render the per-server body lines of the tools index at one detail level.
4528
+
4529
+ detail controls how much of each tool is emitted (richest to cheapest):
4530
+ "schema" — full line via _format_tool_line, including the inline JSON schema
4531
+ "args" — same line without the schema (name + arg summary + description)
4532
+ "names" — one "tools: a, b, c" line per server, names only
4533
+
4534
+ Every enabled server is represented regardless of detail: a connected server shows
4535
+ its tools, an unconnected one (no tools/resources) is collected into a trailing
4536
+ "not yet available" section so the model still knows it exists.
4537
+ """
4538
+ lines: list[str] = []
4404
4539
  pending: list[str] = []
4405
4540
  for config in configs:
4406
4541
  tools = self.tools.get(config.name, [])
@@ -4408,12 +4543,15 @@ class MCPManager:
4408
4543
  if not tools and not resources:
4409
4544
  pending.append(f"- {config.name}: {self._pending_status(config.name)}")
4410
4545
  continue
4411
- name_display = config.name.capitalize()
4412
- lines.append(f"[{config.name}] {name_display}")
4413
- for tool in tools:
4414
- line = self._format_tool_line(config.name, tool)
4415
- if line:
4416
- lines.append(line)
4546
+ lines.append(f"[{config.name}] {config.name.capitalize()}")
4547
+ if detail == "names":
4548
+ if tools:
4549
+ lines.append("tools: " + ", ".join(tool.name for tool in tools))
4550
+ else:
4551
+ for tool in tools:
4552
+ line = self._format_tool_line(config.name, tool, include_schema=detail == "schema")
4553
+ if line:
4554
+ lines.append(line)
4417
4555
  if resources:
4418
4556
  lines.append(f"resources ({len(resources)}) — read with MCP(action=\"read_resource\", server={json.dumps(config.name)}, uri=...):")
4419
4557
  lines.extend(self._format_resource_line(res) for res in resources)
@@ -4423,11 +4561,7 @@ class MCPManager:
4423
4561
  lines.append("Configured servers not yet available (they exist — do not assume otherwise):")
4424
4562
  lines.extend(pending)
4425
4563
  lines.append("")
4426
-
4427
- text = "\n".join(lines)
4428
- if len(text) > self.INDEX_TOTAL_LIMIT:
4429
- text = text[: self.INDEX_TOTAL_LIMIT - 10] + "\n... MCP tools truncated; use /mcp tools for full list."
4430
- return text
4564
+ return lines
4431
4565
 
4432
4566
  def server_issue(self, name: str) -> tuple[str, str] | None:
4433
4567
  """Classify a server's failure state as (kind, message); error takes precedence over skip."""
@@ -4503,7 +4637,7 @@ class MCPManager:
4503
4637
  lines.extend(self._format_resource_line(res) for res in resources)
4504
4638
  return "\n".join(lines)
4505
4639
 
4506
- def _format_tool_line(self, server: str, info: MCPToolInfo) -> str:
4640
+ def _format_tool_line(self, server: str, info: MCPToolInfo, *, include_schema: bool = True) -> str:
4507
4641
  args_str = self._tool_args_summary(info)
4508
4642
  desc = (info.description or "").split("\n")[0].strip()
4509
4643
  desc = " ".join(desc.split())
@@ -4518,9 +4652,10 @@ class MCPManager:
4518
4652
  uris = self._extract_uris(info.description)
4519
4653
  if uris:
4520
4654
  line += "\n refs (read with MCP action=\"read_resource\"): " + ", ".join(uris)
4521
- schema = self._schema_json(info.input_schema, self.INDEX_SCHEMA_LIMIT)
4522
- if schema:
4523
- line += f"\n schema: {schema}"
4655
+ if include_schema:
4656
+ schema = self._schema_json(info.input_schema, self.INDEX_SCHEMA_LIMIT)
4657
+ if schema:
4658
+ line += f"\n schema: {schema}"
4524
4659
  return line
4525
4660
 
4526
4661
  URI_PATTERN: ClassVar[re.Pattern] = re.compile(r"[a-zA-Z][a-zA-Z0-9+.\-]*://[^\s'\"<>)\]}]+")
@@ -4645,28 +4780,112 @@ class ToolRunner:
4645
4780
  self.question_fn: Callable[[QuestionSpec, str], str] | None = None
4646
4781
 
4647
4782
  def run(self, calls: list[ToolCall], batch_suffix: str = "") -> list[Json]:
4648
- messages = []
4783
+ messages: list[Json] = []
4784
+ # Shared, mutated across segments: `first` controls which display carries batch_suffix;
4785
+ # `refused` short-circuits the rest of the batch once a confirmation is declined.
4786
+ state = {"first": True, "refused": False}
4649
4787
  index = 0
4650
- first, refused = True, False
4651
4788
  while index < len(calls):
4789
+ if state["refused"]:
4790
+ messages.append(self.skip_message(calls[index]))
4791
+ index += 1
4792
+ continue
4793
+ end = self.parallel_segment_end(calls, index)
4794
+ if end - index >= 2 and self.session.settings.max_parallel_tools > 1:
4795
+ messages.extend(self.run_parallel(calls[index:end], batch_suffix, state))
4796
+ index = end
4797
+ continue
4652
4798
  end = index + 1 if self.edit_barrier(calls[index]) else self.edit_segment_end(calls, index)
4653
- segment = calls[index:end]
4654
- plan = EditBatchPlan(self.session).build(segment) if not refused and any(call.name == "Edit" for call in segment) else EditBatchPlan(self.session)
4655
- for call in segment:
4656
- self.session.state.turn_tool_calls += 1
4657
- suffix = batch_suffix if first else ""
4658
- first = False
4659
- status, content = (
4660
- ("skipped", self.tool_message(call, "", "Skipped: previous tool call was refused", failed=True))
4661
- if refused
4662
- else self.run_one(call, batch_suffix=suffix, planned_edit=plan.planned.get(call.id), plan_error=plan.errors.get(call.id, ""))
4663
- )
4664
- messages.append({"role": "tool", "tool_call_id": call.id, "content": content})
4665
- if status == "refused":
4666
- refused = True
4799
+ messages.extend(self.run_serial(calls[index:end], batch_suffix, state))
4667
4800
  index = end
4668
4801
  return messages
4669
4802
 
4803
+ def skip_message(self, call: ToolCall) -> Json:
4804
+ self.session.state.turn_tool_calls += 1
4805
+ content = self.tool_message(call, "", "Skipped: previous tool call was refused", failed=True)
4806
+ return {"role": "tool", "tool_call_id": call.id, "content": content}
4807
+
4808
+ def run_serial(self, segment: list[ToolCall], batch_suffix: str, state: dict[str, bool]) -> list[Json]:
4809
+ messages: list[Json] = []
4810
+ plan = EditBatchPlan(self.session).build(segment) if any(call.name == "Edit" for call in segment) else EditBatchPlan(self.session)
4811
+ for call in segment:
4812
+ self.session.state.turn_tool_calls += 1
4813
+ suffix = batch_suffix if state["first"] else ""
4814
+ state["first"] = False
4815
+ status, content = self.run_one(call, batch_suffix=suffix, planned_edit=plan.planned.get(call.id), plan_error=plan.errors.get(call.id, ""))
4816
+ messages.append({"role": "tool", "tool_call_id": call.id, "content": content})
4817
+ if status == "refused":
4818
+ state["refused"] = True
4819
+ return messages
4820
+
4821
+ def run_parallel(self, segment: list[ToolCall], batch_suffix: str, state: dict[str, bool]) -> list[Json]:
4822
+ # Run the pure tool.call() work concurrently, but apply all side effects (display, session
4823
+ # bookkeeping, tool messages) on this thread in request order, so output and the results
4824
+ # handed back to the model match the order the model issued the calls.
4825
+ cap = max(1, self.session.settings.max_parallel_tools)
4826
+ outcomes: list[tuple[str, str, str | None, float] | None] = [None] * len(segment)
4827
+ with ThreadPoolExecutor(max_workers=min(len(segment), cap), thread_name_prefix="tool") as executor:
4828
+ futures = {executor.submit(self.execute_readonly, call): position for position, call in enumerate(segment)}
4829
+ for future in as_completed(futures):
4830
+ outcomes[futures[future]] = future.result()
4831
+ messages: list[Json] = []
4832
+ for call, outcome in zip(segment, outcomes):
4833
+ self.session.state.turn_tool_calls += 1
4834
+ suffix = batch_suffix if state["first"] else ""
4835
+ state["first"] = False
4836
+ assert outcome is not None
4837
+ content = self.finalize_outcome(call, outcome, batch_suffix=suffix)
4838
+ messages.append({"role": "tool", "tool_call_id": call.id, "content": content})
4839
+ return messages
4840
+
4841
+ def parallel_segment_end(self, calls: list[ToolCall], start: int) -> int:
4842
+ end = start
4843
+ while end < len(calls) and self.parallel_safe(calls[end]):
4844
+ end += 1
4845
+ return end
4846
+
4847
+ def parallel_safe(self, call: ToolCall) -> bool:
4848
+ # A call may run concurrently only if it neither mutates state nor blocks on interactive
4849
+ # input: read-only, auto-approved, non-interactive tools (Read/Search/Find/List/Recall/
4850
+ # InspectCode, read-only Git, read-only MCP). Edit is coordinated serially by EditBatchPlan;
4851
+ # Bash streams live output and mutates; Question blocks on the user.
4852
+ tool_class = TOOL_REGISTRY.get(call.name)
4853
+ if tool_class is None or call.name in {"Edit", "Question"} or tool_class is BashTool:
4854
+ return False
4855
+ try:
4856
+ return not tool_class(self.session, call.args).needs_confirmation()
4857
+ except Exception:
4858
+ return False
4859
+
4860
+ def execute_readonly(self, call: ToolCall) -> tuple[str, str, str | None, float]:
4861
+ # Pure execution for a parallel worker: returns (kind, output, display, elapsed) and performs
4862
+ # no display or session writes (those happen in finalize_outcome on the main thread). Mirrors
4863
+ # run_one's branches, minus confirmation (parallel_safe guarantees none is needed).
4864
+ started = time.monotonic()
4865
+ tool_class = TOOL_REGISTRY.get(call.name)
4866
+ if tool_class is None:
4867
+ return "reject", f"ToolError: unknown tool {call.name}", None, 0.0
4868
+ tool = tool_class(self.session, call.args)
4869
+ display = None
4870
+ try:
4871
+ display = self.short_call(call, tool.short_args())
4872
+ if call.error:
4873
+ raise ToolError(call.error)
4874
+ output = tool.call()
4875
+ except ToolError as error:
4876
+ return "reject", f"ToolError: {error}", display, time.monotonic() - started
4877
+ except Exception as error:
4878
+ return "error", f"ToolError: {error}", display, time.monotonic() - started
4879
+ return "ok", output, display, time.monotonic() - started
4880
+
4881
+ def finalize_outcome(self, call: ToolCall, outcome: tuple[str, str, str | None, float], batch_suffix: str = "") -> str:
4882
+ kind, output, display, elapsed = outcome
4883
+ if kind == "ok":
4884
+ return self.finish(call, output, elapsed=elapsed, display=display, batch_suffix=batch_suffix)
4885
+ if kind == "reject":
4886
+ return self.reject(call, output, elapsed=elapsed, display=display, batch_suffix=batch_suffix)
4887
+ return self.finish(call, output, failed=True, elapsed=elapsed, display=display, batch_suffix=batch_suffix)
4888
+
4670
4889
  def edit_segment_end(self, calls: list[ToolCall], start: int) -> int:
4671
4890
  end = start
4672
4891
  while end < len(calls) and not self.edit_barrier(calls[end]):
@@ -4701,16 +4920,24 @@ class ToolRunner:
4701
4920
  tool = tool_class(self.session, call.args)
4702
4921
  if isinstance(tool, BashTool):
4703
4922
  tool.live_output = self.live_output
4704
- started, approved, display = time.monotonic(), False, None
4923
+ started, approved, auto, display = time.monotonic(), False, False, None
4705
4924
  if isinstance(tool, QuestionTool):
4706
4925
  tool.question_fn = self.question_fn
4707
4926
  try:
4708
4927
  display = self.short_call(call, tool.short_args())
4928
+ if call.error:
4929
+ raise ToolError(call.error)
4709
4930
  if plan_error:
4710
4931
  raise ToolError(plan_error)
4711
4932
  needs_confirmation = tool.needs_confirmation()
4712
4933
  if needs_confirmation and self.session.settings.yolo:
4713
- self.output_fn(self.approval_display(call, tool, "auto", batch_suffix=batch_suffix, planned_edit=planned_edit))
4934
+ auto = True
4935
+ pre = self.approval_display(call, tool, "auto", batch_suffix=batch_suffix, planned_edit=planned_edit)
4936
+ # The "auto …" header duplicates the result line; only surface it when it carries a
4937
+ # preview the result line won't repeat (e.g. an Edit diff). The auto-approval itself
4938
+ # is recorded by the [auto] tag on the result line below.
4939
+ if "\n" in pre:
4940
+ self.output_fn(pre)
4714
4941
  elif needs_confirmation:
4715
4942
  confirmed, reason = self.confirm(call, tool, batch_suffix=batch_suffix, planned_edit=planned_edit)
4716
4943
  if not confirmed:
@@ -4725,7 +4952,7 @@ class ToolRunner:
4725
4952
  except Exception as error:
4726
4953
  output = f"ToolError: {error}"
4727
4954
  return "failed", self.finish(call, output, failed=True, elapsed=time.monotonic() - started, display=display, batch_suffix=batch_suffix)
4728
- return "ok", self.finish(call, output, elapsed=time.monotonic() - started, approved=approved, display=display, batch_suffix=batch_suffix)
4955
+ return "ok", self.finish(call, output, elapsed=time.monotonic() - started, approved=approved, auto=auto, display=display, batch_suffix=batch_suffix)
4729
4956
 
4730
4957
  def reject(self, call: ToolCall, output: str, *, elapsed: float | None = None, display: str | None = None, batch_suffix: str = "") -> str:
4731
4958
  if self.session.settings.debug:
@@ -4749,6 +4976,7 @@ class ToolRunner:
4749
4976
  failed: bool = False,
4750
4977
  elapsed: float | None = None,
4751
4978
  approved: bool = False,
4979
+ auto: bool = False,
4752
4980
  display: str | None = None,
4753
4981
  store: bool = True,
4754
4982
  batch_suffix: str = "",
@@ -4763,7 +4991,7 @@ class ToolRunner:
4763
4991
  self.session.record_tool_error(key or "-", call.name, call.args, output)
4764
4992
  elif key:
4765
4993
  self.update_code_index(call, output)
4766
- self.output_fn(self.finish_display(call, key, output, failed=failed, approved=approved, display=display, batch_suffix=batch_suffix, elapsed=elapsed))
4994
+ self.output_fn(self.finish_display(call, key, output, failed=failed, approved=approved, auto=auto, display=display, batch_suffix=batch_suffix, elapsed=elapsed))
4767
4995
  return self.tool_message(call, key, output, failed=failed, display=display)
4768
4996
 
4769
4997
  def tool_message(self, call: ToolCall, key: str, output: str, *, failed: bool = False, display: str | None = None) -> str:
@@ -4839,11 +5067,11 @@ class ToolRunner:
4839
5067
  return "\n".join([" preview", *(" " + line for line in lines)])
4840
5068
 
4841
5069
  def finish_display(
4842
- self, call: ToolCall, key: str, output: str, *, failed: bool, approved: bool = False, display: str | None = None, batch_suffix: str = "", elapsed: float | None = None
5070
+ self, call: ToolCall, key: str, output: str, *, failed: bool, approved: bool = False, auto: bool = False, display: str | None = None, batch_suffix: str = "", elapsed: float | None = None
4843
5071
  ) -> str:
4844
5072
  if call.name == "Note" and not failed and display:
4845
5073
  return self.with_batch_suffix(display.removeprefix("Note ").strip(), batch_suffix)
4846
- tag = " [refused]" if failed and "user refused" in output else " [failed]" if failed else " [approved]" if approved else ""
5074
+ tag = " [refused]" if failed and "user refused" in output else " [failed]" if failed else " [approved]" if approved else " [auto]" if auto else ""
4847
5075
  line = self.with_batch_suffix("tool " + (display or self.short_call(call)) + ((" -> " + key) if key else "") + tag, batch_suffix)
4848
5076
  lines = [line]
4849
5077
  if failed:
@@ -5051,6 +5279,7 @@ class ModelClient:
5051
5279
  Compact the nanocode working context.
5052
5280
  Return one JSON object only. No markdown, prose, code fences, or comments.
5053
5281
  Use keys: summary, goal, plan, known, check.
5282
+ Plan must be an array of objects: {"status":"todo|doing|done|blocked","text":"..."}.
5054
5283
  Rewrite recent conversation briefly inside summary.
5055
5284
  Keep only durable facts needed to continue; preserve file paths, symbols, constraints, and tr.N keys.
5056
5285
  """.strip()
@@ -5195,7 +5424,9 @@ Keep only durable facts needed to continue; preserve file paths, symbols, constr
5195
5424
  continue
5196
5425
  function = raw.get("function") if isinstance(raw.get("function"), dict) else {}
5197
5426
  try:
5198
- payload = json.loads(str(function.get("arguments") or "{}"))
5427
+ # strict=False: tool-call argument strings often contain literal newlines
5428
+ # (e.g. a multi-line git commit message), which are not valid JSON otherwise.
5429
+ payload = json.loads(str(function.get("arguments") or "{}"), strict=False)
5199
5430
  except json.JSONDecodeError:
5200
5431
  payload = {}
5201
5432
  blocks.append(
@@ -5235,7 +5466,7 @@ Keep only durable facts needed to continue; preserve file paths, symbols, constr
5235
5466
  call_id = str(self.message_field(block, "id") or uuid.uuid4().hex)
5236
5467
  arguments = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
5237
5468
  tool_calls.append({"id": call_id, "type": "function", "function": {"name": name, "arguments": arguments}})
5238
- calls.append(ToolCall(id=call_id, name=name, args=self.tool_payload(name, payload)))
5469
+ calls.append(self.tool_call(call_id, name, payload))
5239
5470
  text = "".join(text_parts)
5240
5471
  assistant: Json = {"role": "assistant", "content": text or None}
5241
5472
  if tool_calls:
@@ -5298,11 +5529,13 @@ Keep only durable facts needed to continue; preserve file paths, symbols, constr
5298
5529
  calls = []
5299
5530
  for raw in getattr(message, "tool_calls", None) or []:
5300
5531
  try:
5301
- payload = json.loads(raw.function.arguments or "{}")
5532
+ # strict=False so literal newlines in argument strings (e.g. a multi-line
5533
+ # git commit message) parse instead of dropping the call's args.
5534
+ payload = json.loads(raw.function.arguments or "{}", strict=False)
5302
5535
  except json.JSONDecodeError:
5303
5536
  calls.append(ToolCall(id=raw.id, name=raw.function.name, args=[]))
5304
5537
  continue
5305
- calls.append(ToolCall(id=raw.id, name=raw.function.name, args=self.tool_payload(raw.function.name, payload)))
5538
+ calls.append(self.tool_call(raw.id, raw.function.name, payload))
5306
5539
  return calls
5307
5540
 
5308
5541
  @staticmethod
@@ -5311,6 +5544,16 @@ Keep only durable facts needed to continue; preserve file paths, symbols, constr
5311
5544
  return tool.payload_args(payload)
5312
5545
  return [payload]
5313
5546
 
5547
+ @classmethod
5548
+ def tool_call(cls, call_id: str, name: str, payload: Any) -> ToolCall:
5549
+ # payload_args may reject malformed arguments (e.g. Git with an empty argv). Capture that
5550
+ # error on the call so it is replayed as a tool result during execution, letting the model
5551
+ # self-correct, rather than escaping to abort the entire agent turn.
5552
+ try:
5553
+ return ToolCall(id=call_id, name=name, args=cls.tool_payload(name, payload))
5554
+ except ToolError as error:
5555
+ return ToolCall(id=call_id, name=name, args=[], error=str(error))
5556
+
5314
5557
 
5315
5558
  class Agent:
5316
5559
  SYSTEM_PROMPT = """\
@@ -5324,6 +5567,12 @@ TOOLS:
5324
5567
  - State/external: Recall retrieves tr.N outputs; Note maintains goal/plan/known/check; MCP calls configured external tools.
5325
5568
  - Restraint: Before calling "Question", make progress with other tools first; only ask when genuinely blocked, and batch related questions into one call.
5326
5569
 
5570
+ GUIDE:
5571
+ - THINK BEFORE CODING: briefly state your approach before acting; surface important assumptions, ambiguity, and tradeoffs.
5572
+ - SIMPLICITY FIRST: implement the smallest non-speculative solution.
5573
+ - SURGICAL CHANGES: touch only lines that trace to the request; clean up only your own orphans.
5574
+ - GOAL-DRIVEN EXECUTION: define success criteria and loop until verified or blocked.
5575
+
5327
5576
  FLOW:
5328
5577
  - Act when clear; keep using tools until done, or return a final answer.
5329
5578
  - Batch tool calls when practical.
@@ -5452,6 +5701,7 @@ class CommandCompleter(Completer):
5452
5701
  "runtime.yolo",
5453
5702
  "runtime.max_agent_steps",
5454
5703
  "runtime.max_context_tokens",
5704
+ "runtime.max_parallel_tools",
5455
5705
  "runtime.shell_timeout",
5456
5706
  "runtime.check_updates",
5457
5707
  )
@@ -5564,7 +5814,7 @@ class UiPrinter:
5564
5814
  return self.tool_segments(text)
5565
5815
  if text.startswith("approve ") or text.startswith("auto "):
5566
5816
  return self.approval_segments(text)
5567
- if text.startswith(("goal ->", "goal:", "plan:", "known:")):
5817
+ if text.startswith(("goal:", "check:", "plan:", "known:")):
5568
5818
  return self.memory_segments(text)
5569
5819
  if text.startswith("+ "):
5570
5820
  return [("ansibrightblack", "+ "), ("ansiwhite", text[2:] + "\n")]
@@ -5603,10 +5853,16 @@ class UiPrinter:
5603
5853
  def memory_segments(self, text: str) -> list[tuple[str, str]]:
5604
5854
  segments = []
5605
5855
  for line in text.splitlines() or [""]:
5606
- if line.startswith(("goal ->", "goal:")):
5856
+ if line.startswith(("goal:", "check:")):
5607
5857
  segments.append(("ansimagenta", line))
5608
5858
  elif line in {"summary:", "plan:", "known:"}:
5609
5859
  segments.append(("ansicyan", line))
5860
+ elif line.lstrip().startswith("- [x]"):
5861
+ segments.append(("ansigreen", line))
5862
+ elif line.lstrip().startswith("- [~]"):
5863
+ segments.append(("ansiyellow", line))
5864
+ elif line.lstrip().startswith("- [-]"):
5865
+ segments.append(("ansired", line))
5610
5866
  elif line.lstrip().startswith("+ "):
5611
5867
  segments.append(("ansigreen", line))
5612
5868
  else:
@@ -5856,17 +6112,10 @@ class StatusBar:
5856
6112
  while not self.stop_event.is_set():
5857
6113
  self.output.write_raw("\r")
5858
6114
  self.output.erase_end_of_line()
5859
- print_formatted_text(FormattedText(self.active_fragments()), output=self.output, end="", flush=True)
6115
+ print_formatted_text(FormattedText(self.display_fragments(active=True)), output=self.output, end="", flush=True)
5860
6116
  self.rendered = True
5861
6117
  self.stop_event.wait(self.INTERVAL)
5862
6118
 
5863
- def refresh_retry_state(self) -> None:
5864
- count = self.session.state.model_retry_count
5865
- if count == self.seen_retry_count:
5866
- return
5867
- self.seen_retry_count = count
5868
- self.retry_notice_until = time.monotonic() + 2.0
5869
-
5870
6119
  def model_elapsed(self) -> float:
5871
6120
  return max(0.0, time.monotonic() - started) if (started := self.session.state.current_model_call_started_at) > 0 else 0.0
5872
6121
 
@@ -5877,11 +6126,13 @@ class StatusBar:
5877
6126
  self.output.flush()
5878
6127
  self.rendered = False
5879
6128
 
5880
- def idle_fragments(self) -> list[tuple[str, str]]:
5881
- return self.fragments(0.0, sweep=False, show_elapsed=False)
5882
-
5883
- def active_fragments(self) -> list[tuple[str, str]]:
5884
- self.refresh_retry_state()
6129
+ def display_fragments(self, *, active: bool) -> list[tuple[str, str]]:
6130
+ if not active:
6131
+ return self.fragments(0.0, sweep=False, show_elapsed=False)
6132
+ count = self.session.state.model_retry_count
6133
+ if count != self.seen_retry_count:
6134
+ self.seen_retry_count = count
6135
+ self.retry_notice_until = time.monotonic() + 2.0
5885
6136
  elapsed = max(0.0, time.monotonic() - self.started_at) if self.started_at else 0.0
5886
6137
  return self.fragments(elapsed, sweep=True, show_elapsed=True)
5887
6138
 
@@ -5928,7 +6179,8 @@ class StatusBar:
5928
6179
  if show_elapsed and self.session.pending_user_inputs:
5929
6180
  parts.append(("+" + str(len(self.session.pending_user_inputs)), "warn"))
5930
6181
  if show_elapsed:
5931
- parts.append((self.duration(elapsed), "runtime"))
6182
+ minutes, rest = divmod(int(elapsed), 60)
6183
+ parts.append((f"{elapsed:.1f}s" if elapsed < 60 else f"{minutes}m{rest:02d}s", "runtime"))
5932
6184
  if self.retry_notice_until > time.monotonic():
5933
6185
  parts.append(("retrying", "warn"))
5934
6186
  elif self.model_elapsed() >= self.stress_after():
@@ -5993,16 +6245,14 @@ class StatusBar:
5993
6245
  return f"mcp {loaded}/{total}{spinner}"
5994
6246
  if status == "error":
5995
6247
  return "mcp err"
5996
- return f"mcp {len(self.session.mcp.tools)}" if status == "ready" else ""
6248
+ if status != "ready":
6249
+ return ""
6250
+ # "!" flags that the tools index overflowed the cap and some tools are hidden.
6251
+ return f"mcp {len(self.session.mcp.tools)}{'!' if self.session.mcp.index_truncated else ''}"
5997
6252
 
5998
6253
  def stress_after(self) -> float:
5999
6254
  return max(30.0, self.session.config.provider.timeout * 0.5)
6000
6255
 
6001
- @staticmethod
6002
- def duration(seconds: float) -> str:
6003
- minutes, rest = divmod(int(seconds), 60)
6004
- return f"{seconds:.1f}s" if seconds < 60 else f"{minutes}m{rest:02d}s"
6005
-
6006
6256
 
6007
6257
  class CommandLoop:
6008
6258
  # Commands safe to run from the background queue-input thread while the agent works: read-only
@@ -6307,7 +6557,7 @@ Tools:
6307
6557
  if not self.session.resumed:
6308
6558
  return
6309
6559
  self.session.resumed = False
6310
- messages = [message for message in self.session.messages if self.should_render_resumed_message(message)]
6560
+ messages = [message for message in self.session.messages if not SessionSnapshotCodec.is_internal_message(message) and message.get("role") != "tool"]
6311
6561
  if not messages:
6312
6562
  return
6313
6563
  self.emit(f"Restored session: {self.session.uid}")
@@ -6315,15 +6565,6 @@ Tools:
6315
6565
  for message in messages:
6316
6566
  tool_record_index = self.render_transcript_message(message, tool_record_index)
6317
6567
 
6318
- @staticmethod
6319
- def is_resume_marker(message: Json) -> bool:
6320
- return SessionSnapshotCodec.is_internal_message(message)
6321
-
6322
- def should_render_resumed_message(self, message: Json) -> bool:
6323
- if self.is_resume_marker(message):
6324
- return False
6325
- return message.get("role") != "tool"
6326
-
6327
6568
  def render_transcript_message(self, message: Json, tool_record_index: int = 0) -> int:
6328
6569
  role = str(message.get("role") or "")
6329
6570
  content = str(message.get("content") or "").strip()
@@ -6359,10 +6600,18 @@ Tools:
6359
6600
  return None
6360
6601
  arguments = function.get("arguments")
6361
6602
  try:
6362
- payload = json.loads(arguments) if isinstance(arguments, str) else (arguments or {})
6603
+ # strict=False tolerates literal newlines in argument strings (e.g. multi-line
6604
+ # git commit messages) that would otherwise be rejected as invalid JSON.
6605
+ payload = json.loads(arguments, strict=False) if isinstance(arguments, str) else (arguments or {})
6363
6606
  except json.JSONDecodeError:
6364
6607
  payload = {}
6365
- return ToolCall(id=str(raw.get("id") or ""), name=name, args=ModelClient.tool_payload(name, payload))
6608
+ try:
6609
+ args = ModelClient.tool_payload(name, payload)
6610
+ except ToolError:
6611
+ # A malformed historical call (e.g. tool args that fail validation) must not crash
6612
+ # the resume; render it without parsed args.
6613
+ args = [payload] if payload else []
6614
+ return ToolCall(id=str(raw.get("id") or ""), name=name, args=args)
6366
6615
 
6367
6616
  def transcript_tool_record(self, call: ToolCall, tool_record_index: int) -> tuple[ToolResultRecord | None, int]:
6368
6617
  tool_class = TOOL_REGISTRY.get(call.name)
@@ -6386,6 +6635,11 @@ Tools:
6386
6635
  notice = self.mcp_error_notice()
6387
6636
  if notice:
6388
6637
  self.emit(notice)
6638
+ # render once so index_truncated reflects the freshly discovered tools, then warn if
6639
+ # the index is too large to fit even as name-only (some tools are hidden from the model).
6640
+ self.session.mcp.render_tools_index()
6641
+ if self.session.mcp.index_truncated:
6642
+ self.emit("mcp: tools index exceeds the size budget; some tools are hidden from the model. Reduce enabled servers or run /mcp tools to see the full list.")
6389
6643
 
6390
6644
  def mcp_error_notice(self) -> str:
6391
6645
  errors = [
@@ -6426,7 +6680,7 @@ Tools:
6426
6680
 
6427
6681
  def status_window(self, *, active: bool = False) -> Window:
6428
6682
  return Window(
6429
- FormattedTextControl(self.status_bar.active_fragments if active else self.status_bar.idle_fragments, style="class:bottom-toolbar.text"),
6683
+ FormattedTextControl(lambda: self.status_bar.display_fragments(active=active), style="class:bottom-toolbar.text"),
6430
6684
  style="class:bottom-toolbar",
6431
6685
  height=1,
6432
6686
  dont_extend_height=True,
@@ -7003,16 +7257,6 @@ Tools:
7003
7257
  self.emit(result + "\n")
7004
7258
  return result
7005
7259
 
7006
- def select_model(self, choices: tuple[str, ...]) -> str | object | None:
7007
- current = self.session.config.provider.model
7008
- labels = {label: label for label in self.MODEL_LABELS if label in choices}
7009
- labels.update({current: current + " (current)"} if current in choices else {})
7010
- return self.select_choice("Model", choices, labels=labels, current=current, disabled=self.MODEL_LABELS)
7011
-
7012
- def select_provider(self, choices: tuple[str, ...]) -> str | object | None:
7013
- current = self.session.config.active_provider
7014
- return self.select_choice("Provider", choices, labels={current: current + " (current)"}, current=current)
7015
-
7016
7260
  def select_reasoning(self) -> str | object | None:
7017
7261
  current = self.session.config.provider.reasoning
7018
7262
  labels = {"off": "off - disable reasoning"}
@@ -7072,7 +7316,7 @@ Tools:
7072
7316
  state = self.session.state
7073
7317
  known = ["- " + item for item in state.known] or ["- (empty)"]
7074
7318
  return "\n".join(
7075
- ["goal: " + (state.goal or "(empty)"), "summary:", state.summary or "(empty)", "plan:", *state.plan_rows(status=True), "known:", *known]
7319
+ ["goal: " + (state.goal or "(empty)"), "summary:", state.summary or "(empty)", "plan:", *AgentState.plan_rows_for(state.plan, status=True, style="symbol"), "known:", *known]
7076
7320
  )
7077
7321
 
7078
7322
  def config(self, args: str) -> str:
@@ -7097,6 +7341,7 @@ Tools:
7097
7341
  f"runtime.shell_timeout: {self.session.settings.shell_timeout}",
7098
7342
  f"runtime.max_agent_steps: {self.session.settings.max_steps}",
7099
7343
  f"runtime.max_context_tokens: {self.session.settings.max_context_tokens}",
7344
+ f"runtime.max_parallel_tools: {self.session.settings.max_parallel_tools}",
7100
7345
  f"runtime.check_updates: {'on' if self.session.settings.check_updates else 'off'}",
7101
7346
  f"runtime.update_check_interval_hours: {self.session.settings.update_check_interval_hours}",
7102
7347
  f"runtime.session_retention_days: {self.session.settings.session_retention_days}",
@@ -7181,13 +7426,12 @@ Tools:
7181
7426
  if parts:
7182
7427
  return self.set_provider(parts[0])
7183
7428
  choices = tuple(sorted(self.session.config.providers))
7429
+ summary = "provider: " + self.session.config.active_provider + "\nproviders: " + ", ".join(choices)
7184
7430
  if len(choices) <= 1:
7185
- return self.provider_summary()
7186
- choice = self.select_provider(choices)
7187
- return self.set_provider(choice) if isinstance(choice, str) else ("No change" if choice is SELECTION_BACK else self.provider_summary())
7188
-
7189
- def provider_summary(self) -> str:
7190
- return "provider: " + self.session.config.active_provider + "\nproviders: " + ", ".join(sorted(self.session.config.providers))
7431
+ return summary
7432
+ current = self.session.config.active_provider
7433
+ choice = self.select_choice("Provider", choices, labels={current: current + " (current)"}, current=current)
7434
+ return self.set_provider(choice) if isinstance(choice, str) else ("No change" if choice is SELECTION_BACK else summary)
7191
7435
 
7192
7436
  def set_provider(self, name: str) -> str:
7193
7437
  if name not in self.session.config.providers:
@@ -7202,11 +7446,22 @@ Tools:
7202
7446
  if parts:
7203
7447
  result = self.set_model(parts[0])
7204
7448
  return "No change" if result is SELECTION_BACK else str(result)
7205
- choices = self.model_choices()
7449
+ provider = self.session.config.provider
7450
+ configured = tuple(dict.fromkeys(provider.available_models))
7451
+ remote = tuple(model for model in self.remote_models(provider) if model not in configured)
7452
+ choices: list[str] = []
7453
+ if configured:
7454
+ choices.extend((self.MODEL_CONFIGURED_LABEL, *configured))
7455
+ if remote:
7456
+ choices.extend((self.MODEL_DISCOVERED_LABEL, *remote))
7457
+ choices = tuple(choices)
7206
7458
  if not choices:
7207
7459
  return "Current provider.model is " + (self.session.config.provider.model or "(empty)")
7208
7460
  while True:
7209
- choice = self.select_model(choices)
7461
+ current = self.session.config.provider.model
7462
+ labels = {label: label for label in self.MODEL_LABELS if label in choices}
7463
+ labels.update({current: current + " (current)"} if current in choices else {})
7464
+ choice = self.select_choice("Model", choices, labels=labels, current=current, disabled=self.MODEL_LABELS)
7210
7465
  if choice is SELECTION_BACK:
7211
7466
  return "No change"
7212
7467
  if not isinstance(choice, str):
@@ -7218,17 +7473,6 @@ Tools:
7218
7473
  continue
7219
7474
  return str(result)
7220
7475
 
7221
- def model_choices(self) -> tuple[str, ...]:
7222
- provider = self.session.config.provider
7223
- configured = tuple(dict.fromkeys(provider.available_models))
7224
- remote = tuple(model for model in self.remote_models(provider) if model not in configured)
7225
- choices: list[str] = []
7226
- if configured:
7227
- choices.extend((self.MODEL_CONFIGURED_LABEL, *configured))
7228
- if remote:
7229
- choices.extend((self.MODEL_DISCOVERED_LABEL, *remote))
7230
- return tuple(choices)
7231
-
7232
7476
  def remote_models(self, provider: ProviderConfig) -> tuple[str, ...]:
7233
7477
  if not provider.url or not provider.key:
7234
7478
  return ()
@@ -7308,6 +7552,8 @@ Tools:
7308
7552
  runtime.max_context_tokens = max(1, int(value))
7309
7553
  elif key == "runtime.shell_timeout":
7310
7554
  runtime.shell_timeout = max(1, int(value))
7555
+ elif key == "runtime.max_parallel_tools":
7556
+ runtime.max_parallel_tools = max(1, int(value))
7311
7557
  else:
7312
7558
  return "Unknown config key: " + key
7313
7559
  except (ConfigError, ValueError):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nanocode-cli
3
- Version: 0.6.6
3
+ Version: 0.7.0
4
4
  Summary: A small terminal coding agent written in Python
5
5
  Author-email: hit9 <hit9@icloud.com>
6
6
  License-Expression: BSD-3-Clause
@@ -22,13 +22,14 @@ Requires-Python: >=3.11
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
24
  Requires-Dist: anthropic>=0.64.0
25
- Requires-Dist: code-symbol-index>=0.3.0
25
+ Requires-Dist: code-symbol-index>=0.3.1
26
26
  Requires-Dist: json-repair
27
- Requires-Dist: fastmcp<4,>=3
27
+ Requires-Dist: fastmcp-slim[client]<4,>=3
28
28
  Requires-Dist: openai>=2.37.0
29
29
  Requires-Dist: prompt-toolkit>=3.0
30
30
  Requires-Dist: rich>=13.0
31
31
  Requires-Dist: socksio>=1.0.0
32
+ Requires-Dist: websockets>=15.0.1
32
33
  Provides-Extra: dev
33
34
  Requires-Dist: pytest>=8.0; extra == "dev"
34
35
  Requires-Dist: ruff>=0.4.0; extra == "dev"
@@ -1,11 +1,12 @@
1
1
  anthropic>=0.64.0
2
- code-symbol-index>=0.3.0
2
+ code-symbol-index>=0.3.1
3
3
  json-repair
4
- fastmcp<4,>=3
4
+ fastmcp-slim[client]<4,>=3
5
5
  openai>=2.37.0
6
6
  prompt-toolkit>=3.0
7
7
  rich>=13.0
8
8
  socksio>=1.0.0
9
+ websockets>=15.0.1
9
10
 
10
11
  [dev]
11
12
  pytest>=8.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nanocode-cli"
7
- version = "0.6.6"
7
+ version = "0.7.0"
8
8
  description = "A small terminal coding agent written in Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -28,13 +28,14 @@ classifiers = [
28
28
  ]
29
29
  dependencies = [
30
30
  "anthropic>=0.64.0",
31
- "code-symbol-index>=0.3.0",
31
+ "code-symbol-index>=0.3.1",
32
32
  "json-repair",
33
- "fastmcp>=3,<4",
33
+ "fastmcp-slim[client]>=3,<4",
34
34
  "openai>=2.37.0",
35
35
  "prompt-toolkit>=3.0",
36
36
  "rich>=13.0",
37
37
  "socksio>=1.0.0",
38
+ "websockets>=15.0.1",
38
39
  ]
39
40
 
40
41
  [project.urls]
File without changes
File without changes
File without changes
File without changes