meshcode 2.10.42__tar.gz → 2.10.45__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 (32) hide show
  1. {meshcode-2.10.42 → meshcode-2.10.45}/PKG-INFO +1 -1
  2. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/__init__.py +1 -1
  3. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/comms_v4.py +82 -10
  4. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/meshcode_mcp/server.py +37 -14
  5. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/run_agent.py +162 -15
  6. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/secrets.py +29 -8
  7. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/setup_clients.py +20 -0
  8. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode.egg-info/PKG-INFO +1 -1
  9. {meshcode-2.10.42 → meshcode-2.10.45}/pyproject.toml +1 -1
  10. {meshcode-2.10.42 → meshcode-2.10.45}/README.md +0 -0
  11. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/ascii_art.py +0 -0
  12. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/cli.py +0 -0
  13. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/invites.py +0 -0
  14. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/launcher.py +0 -0
  15. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/launcher_install.py +0 -0
  16. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/meshcode_mcp/__init__.py +0 -0
  17. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/meshcode_mcp/__main__.py +0 -0
  18. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/meshcode_mcp/backend.py +0 -0
  19. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/meshcode_mcp/realtime.py +0 -0
  20. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/meshcode_mcp/test_backend.py +0 -0
  21. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/meshcode_mcp/test_realtime.py +0 -0
  22. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
  23. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/preferences.py +0 -0
  24. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/protocol_v2.py +0 -0
  25. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode/self_update.py +0 -0
  26. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode.egg-info/SOURCES.txt +0 -0
  27. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode.egg-info/dependency_links.txt +0 -0
  28. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode.egg-info/entry_points.txt +0 -0
  29. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode.egg-info/requires.txt +0 -0
  30. {meshcode-2.10.42 → meshcode-2.10.45}/meshcode.egg-info/top_level.txt +0 -0
  31. {meshcode-2.10.42 → meshcode-2.10.45}/setup.cfg +0 -0
  32. {meshcode-2.10.42 → meshcode-2.10.45}/tests/test_status_enum_coverage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 2.10.42
3
+ Version: 2.10.45
4
4
  Summary: Real-time communication between AI agents — Supabase-backed CLI
5
5
  Author-email: MeshCode <hello@meshcode.io>
6
6
  License: MIT
@@ -1,2 +1,2 @@
1
1
  """MeshCode — Real-time communication between AI agents."""
2
- __version__ = "2.10.42"
2
+ __version__ = "2.10.45"
@@ -315,10 +315,14 @@ def mark_nudged(project, name):
315
315
 
316
316
 
317
317
  def _sanitize_notification_text(text: str) -> str:
318
- """Strip characters that could escape shell/PS quoting contexts."""
319
- # Remove quotes, backticks, dollar signs, semicolons, pipes, and backslashes
320
- # to prevent injection in osascript, PowerShell, and notify-send.
321
- return "".join(c for c in text if c not in '"\'`$;|\\<>(){}')
318
+ """Whitelist-sanitize text for safe use in shell notification commands.
319
+
320
+ Only allows alphanumeric, spaces, and safe punctuation. This is a
321
+ whitelist (not blocklist) to prevent injection in osascript, PowerShell,
322
+ and notify-send even if new shell metacharacters are discovered.
323
+ """
324
+ import re
325
+ return re.sub(r'[^a-zA-Z0-9 _\-.,!?:@#/\u00c0-\u024f\u2190-\u21ff]', '', text)[:200]
322
326
 
323
327
 
324
328
  def send_notification(project, name, from_agent, pending=1):
@@ -332,19 +336,20 @@ def send_notification(project, name, from_agent, pending=1):
332
336
  f'display notification "{body}" with title "{title}" sound name "Ping"'],
333
337
  capture_output=True, timeout=3)
334
338
  elif sys.platform == "win32":
