msapling-cli 0.1.2__tar.gz → 0.1.4__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.
Files changed (39) hide show
  1. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/PKG-INFO +4 -1
  2. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/__init__.py +1 -1
  3. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/agent.py +318 -14
  4. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/api.py +188 -11
  5. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/completer.py +5 -2
  6. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/config.py +5 -2
  7. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/main.py +1 -1
  8. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/shell.py +418 -90
  9. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli.egg-info/PKG-INFO +4 -1
  10. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli.egg-info/requires.txt +4 -0
  11. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/pyproject.toml +3 -2
  12. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/tests/test_agent.py +8 -1
  13. msapling_cli-0.1.4/tests/test_api.py +159 -0
  14. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/tests/test_config.py +1 -1
  15. msapling_cli-0.1.2/tests/test_api.py +0 -89
  16. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/LICENSE +0 -0
  17. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/README.md +0 -0
  18. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/local.py +0 -0
  19. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/mcp/__init__.py +0 -0
  20. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/mcp/server.py +0 -0
  21. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/memory.py +0 -0
  22. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/session.py +0 -0
  23. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/storage.py +0 -0
  24. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/tier.py +0 -0
  25. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/tui.py +0 -0
  26. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli/worker_pool.py +0 -0
  27. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli.egg-info/SOURCES.txt +0 -0
  28. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli.egg-info/dependency_links.txt +0 -0
  29. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli.egg-info/entry_points.txt +0 -0
  30. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/msapling_cli.egg-info/top_level.txt +0 -0
  31. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/setup.cfg +0 -0
  32. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/tests/test_local.py +0 -0
  33. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/tests/test_mcp.py +0 -0
  34. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/tests/test_memory.py +0 -0
  35. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/tests/test_session.py +0 -0
  36. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/tests/test_shell.py +0 -0
  37. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/tests/test_storage.py +0 -0
  38. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/tests/test_tier.py +0 -0
  39. {msapling_cli-0.1.2 → msapling_cli-0.1.4}/tests/test_tui.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: msapling-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: MSapling CLI - Multi-chat AI development environment in your terminal
5
5
  Author: MSapling Team
6
6
  License-Expression: MIT
@@ -31,9 +31,12 @@ Provides-Extra: keyring
31
31
  Requires-Dist: keyring>=24.0; extra == "keyring"
32
32
  Provides-Extra: tui
33
33
  Requires-Dist: textual>=0.40.0; extra == "tui"
34
+ Provides-Extra: pdf
35
+ Requires-Dist: pdfplumber>=0.10.0; extra == "pdf"
34
36
  Provides-Extra: all
35
37
  Requires-Dist: keyring>=24.0; extra == "all"
36
38
  Requires-Dist: textual>=0.40.0; extra == "all"
39
+ Requires-Dist: pdfplumber>=0.10.0; extra == "all"
37
40
  Provides-Extra: dev
38
41
  Requires-Dist: build>=1.2.0; extra == "dev"
39
42
  Requires-Dist: pytest>=8.0; extra == "dev"
@@ -1,2 +1,2 @@
1
1
  """MSapling CLI - Multi-chat AI development environment in your terminal."""
2
- __version__ = "0.1.2"
2
+ __version__ = "0.1.4"
@@ -56,7 +56,10 @@ _permission_mode: str = "ask"
56
56
  _permissions: Dict[str, bool] = {}
57
57
 
58
58
  # Read-only tools that never require approval in any mode
59
- _READ_ONLY_TOOLS = frozenset({"read_file", "glob_files", "grep_search", "web_search", "cost_check"})
59
+ _READ_ONLY_TOOLS = frozenset({
60
+ "read_file", "glob_files", "grep_search", "web_search", "web_fetch",
61
+ "cost_check", "list_directory", "ask_user", "todo_list",
62
+ })
60
63
 
61
64
  # Write tools blocked in readonly/plan mode
62
65
  _WRITE_TOOLS = frozenset({"write_file", "edit_file", "run_command"})
@@ -131,6 +134,31 @@ AGENT_TOOLS_SCHEMA = [
131
134
  "description": "Search the web for current information. Uses MSapling's backend search. Use when the user asks about recent events, documentation, or anything that might be outdated in your training data.",
132
135
  "parameters": {"query": "string (search query)"},
133
136
  },