335
- # PowerShell: use single-quoted strings (no variable expansion)
336
- # and pass title/body via -replace to avoid any interpolation
339
+ # PowerShell: read title/body from environment variables instead of
340
+ # interpolating into the script string prevents PS injection.
337
341
  ps_script = (
338
342
  '[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null; '
339
343
  '$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent(0); '
340
344
  '$text = $xml.GetElementsByTagName("text"); '
341
- f"$text[0].AppendChild($xml.CreateTextNode('{title}')); "
342
- f"$text[1].AppendChild($xml.CreateTextNode('{body}')); "
345
+ '$text[0].AppendChild($xml.CreateTextNode($env:MC_NOTIFY_TITLE)); '
346
+ '$text[1].AppendChild($xml.CreateTextNode($env:MC_NOTIFY_BODY)); '
343
347
  '$toast = [Windows.UI.Notifications.ToastNotification]::new($xml); '
344
348
  '[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("MeshCode").Show($toast)'
345
349
  )
350
+ env = {**os.environ, "MC_NOTIFY_TITLE": title, "MC_NOTIFY_BODY": body}
346
351
  subprocess.run(['powershell', '-NoProfile', '-Command', ps_script],
347
- capture_output=True, timeout=5)
352
+ capture_output=True, timeout=5, env=env)
348
353
  else:
349
354
  # Linux: notify-send takes title and body as separate args (no shell)
350
355
  subprocess.run(['notify-send', title, body], capture_output=True, timeout=3)
@@ -352,6 +357,48 @@ def send_notification(project, name, from_agent, pending=1):
352
357
  pass
353
358
 
354
359
 
360
+ def _nudge_windows_terminal(name, pending, tty, pid):
361
+ """Activate the agent's terminal window on Windows.
362
+
363
+ Uses PowerShell + kernel32 to find the console window by process ID and
364
+ bring it to the foreground. Does NOT send keystrokes — that would risk
365
+ confirming prompts or interrupting bypass mode. The window activation
366
+ combined with the desktop notification is enough to alert the user.
367
+ """
368
+ if not pid:
369
+ return
370
+ try:
371
+ pid = int(pid)
372
+ except (ValueError, TypeError):
373
+ return
374
+ try:
375
+ ps_script = f'''
376
+ $proc = Get-Process -Id {pid} -ErrorAction SilentlyContinue
377
+ if ($proc -and $proc.MainWindowHandle -ne 0) {{
378
+ Add-Type @"
379
+ using System;
380
+ using System.Runtime.InteropServices;
381
+ public class WinApi {{
382
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
383
+ [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
384
+ }}
385
+ "@
386
+ [WinApi]::ShowWindow($proc.MainWindowHandle, 9)
387
+ [WinApi]::SetForegroundWindow($proc.MainWindowHandle)
388
+ }}
389
+ '''
390
+ subprocess.run(
391
+ ["powershell", "-NoProfile", "-Command", ps_script],
392
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
393
+ timeout=10,
394
+ )
395
+ log_msg(f"[nudge] Windows: activated terminal for {name} (pid={pid})")
396
+ except subprocess.TimeoutExpired:
397
+ log_msg(f"[nudge] Windows: PowerShell timed out for {name}")
398
+ except Exception as e:
399
+ log_msg(f"[nudge] Windows: failed for {name}: {e}")
400
+
401
+
355
402
  def _is_pid_alive(pid):
356
403
  """Return True iff the given pid corresponds to a live process."""
357
404
  if not pid:
@@ -796,8 +843,10 @@ def nudge_agent(project, name, from_agent=""):
796
843
  message = f"Tienes {pending} mensaje(s). Revisa: meshcode read {project} {name}"
797
844
  escaped_message = message.replace('\\', '\\\\').replace('"', '\\"')
798
845
 
799
- # Only attempt AppleScript nudge on macOS
846
+ # Non-macOS: use platform-specific terminal nudge
800
847
  if sys.platform != "darwin":
848
+ if sys.platform == "win32":
849
+ _nudge_windows_terminal(name, pending, tty, cached_pid)
801
850
  send_notification(project, name, from_agent, pending)
802
851
  mark_nudged(project, name)
803
852
  return True
@@ -2691,12 +2740,35 @@ if __name__ == "__main__":
2691
2740
  cur = get_permission_mode()
2692
2741
  print(f"permission_mode: {cur or '(unset — will prompt on next run)'}")
2693
2742
  sys.exit(0)
2743
+ elif sub == "claude-version":
2744
+ from meshcode.preferences import load_prefs, save_prefs
2745
+ if len(sys.argv) > 3:
2746
+ ver = sys.argv[3].strip()
2747
+ if ver in ("reset", "unpin", "latest"):
2748
+ prefs = load_prefs()
2749
+ prefs.pop("claude_version", None)
2750
+ save_prefs(prefs)
2751
+ print("[meshcode] Claude Code version pin removed — will use installed version")
2752
+ else:
2753
+ prefs = load_prefs()
2754
+ prefs["claude_version"] = ver
2755
+ save_prefs(prefs)
2756
+ print(f"[meshcode] Claude Code version pinned to: {ver}")
2757
+ print(f"[meshcode] meshcode run will use npx @anthropic-ai/claude-code@{ver}")
2758
+ sys.exit(0)
2759
+ else:
2760
+ from meshcode.preferences import load_prefs
2761
+ cur = load_prefs().get("claude_version")
2762
+ print(f"claude_version: {cur or '(not pinned — using installed version)'}")
2763
+ sys.exit(0)
2694
2764
  elif sub == "reset":
2695
2765
  reset_permission_mode()
2696
2766
  print("[meshcode] preferences reset")
2697
2767
  sys.exit(0)
2698
2768
  else:
2699
2769
  print("Usage: meshcode prefs permission-mode [bypass|safe|ask]")
2770
+ print(" meshcode prefs claude-version [version|reset]")
2771
+ print(" meshcode prefs auto-update [on|off|reset]")
2700
2772
  print(" meshcode prefs reset")
2701
2773
  sys.exit(1)
2702
2774
 
@@ -108,14 +108,14 @@ _SEEN_TTL = 300.0 # 5 minutes
108
108
  # ============================================================
109
109
  # Auto-wake: when agent is NOT in meshcode_wait and a message
110
110
  # arrives, inject text into the terminal to wake the agent.
111
- # DEFAULT: ON. Disable with MESHCODE_AUTO_WAKE=0 if you don't want it.
112
111
  # ============================================================
113
112
  _IN_WAIT = False # True while meshcode_wait is blocking
114
- # Default OFF as of 2.10.27 the AppleScript/PowerShell keystroke injection
115
- # wrote nudge text directly into the user's terminal, which corrupts stdin on
116
- # terminals that don't interpret ANSI, interrupts the user mid-typing, and
117
- # duplicates what the dashboard already surfaces. Opt-in with
118
- # MESHCODE_AUTO_WAKE=1 for users who want the legacy behavior.
113
+ # Default OFF keystroke injection can corrupt stdin on some terminals.
114
+ # The primary nudge path is now in comms_v4.nudge_agent() which uses
115
+ # platform-specific window activation (SetForegroundWindow on Windows,
116
+ # AppleScript on macOS) + desktop notification.
117
+ # Set MESHCODE_AUTO_WAKE=1 to also inject text into the terminal from
118
+ # the MCP server side (useful if comms_v4 nudge doesn't reach the agent).
119
119
  _AUTO_WAKE = os.environ.get("MESHCODE_AUTO_WAKE", "0").lower() in ("1", "true", "yes")
120
120
 
121
121
 
@@ -174,10 +174,12 @@ def _try_auto_wake(from_agent: str, preview: str) -> None:
174
174
  system = platform.system()
175
175
  try:
176
176
  if system == "Darwin":
177
- # Use xdotool-style approach: pass nudge as argument, not interpolated script
177
+ # Sanitize app_name TERM_PROGRAM is an env var, not DB-sourced,
178
+ # but defense-in-depth: only allow known terminal app names.
178
179
  parent_app = os.environ.get("TERM_PROGRAM", "Terminal")
179
180
  app_name = "iTerm" if "iTerm" in parent_app else "Terminal"