137
+ {
138
+ "name": "web_fetch",
139
+ "description": "Fetch the content of a specific URL. Returns the page text (HTML stripped). Use for reading documentation, API references, or specific web pages.",
140
+ "parameters": {"url": "string (full URL to fetch)"},
141
+ },
142
+ {
143
+ "name": "list_directory",
144
+ "description": "List files and directories at a path with sizes and types. More detailed than glob_files.",
145
+ "parameters": {"path": "string (relative directory path, default '.')"},
146
+ },
147
+ {
148
+ "name": "ask_user",
149
+ "description": "Ask the user a clarifying question when you need more information to proceed. Use sparingly — only when truly ambiguous.",
150
+ "parameters": {"question": "string (the question to ask)"},
151
+ },
152
+ {
153
+ "name": "todo_list",
154
+ "description": "Manage a task checklist for the current session. Use to plan multi-step work and track progress.",
155
+ "parameters": {"action": "string ('add', 'done', 'list', 'clear')", "item": "string (task description, for 'add' and 'done')"},
156
+ },
157
+ {
158
+ "name": "search_replace_all",
159
+ "description": "Replace ALL occurrences of a string in a file (not just the first). Use when renaming a variable or updating all imports.",
160
+ "parameters": {"path": "string (relative file path)", "old_string": "string (text to find)", "new_string": "string (replacement)"},
161
+ },
134
162
  {
135
163
  "name": "cost_check",
136
164
  "description": "Check the user's remaining fuel credits and session cost before expensive operations. Use this before large file writes or multi-step tasks.",
@@ -157,17 +185,22 @@ To use a tool, respond with a JSON block in this format:
157
185
  ```
158
186
 
159
187
  Available tools:
160
- - read_file: Read a file. Args: path, offset (optional), limit (optional)
188
+ - read_file: Read a file (supports text, PDF, notebooks). Args: path, offset (optional), limit (optional)
161
189
  - write_file: Create/overwrite a file. Args: path, content
162
- - edit_file: Replace a specific string in a file. Args: path, old_string, new_string
190
+ - edit_file: Replace first occurrence of a string. Args: path, old_string, new_string
191
+ - search_replace_all: Replace ALL occurrences in a file. Args: path, old_string, new_string
163
192
  - run_command: Execute a shell command. Args: command
193
+ - list_directory: List files with sizes and types. Args: path (default ".")
164
194
  - glob_files: Find files by pattern. Args: pattern
165
195
  - grep_search: Search file contents. Args: pattern, path (optional)
166
196
  - web_search: Search the web via MSapling backend. Args: query
167
- - cost_check: Check remaining fuel credits and session cost. No args.
168
- - model_switch: Switch to a different model mid-task. Args: model (e.g. "google/gemini-2.0-flash-001")
197
+ - web_fetch: Fetch content of a specific URL. Args: url
198
+ - ask_user: Ask the user a clarifying question. Args: question
199
+ - todo_list: Manage task checklist. Args: action (add/done/list/clear), item
200
+ - cost_check: Check remaining fuel credits. No args.
201
+ - model_switch: Switch model mid-task. Args: model
169
202
 
170
- Cost tips: Use cost_check before expensive multi-step tasks. Use model_switch to use cheaper models for boilerplate.
203
+ Tips: Use cost_check before expensive tasks. Use model_switch for cheaper models on grunt work. Use search_replace_all for renaming variables across a file.
171
204
 
172
205
  You can use multiple tools in one response. After tool results, continue reasoning.
173
206
  When you're done (no more tools needed), just respond with your final message.
@@ -356,17 +389,103 @@ def _web_search(query: str) -> str:
356
389
  return f"Web search error: {e}"
357
390
 
358
391
 
392
+ # ─── Command Sandbox ─────────────────────────────────────────────────
393
+ # Block dangerous command patterns. Not OS-level but catches common mistakes.
394
+
395
+ _BLOCKED_COMMAND_PATTERNS = [
396
+ r"\brm\s+(-[rRf]+\s+)?/", # rm -rf /
397
+ r"\brm\s+-[rRf]*\s+~", # rm -rf ~
398
+ r"\brm\s+-[rRf]*\s+\.\.", # rm -rf ..
399
+ r"\bsudo\b", # sudo anything
400
+ r"\bchmod\s+777\b", # chmod 777
401
+ r"\bchmod\s+-R\b", # chmod -R (recursive)
402
+ r"\bmkfs\b", # format filesystem
403
+ r"\bdd\s+if=", # dd disk write
404
+ r"\b:()\s*\{", # fork bomb
405
+ r"\bcurl\b.*\|\s*bash", # curl | bash
406
+ r"\bwget\b.*\|\s*bash", # wget | bash
407
+ r"\bcurl\b.*\|\s*sh", # curl | sh
408
+ r"\bwget\b.*\|\s*sh", # wget | sh
409
+ r">\s*/dev/sd", # write to disk device
410
+ r"\bshutdown\b", # shutdown
411
+ r"\breboot\b", # reboot
412
+ r"\bkill\s+-9\s+1\b", # kill init
413
+ r"\biptables\b", # firewall rules
414
+ r"\bufw\b", # firewall
415
+ ]
416
+
417
+ _sandbox_enabled: bool = True
418
+
419
+
420
+ def _check_command_sandbox(command: str) -> Optional[str]:
421
+ """Check if a command is blocked by the sandbox. Returns error message or None."""
422
+ if not _sandbox_enabled:
423
+ return None
424
+ cmd_lower = command.lower().strip()
425
+ for pattern in _BLOCKED_COMMAND_PATTERNS:
426
+ if re.search(pattern, cmd_lower):
427
+ return f"Blocked by sandbox: '{command[:60]}' matches dangerous pattern. Use /sandbox off to disable."
428
+ return None
429
+
430
+
431
+ def set_sandbox(enabled: bool) -> bool:
432
+ global _sandbox_enabled
433
+ _sandbox_enabled = enabled
434
+ return _sandbox_enabled
435
+
436
+
437
+ # Strong references to background tasks so they don't get GC'd mid-flight.
438
+ # See: https://docs.python.org/3/library/asyncio-task.html#creating-tasks
439
+ _background_tasks: set = set()
440
+
441
+ # Agent todo list (managed via todo_list tool)
442
+ _agent_todos: List[Dict[str, Any]] = []
443
+
444
+
445
+ def _mdrive_auto_push(project_root: str, rel_path: str, content: str, project_name: str = "", agent_id: str = "cli-agent"):
446
+ """Background push to MDrive after local file write. Best-effort, non-blocking."""
447
+ if not project_name:
448
+ return
449
+ try:
450
+ import asyncio as _aio
451
+ from .api import MSaplingClient
452
+
453
+ async def _push():
454
+ client = MSaplingClient()
455
+ try:
456
+ await client.mdrive_write(
457
+ project_name, rel_path, content,
458
+ agent_id=agent_id,
459
+ change_summary="Auto-pushed by agent tool",
460
+ )
461
+ finally:
462
+ await client.close()
463
+
464
+ try:
465
+ loop = _aio.get_running_loop()
466
+ task = loop.create_task(_push())
467
+ # Hold strong reference until task completes
468
+ _background_tasks.add(task)
469
+ task.add_done_callback(_background_tasks.discard)
470
+ except RuntimeError:
471
+ _aio.run(_push())
472
+ except Exception:
473
+ pass # Best-effort, never block the agent
474
+
475
+
359
476
  def _execute_tool(
360
477
  tool_name: str,
361
478
  args: Dict[str, Any],
362
479
  project_root: str,
363
480
  hooks_mgr: Any = None,
364
481
  model: str = "",
482
+ project_name: str = "",
365
483
  ) -> str:
366
484
  """Execute a single tool call locally. Returns result as string.
367
485
 
368
486
  When *hooks_mgr* is provided (a HooksManager), the function fires
369
487
  *on_file_write* hooks after a successful write_file or edit_file.
488
+ When *project_name* is set, auto-pushes written files to MDrive.
370
489
  """
371
490
 
372
491
  # --- Approval gate for mutating tools ---
@@ -382,7 +501,47 @@ def _execute_tool(
382
501
  if not fpath.is_file():
383
502
  return f"Error: file not found: {path}"
384
503
  try:
385
- content = fpath.read_text(encoding="utf-8", errors="ignore")
504
+ ext = fpath.suffix.lower()
505
+
506
+ # PDF reading
507
+ if ext == ".pdf":
508
+ try:
509
+ import pdfplumber
510
+ text_parts = []
511
+ with pdfplumber.open(fpath) as pdf:
512
+ for i, page in enumerate(pdf.pages[:30]):
513
+ page_text = page.extract_text() or ""
514
+ if page_text.strip():
515
+ text_parts.append(f"--- Page {i+1} ---\n{page_text}")
516
+ content = "\n\n".join(text_parts) if text_parts else "(no extractable text)"
517
+ except ImportError:
518
+ return "Error: PDF reading requires pdfplumber. Install: pip install pdfplumber"
519
+
520
+ # Jupyter notebook reading
521
+ elif ext == ".ipynb":
522
+ try:
523
+ nb = json.loads(fpath.read_text(encoding="utf-8"))
524
+ cells = nb.get("cells", [])
525
+ parts = []
526
+ for i, cell in enumerate(cells):
527
+ cell_type = cell.get("cell_type", "code")
528
+ source = "".join(cell.get("source", []))
529
+ if cell_type == "markdown":
530
+ parts.append(f"[Markdown cell {i+1}]\n{source}")
531
+ else:
532
+ parts.append(f"[Code cell {i+1}]\n```\n{source}\n```")
533
+ outputs = cell.get("outputs", [])
534
+ for out in outputs[:3]:
535
+ if "text" in out:
536
+ parts.append("Output: " + "".join(out["text"])[:500])
537
+ content = "\n\n".join(parts)
538
+ except Exception as e:
539
+ return f"Error parsing notebook: {e}"
540
+
541
+ # Standard text file
542
+ else:
543
+ content = fpath.read_text(encoding="utf-8", errors="ignore")
544
+
386
545
  offset = int(args.get("offset", 0))
387
546
  limit = int(args.get("limit", 0))
388
547
  if offset or limit:
@@ -411,6 +570,8 @@ def _execute_tool(
411
570
  project=project_root, model=model,
412
571
  file=str(fpath), tool="write_file",
413
572
  )
573
+ # Auto-push to MDrive (best-effort, non-blocking)
574
+ _mdrive_auto_push(project_root, path, content, project_name)
414
575
  return f"Wrote {len(content)} bytes to {path}"
415
576
  except Exception as e:
416
577
  return f"Error writing {path}: {e}"
@@ -431,7 +592,9 @@ def _execute_tool(
431
592
  # Backup before editing (like Claude Code's file-history)
432
593
  try:
433
594
  from .storage import backup_file
434
- backup_file(path, content, "agent")
595
+ import hashlib
596
+ session_key = hashlib.md5(project_root.encode()).hexdigest()[:12]
597
+ backup_file(path, content, session_key)
435
598
  except Exception:
436
599
  pass
437
600
  new_content = content.replace(old_string, new_string, 1)
@@ -442,6 +605,8 @@ def _execute_tool(
442
605
  project=project_root, model=model,
443
606
  file=str(fpath), tool="edit_file",
444
607
  )
608
+ # Auto-push to MDrive (best-effort, non-blocking)
609
+ _mdrive_auto_push(project_root, path, new_content, project_name)
445
610
  return f"Edited {path}: replaced {len(old_string)} chars with {len(new_string)} chars"
446
611
  except Exception as e:
447
612
  return f"Error editing {path}: {e}"
@@ -450,10 +615,14 @@ def _execute_tool(
450
615
  command = args.get("command", "")
451
616
  if not command:
452
617
  return "Error: no command provided"
618
+ # Sandbox check: block dangerous patterns
619
+ blocked = _check_command_sandbox(command)
620
+ if blocked:
621
+ return blocked
453
622
  try:
454
623
  result = subprocess.run(
455
624
  command, shell=True, cwd=project_root,
456
- capture_output=True, text=True, timeout=30,
625
+ capture_output=True, text=True, timeout=60,
457
626
  )
458
627
  output = ""
459
628
  if result.stdout:
@@ -463,7 +632,7 @@ def _execute_tool(
463
632
  output += f"\n(exit code: {result.returncode})"
464
633
  return output or "(no output)"
465
634
  except subprocess.TimeoutExpired:
466
- return "Error: command timed out (30s limit)"
635
+ return "Error: command timed out (60s limit)"
467
636
  except Exception as e:
468
637
  return f"Error running command: {e}"
469
638
 
@@ -511,11 +680,138 @@ def _execute_tool(
511
680
  return "Error: no search query provided"
512
681
  return _web_search(query)
513
682
 
514
- elif tool_name == "cost_check":
683
+ elif tool_name == "web_fetch":
684
+ url = args.get("url", "")
685
+ if not url:
686
+ return "Error: no URL provided"
515
687
  try:
688
+ # Try backend first, fall back to direct fetch
516
689
  from .config import get_settings, get_token
690
+ import httpx as _httpx
691
+ settings = get_settings()
692
+ api_url = settings.api_url.rstrip("/")
693
+ token = get_token()
694
+ cookies = {"msaplingauth": token} if token else {}
695
+ try:
696
+ resp = _httpx.post(
697
+ f"{api_url}/api/tools/web-research",
698
+ json={"url": url}, cookies=cookies, timeout=15.0,
699
+ )
700
+ if resp.status_code == 200:
701
+ content = resp.json().get("content", resp.json().get("text", ""))
702
+ if content and len(content) > 20:
703
+ return content[:8000]
704
+ except Exception:
705
+ pass
706
+ # Direct fetch fallback
707
+ resp = _httpx.get(url, timeout=15.0, follow_redirects=True,
708
+ headers={"User-Agent": "MSapling-CLI/0.2"})
709
+ text = resp.text
710
+ # Strip HTML tags for readability
711
+ text = re.sub(r"<script[^>]*>.*?</script>", "", text, flags=re.DOTALL)
712
+ text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL)
713
+ text = re.sub(r"<[^>]+>", " ", text)
714
+ text = re.sub(r"\s+", " ", text).strip()
715
+ return text[:8000] if text else "No content found"
716
+ except Exception as e:
717
+ return f"Error fetching URL: {e}"
718
+
719
+ elif tool_name == "list_directory":
720
+ dir_path = args.get("path", ".")
721
+ fpath = _safe_path(project_root, dir_path)
722
+ if not fpath:
723
+ return f"Error: path '{dir_path}' is outside project root"
724
+ if not fpath.is_dir():
725
+ return f"Error: not a directory: {dir_path}"
726
+ try:
727
+ skip = {".git", "node_modules", "__pycache__", "venv", ".venv", "dist", "build"}
728
+ entries = []
729
+ for item in sorted(fpath.iterdir()):
730
+ name = item.name
731
+ if name in skip:
732
+ continue
733
+ if item.is_dir():
734
+ entries.append(f" {name}/")
735
+ else:
736
+ size = item.stat().st_size
737
+ size_str = f"{size / 1024:.1f}KB" if size > 1024 else f"{size}B"
738
+ entries.append(f" {name} ({size_str})")
739
+ if len(entries) >= 80:
740
+ entries.append(f" ... (truncated)")
741
+ break
742
+ return "\n".join(entries) if entries else "(empty directory)"
743
+ except Exception as e:
744
+ return f"Error: {e}"
745
+
746
+ elif tool_name == "ask_user":
747
+ question = args.get("question", "")
748
+ if not question:
749
+ return "Error: no question provided"
750
+ try:
751
+ console.print(f"\n[bold yellow]Agent asks:[/bold yellow] {question}")
752
+ answer = console.input("[bold green]Your answer:[/bold green] ")
753
+ return answer.strip() or "(no answer)"
754
+ except (KeyboardInterrupt, EOFError):
755
+ return "(user declined to answer)"
756
+
757
+ elif tool_name == "todo_list":
758
+ action = args.get("action", "list").lower()
759
+ item = args.get("item", "")
760
+ # Module-level todo storage
761
+ global _agent_todos
762
+ if action == "add" and item:
763
+ _agent_todos.append({"text": item, "done": False})
764
+ return f"Added: {item} ({len(_agent_todos)} total)"
765
+ elif action == "done" and item:
766
+ for t in _agent_todos:
767
+ if item.lower() in t["text"].lower() and not t["done"]:
768
+ t["done"] = True
769
+ return f"Completed: {t['text']}"
770
+ return f"Not found: {item}"
771
+ elif action == "clear":
772
+ _agent_todos.clear()
773
+ return "Todo list cleared"
774
+ else:
775
+ if not _agent_todos:
776
+ return "Todo list is empty"
777
+ lines = []
778
+ for i, t in enumerate(_agent_todos, 1):
779
+ mark = "x" if t["done"] else " "
780
+ lines.append(f" [{mark}] {i}. {t['text']}")
781
+ return "\n".join(lines)
782
+
783
+ elif tool_name == "search_replace_all":
784
+ path = args.get("path", "")
785
+ old_string = args.get("old_string", "")
786
+ new_string = args.get("new_string", "")
787
+ fpath = _safe_path(project_root, path)
788
+ if not fpath:
789
+ return f"Error: path '{path}' is outside project root"
790
+ if not fpath.is_file():
791
+ return f"Error: file not found: {path}"
792
+ try:
793
+ content = fpath.read_text(encoding="utf-8", errors="ignore")
794
+ count = content.count(old_string)
795
+ if count == 0:
796
+ return f"Error: string not found in {path}"
797
+ try:
798
+ from .storage import backup_file
799
+ import hashlib
800
+ session_key = hashlib.md5(project_root.encode()).hexdigest()[:12]
801
+ backup_file(path, content, session_key)
802
+ except Exception:
803
+ pass
804
+ new_content = content.replace(old_string, new_string)
805
+ fpath.write_text(new_content, encoding="utf-8")
806
+ _mdrive_auto_push(project_root, path, new_content, project_name)
807
+ return f"Replaced {count} occurrence(s) in {path}"
808
+ except Exception as e:
809
+ return f"Error: {e}"
810
+
811
+ elif tool_name == "cost_check":
812
+ try:
517
813
  from .api import MSaplingClient
518
- import asyncio as _asyncio
814
+ import concurrent.futures
519
815
 
520
816
  async def _check():
521
817
  client = MSaplingClient()
@@ -531,7 +827,11 @@ def _execute_tool(
531
827
  finally:
532
828
  await client.close()
533
829
 
534
- return _asyncio.get_event_loop().run_until_complete(_check())
830
+ # _execute_tool is sync but called from within a running event loop.
831
+ # Use a thread to avoid "event loop already running" RuntimeError.
832
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
833
+ result = pool.submit(asyncio.run, _check()).result(timeout=10)
834
+ return result
535
835
  except Exception as e:
536
836
  return f"Cost check error: {e}"
537
837
 
@@ -566,6 +866,8 @@ async def run_agent_loop(
566
866
  on_text: Any = None,
567
867
  on_usage: Any = None,
568
868
  hooks_mgr: Any = None,
869
+ project_name: str = "",
870
+ chat_id: str = "",
569
871
  ) -> str:
570
872
  """Run the agentic tool-use loop.
571
873
 
@@ -600,9 +902,10 @@ async def run_agent_loop(
600
902
  # Get LLM response
601
903
  parts = []
602
904
  async for chunk in client.stream_chat(
603
- chat_id=str(uuid.uuid4())[:12],
905
+ chat_id=chat_id or str(uuid.uuid4())[:12],
604
906
  prompt=prompt if step == 0 else "",
605
907
  model=model,
908
+ project_name=project_name,
606
909
  history=messages[-30:], # Keep recent context
607
910
  ):
608
911
  content = chunk.get("content", "")
@@ -648,6 +951,7 @@ async def run_agent_loop(
648
951
  result = _execute_tool(
649
952
  tool_name, tool_args, project_root,
650
953
  hooks_mgr=hooks_mgr, model=model,
954
+ project_name=project_name,
651
955
  )
652
956
 
653
957
  # on_after_tool hook