180
- # Escape for AppleScript string: replace backslash and double-quote
181
+ # Escape for AppleScript string: replace backslash and double-quote.
182
+ # nudge is already whitelist-sanitized (alphanum + _-.,!? space only).
181
183
  as_safe = nudge.replace("\\", "\\\\").replace('"', '\\"')
182
184
  script = f'''
183
185
  tell application "{app_name}"
@@ -191,14 +193,16 @@ def _try_auto_wake(from_agent: str, preview: str) -> None:
191
193
  stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
192
194
  log.info(f"auto-wake: injected nudge via {app_name} AppleScript")
193
195
  elif system == "Windows":
194
- # Sanitize for SendKeys: strip braces and special SendKeys metacharacters
195
- sk_safe = nudge.replace("{", "").replace("}", "").replace("+", "").replace("^", "").replace("%", "").replace("~", "")
196
- ps_script = f'''
196
+ # Pass nudge text via environment variable instead of interpolating
197
+ # into a PowerShell string prevents any PS injection.
198
+ ps_script = '''
197
199
  Add-Type -AssemblyName System.Windows.Forms
198
- [System.Windows.Forms.SendKeys]::SendWait("{sk_safe}{{ENTER}}")
200
+ [System.Windows.Forms.SendKeys]::SendWait($env:MC_NUDGE_TEXT + "{ENTER}")
199
201
  '''
200
- subprocess.Popen(["powershell", "-Command", ps_script],
201
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
202
+ env = {**os.environ, "MC_NUDGE_TEXT": nudge}
203
+ subprocess.run(["powershell", "-NoProfile", "-Command", ps_script],
204
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
205
+ timeout=10, env=env)
202
206
  log.info("auto-wake: injected nudge via PowerShell SendKeys")
203
207
  else:
204
208
  # Linux — xdotool takes args directly, not shell-interpolated (safe)
@@ -3026,6 +3030,25 @@ def meshcode_recall_search(query: str) -> Dict[str, Any]:
3026
3030
  })
3027
3031
 
3028
3032
 
3033
+ # ----------------- BUG REPORTING -----------------
3034
+
3035
+ @mcp.tool()
3036
+ @with_working_status
3037
+ def meshcode_report_bug(description: str) -> Dict[str, Any]:
3038
+ """Report a bug from within the mesh. Visible in admin panel.
3039
+
3040
+ Args:
3041
+ description: What went wrong — include steps to reproduce if possible.
3042
+ """
3043
+ api_key = _get_api_key()
3044
+ return be.sb_rpc("mc_report_bug", {
3045
+ "p_api_key": api_key,
3046
+ "p_description": description,
3047
+ "p_meshwork_name": PROJECT_NAME,
3048
+ "p_agent_name": AGENT_NAME,
3049
+ })
3050
+
3051
+
3029
3052
  # ----------------- HEALTH CHECK -----------------
3030
3053
 
3031
3054
  @mcp.tool()
@@ -351,6 +351,106 @@ def _list_local_projects_for_agent(agent: str) -> list:
351
351
  return out
352
352
 
353
353
 
354
+ STABLE_CLAUDE_VERSION = "2.1.104" # Known-good version: ESC does NOT kill MCP
355
+
356
+
357
+ def _get_claude_version(editor_cmd: str) -> Optional[str]:
358
+ """Get the installed Claude Code version. Returns None on failure."""
359
+ try:
360
+ use_shell = sys.platform == "win32" and editor_cmd.lower().endswith((".cmd", ".bat"))
361
+ r = subprocess.run(
362
+ [editor_cmd, "--version"],
363
+ capture_output=True, text=True, timeout=10,
364
+ shell=use_shell,
365
+ )
366
+ # Output is typically just the version string, e.g. "2.1.104"
367
+ version = r.stdout.strip().split('\n')[0].strip()
368
+ if version:
369
+ return version
370
+ except Exception:
371
+ pass
372
+ return None
373
+
374
+
375
+ def _fetch_global_claude_version() -> Optional[str]:
376
+ """Fetch the admin-pinned Claude Code version from mc_global_config.
377
+
378
+ Uses the user's API key to authenticate the RPC call. Returns None
379
+ on any failure (no network, no key, etc.) — caller falls back to
380
+ local preferences or installed version.
381
+ """
382
+ try:
383
+ from .secrets import get_api_key
384
+ api_key = get_api_key()
385
+ if not api_key:
386
+ return None
387
+ from .setup_clients import _load_supabase_env
388
+ env = _load_supabase_env()
389
+ url = env.get("SUPABASE_URL", "")
390
+ anon_key = env.get("SUPABASE_KEY", "")
391
+ if not url or not anon_key:
392
+ return None
393
+ from urllib.request import Request, urlopen
394
+ import json as _json
395
+ req = Request(
396
+ f"{url}/rest/v1/rpc/mc_get_global_config_by_key",
397
+ data=_json.dumps({"p_api_key": api_key, "p_config_key": "claude_version"}).encode(),
398
+ headers={
399
+ "Content-Type": "application/json",
400
+ "apikey": anon_key,
401
+ "Authorization": f"Bearer {anon_key}",
402
+ },
403
+ )
404
+ with urlopen(req, timeout=5) as resp:
405
+ raw = _json.loads(resp.read())
406
+ # RPC returns the JSONB value directly — could be a string or object
407
+ if isinstance(raw, str) and raw not in ("latest", ""):
408
+ return raw
409
+ except Exception:
410
+ pass
411
+ return None
412
+
413
+
414
+ def _resolve_pinned_claude(editor_cmd: str) -> str:
415
+ """Check if a specific Claude Code version is pinned and resolve the command.
416
+
417
+ Version pin sources (first wins):
418
+ 1. MESHCODE_CLAUDE_VERSION env var (per-launch override)
419
+ 2. mc_global_config 'claude_version' from DB (admin-controlled)
420
+ 3. claude_version in ~/.meshcode/preferences.json (local fallback)
421
+ 4. No pin — use whatever is installed
422
+
423
+ If a pin is set and the installed version doesn't match, uses npx to
424
+ run the exact pinned version.
425
+ """
426
+ from .preferences import load_prefs
427
+
428
+ pinned = os.environ.get("MESHCODE_CLAUDE_VERSION", "").strip()
429
+ if not pinned:
430
+ # Check global admin config from DB
431
+ global_ver = _fetch_global_claude_version()
432
+ if global_ver:
433
+ pinned = global_ver
434
+ print(f"[meshcode] Global admin pin: Claude Code v{pinned}", file=sys.stderr)
435
+ if not pinned:
436
+ pinned = load_prefs().get("claude_version", "")
437
+ if not pinned:
438
+ return editor_cmd # No pin — use installed version
439
+
440
+ current = _get_claude_version(editor_cmd)
441
+ if current == pinned:
442
+ print(f"[meshcode] Claude Code version {current} matches pin ✓", file=sys.stderr)
443
+ return editor_cmd
444
+
445
+ print(f"[meshcode] Version pin: {pinned} (installed: {current or 'unknown'})", file=sys.stderr)
446
+ print(f"[meshcode] Using npx to run pinned version...", file=sys.stderr)
447
+
448
+ # Use npx to run the exact pinned version. npx will download it if needed.
449
+ # We set an env var so the launch path knows to prepend npx args.
450
+ os.environ["_MESHCODE_NPX_CLAUDE_VERSION"] = pinned
451
+ return editor_cmd # Still return the editor — launch path handles npx
452
+
453
+
354
454
  def _claude_supports_bypass(editor_cmd: str) -> bool:
355
455
  """Check if the Claude Code binary supports --dangerously-skip-permissions.
356
456
 
@@ -363,13 +463,30 @@ def _claude_supports_bypass(editor_cmd: str) -> bool:
363
463
  """
364
464
  try:
365
465
  use_shell = sys.platform == "win32" and editor_cmd.lower().endswith((".cmd", ".bat"))
466
+ print(f"[meshcode] bypass-detect: checking '{editor_cmd}' (shell={use_shell})", file=sys.stderr)
366
467
  r = subprocess.run(
367
468
  [editor_cmd, "--help"],
368
469
  capture_output=True, text=True, timeout=10,
369
470
  shell=use_shell,
370
471
  )
371
- return "--dangerously-skip-permissions" in r.stdout
372
- except Exception:
472
+ found = "--dangerously-skip-permissions" in r.stdout
473
+ if found:
474
+ print(f"[meshcode] bypass-detect: ✓ flag found — bypass mode available", file=sys.stderr)
475
+ else:
476
+ stdout_len = len(r.stdout)
477
+ stderr_preview = (r.stderr or "")[:200]
478
+ print(f"[meshcode] bypass-detect: ✗ flag NOT found in --help output ({stdout_len} chars, exit={r.returncode})", file=sys.stderr)
479
+ if stderr_preview:
480
+ print(f"[meshcode] bypass-detect: stderr: {stderr_preview}", file=sys.stderr)
481
+ return found
482
+ except subprocess.TimeoutExpired:
483
+ print(f"[meshcode] bypass-detect: ✗ '{editor_cmd} --help' timed out after 10s", file=sys.stderr)
484
+ return False
485
+ except FileNotFoundError:
486
+ print(f"[meshcode] bypass-detect: ✗ '{editor_cmd}' not found in PATH", file=sys.stderr)
487
+ return False
488
+ except Exception as e:
489
+ print(f"[meshcode] bypass-detect: ✗ unexpected error: {e}", file=sys.stderr)
373
490
  return False
374
491
 
375
492
 
@@ -377,17 +494,22 @@ def _detect_editor() -> Optional[str]:
377
494
  """Pick the user's preferred MCP-aware editor."""
378
495
  override = os.environ.get("MESHCODE_EDITOR", "").strip().lower()
379
496
  if override:
380
- if shutil.which(override):
381
- return override
497
+ resolved = shutil.which(override)
498
+ if resolved:
499
+ # On Windows, return resolved path to preserve .cmd/.bat extension
500
+ return resolved if sys.platform == "win32" else override
382
501
  print(f"[meshcode] WARNING: MESHCODE_EDITOR='{override}' not found in PATH", file=sys.stderr)
383
502
 
384
503
  # Detection order: most likely to be installed + best per-workspace MCP
385
504
  # support first. codex is last because its MCP config is GLOBAL only —
386
505
  # launching it doesn't take a workspace-scoped .mcp.json, so it should
387
506
  # only be picked if nothing else is available.
507
+ # On Windows, return the fully-resolved path so .cmd/.bat extension is
508
+ # preserved — needed for shell=True detection in bypass and launch paths.
388
509
  for cmd in ("claude", "cursor", "code", "windsurf", "codex"):
389
- if shutil.which(cmd):
390
- return cmd
510
+ resolved = shutil.which(cmd)
511
+ if resolved:
512
+ return resolved if sys.platform == "win32" else cmd
391
513
 
392
514
  # Windows fallback: check common install locations not in PATH
393
515
  if sys.platform == "win32":
@@ -641,10 +763,14 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
641
763
  print(f"[meshcode] Closing the editor will flip this agent offline.")
642
764
  print()
643
765
 
766
+ # On Windows, editor is the fully-resolved path (e.g. 'c:\...\claude.cmd')
767
+ # so we compare against the stem (filename without extension).
768
+ editor_stem = Path(editor).stem.lower() if sys.platform == "win32" else editor
769
+
644
770
  # Set editor type so MCP server can report it to the dashboard
645
- os.environ["MESHCODE_EDITOR_TYPE"] = editor
771
+ os.environ["MESHCODE_EDITOR_TYPE"] = editor_stem
646
772
 
647
- if editor == "claude":
773
+ if editor_stem == "claude":
648
774
  # Claude Code: run from inside the workspace and let Claude Code
649
775
  # auto-discover .mcp.json as a PROJECT-scoped MCP. We used to pass
650
776
  # --mcp-config + --strict-mcp-config here, but that categorises the
@@ -654,10 +780,24 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
654
780
  # scoped MCPs (loaded from ./.mcp.json at cwd) live in the "Local
655
781
  # MCP" bucket and survive ESC. Verified with a minimal FastMCP
656
782
  # repro side-by-side: Built-in dies, Local survives, same SDK code.
783
+ # Check for version pin before building launch command
784
+ effective_editor = _resolve_pinned_claude(editor)
785
+ pinned_version = os.environ.get("_MESHCODE_NPX_CLAUDE_VERSION", "")
786
+
657
787
  mode = resolve_permission_mode(permission_override)
658
- cmd = [editor]
788
+ print(f"[meshcode] Editor resolved: {editor}", file=sys.stderr)
789
+ print(f"[meshcode] Permission mode resolved: {mode}", file=sys.stderr)
790
+
791
+ if pinned_version:
792
+ # Use npx to run the exact pinned version
793
+ cmd = ["npx", f"@anthropic-ai/claude-code@{pinned_version}"]
794
+ print(f"[meshcode] Using pinned version via npx: {pinned_version}")
795
+ else:
796
+ cmd = [effective_editor]
797
+
659
798
  if mode == "bypass":
660
- if _claude_supports_bypass(editor):
799
+ bypass_check_cmd = effective_editor if not pinned_version else "claude"
800
+ if pinned_version or _claude_supports_bypass(bypass_check_cmd):
661
801
  cmd.append("--dangerously-skip-permissions")
662
802
  print(f"[meshcode] Permission mode: bypass (autonomous loop, no prompts)")
663
803
  else:
@@ -678,17 +818,17 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
678
818
  os.chdir(ws)
679
819
  except Exception as e:
680
820
  print(f"[meshcode] WARNING: chdir to workspace failed: {e}", file=sys.stderr)
681
- elif editor == "cursor":
821
+ elif editor_stem == "cursor":
682
822
  # Cursor reads .cursor/mcp.json from the workspace cwd.
683
823
  cmd = [editor, str(ws)]
684
- elif editor == "code":
824
+ elif editor_stem == "code":
685
825
  # VS Code (Cline reads .vscode/mcp.json from workspace cwd).
686
826
  cmd = [editor, str(ws)]
687
- elif editor == "windsurf":
827
+ elif editor_stem == "windsurf":
688
828
  # Windsurf (Codeium fork of VS Code) — reads workspace cwd the same
689
829
  # way VS Code does; global MCP config at ~/.codeium/windsurf/mcp_config.json.
690
830
  cmd = [editor, str(ws)]
691
- elif editor == "codex":
831
+ elif editor_stem == "codex":
692
832
  # Codex CLI reads ~/.codex/config.toml globally. There's no per-
693
833
  # workspace flag, so launching is just `codex` with cwd set to the
694
834
  # workspace dir so any file ops the agent does land there.
@@ -745,7 +885,14 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
745
885
  # workspace even if os.chdir() didn't stick (shell=True can
746
886
  # reset cwd via cmd.exe).
747
887
  import subprocess as _sp
748
- use_shell = cmd[0].lower().endswith((".cmd", ".bat"))
888
+ # .cmd/.bat files need shell=True. Also check if the resolved
889
+ # path of cmd[0] is a .cmd (e.g., "npx" resolves to "npx.cmd").
890
+ cmd0 = cmd[0].lower()
891
+ use_shell = cmd0.endswith((".cmd", ".bat"))
892
+ if not use_shell:
893
+ resolved_cmd0 = shutil.which(cmd[0])
894
+ if resolved_cmd0 and resolved_cmd0.lower().endswith((".cmd", ".bat")):
895
+ use_shell = True
749
896
  if use_shell:
750
897
  print(f"[meshcode] (Windows: launching via shell for .cmd wrapper)")
751
898
  launch_cwd = str(ws) if os.path.isdir(ws) else None
@@ -47,6 +47,33 @@ DEFAULT_PROFILE = "default"
47
47
  LEGACY_CREDS_FILE = Path.home() / ".meshcode" / "credentials.json"
48
48
 
49
49
 
50
+ # ============================================================
51
+ # File permission helpers
52
+ # ============================================================
53
+
54
+
55
+ def _restrict_perms(path: "Path") -> None:
56
+ """Restrict file to owner-only access on all platforms.
57
+
58
+ On Windows, os.chmod(0o600) is a no-op — must use icacls to remove
59
+ inherited ACLs and grant only the current user Full Control.
60
+ """
61
+ try:
62
+ if sys.platform == "win32":
63
+ import subprocess
64
+ username = os.environ.get("USERNAME", os.environ.get("USER", ""))
65
+ if username:
66
+ subprocess.run(
67
+ ["icacls", str(path), "/inheritance:r",
68
+ "/grant:r", f"{username}:(F)"],
69
+ capture_output=True, timeout=5,
70
+ )
71
+ else:
72
+ os.chmod(path, 0o600)
73
+ except Exception:
74
+ pass
75
+
76
+
50
77
  # ============================================================
51
78
  # Keyring backend probe
52
79
  # ============================================================
@@ -129,10 +156,7 @@ def _save_index(idx: Dict[str, Any]) -> None:
129
156
  PROFILE_INDEX.parent.mkdir(parents=True, exist_ok=True)
130
157
  tmp = PROFILE_INDEX.with_suffix(".tmp")
131
158
  tmp.write_text(json.dumps(idx, indent=2), encoding="utf-8")
132
- try:
133
- os.chmod(tmp, 0o600)
134
- except Exception:
135
- pass
159
+ _restrict_perms(tmp)
136
160
  tmp.replace(PROFILE_INDEX)
137
161
 
138
162
 
@@ -182,10 +206,7 @@ def set_api_key(api_key: str, profile: str = DEFAULT_PROFILE,
182
206
  fallback_path = Path.home() / ".meshcode" / f"credentials.{profile}.json"
183
207
  fallback_path.parent.mkdir(parents=True, exist_ok=True)
184
208
  fallback_path.write_text(json.dumps({"api_key": api_key, **(meta or {})}, indent=2), encoding="utf-8")
185
- try:
186
- os.chmod(fallback_path, 0o600)
187
- except Exception:
188
- pass
209
+ _restrict_perms(fallback_path)
189
210
  _index_set(profile, {"backend": "file-fallback", **(meta or {})})
190
211
  return True
191
212
 
@@ -204,11 +204,31 @@ def _build_server_block(project: str, project_id: str, agent: str, role: str,
204
204
  }
205
205
 
206
206
 
207
+ def _restrict_perms(path: Path) -> None:
208
+ """Restrict file to owner-only access (Windows-aware)."""
209
+ try:
210
+ if sys.platform == "win32":
211
+ import subprocess
212
+ username = os.environ.get("USERNAME", os.environ.get("USER", ""))
213
+ if username:
214
+ subprocess.run(
215
+ ["icacls", str(path), "/inheritance:r",
216
+ "/grant:r", f"{username}:(F)"],
217
+ capture_output=True, timeout=5,
218
+ )
219
+ else:
220
+ os.chmod(path, 0o600)
221
+ except Exception:
222
+ pass
223
+
224
+
207
225
  def _atomic_write_json(path: Path, data: dict) -> None:
208
226
  path.parent.mkdir(parents=True, exist_ok=True)
209
227
  tmp = path.with_suffix(path.suffix + ".tmp")
210
228
  tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
229
+ _restrict_perms(tmp)
211
230
  tmp.replace(path)
231
+ _restrict_perms(path)
212
232
 
213
233
 
214
234
  def _toml_escape(s: str) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 2.10.42
3
+ Version: 2.10.45
4
4
  Summary: Real-time communication between AI agents — Supabase-backed CLI
5
5
  Author-email: MeshCode <hello@meshcode.io>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "meshcode"
7
- version = "2.10.42"
7
+ version = "2.10.45"
8
8
  description = "Real-time communication between AI agents — Supabase-backed CLI"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
File without changes
File without changes
File without changes