meshcode 2.10.41__tar.gz → 2.10.42__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.41 → meshcode-2.10.42}/PKG-INFO +1 -1
  2. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/__init__.py +1 -1
  3. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/comms_v4.py +26 -12
  4. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/meshcode_mcp/backend.py +144 -20
  5. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/meshcode_mcp/server.py +118 -25
  6. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/preferences.py +22 -5
  7. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/run_agent.py +159 -12
  8. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode.egg-info/PKG-INFO +1 -1
  9. {meshcode-2.10.41 → meshcode-2.10.42}/pyproject.toml +1 -1
  10. {meshcode-2.10.41 → meshcode-2.10.42}/README.md +0 -0
  11. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/ascii_art.py +0 -0
  12. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/cli.py +0 -0
  13. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/invites.py +0 -0
  14. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/launcher.py +0 -0
  15. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/launcher_install.py +0 -0
  16. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/meshcode_mcp/__init__.py +0 -0
  17. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/meshcode_mcp/__main__.py +0 -0
  18. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/meshcode_mcp/realtime.py +0 -0
  19. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/meshcode_mcp/test_backend.py +0 -0
  20. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/meshcode_mcp/test_realtime.py +0 -0
  21. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
  22. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/protocol_v2.py +0 -0
  23. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/secrets.py +0 -0
  24. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/self_update.py +0 -0
  25. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/setup_clients.py +0 -0
  26. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode.egg-info/SOURCES.txt +0 -0
  27. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode.egg-info/dependency_links.txt +0 -0
  28. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode.egg-info/entry_points.txt +0 -0
  29. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode.egg-info/requires.txt +0 -0
  30. {meshcode-2.10.41 → meshcode-2.10.42}/meshcode.egg-info/top_level.txt +0 -0
  31. {meshcode-2.10.41 → meshcode-2.10.42}/setup.cfg +0 -0
  32. {meshcode-2.10.41 → meshcode-2.10.42}/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.41
3
+ Version: 2.10.42
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.41"
2
+ __version__ = "2.10.42"
@@ -74,7 +74,11 @@ SUPABASE_KEY = os.environ.get("SUPABASE_KEY") or _env_file.get("SUPABASE_KEY") o
74
74
  SCHEMA = "meshcode"
75
75
 
76
76
  # Local paths for session/TTY tracking (still needed for nudge)
77
- COMMS_DIR = Path(__file__).parent
77
+ # NEVER use Path(__file__).parent — writing into the package directory
78
+ # creates residual files that turn site-packages/meshcode/ into a
79
+ # namespace package, breaking editable installs and __version__ imports.
80
+ COMMS_DIR = Path.home() / ".meshcode"
81
+ COMMS_DIR.mkdir(parents=True, exist_ok=True)
78
82
  SESSIONS_DIR = COMMS_DIR / "sessions"
79
83
  LOG_FILE = COMMS_DIR / "comms.log"
80
84
 
@@ -310,29 +314,39 @@ def mark_nudged(project, name):
310
314
  nf.write_text(str(time.time()), encoding="utf-8")
311
315
 
312
316
 
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 '"\'`$;|\\<>(){}')
322
+
323
+
313
324
  def send_notification(project, name, from_agent, pending=1):
314
325
  """Cross-platform notification: macOS (osascript), Windows (PowerShell toast), Linux (notify-send)."""
315
- title = f"MeshCode [{project}] → {name}"
316
- body = f"{pending} mensaje(s) pendiente(s) de {from_agent}"
326
+ title = _sanitize_notification_text(f"MeshCode [{project}] → {name}")
327
+ body = _sanitize_notification_text(f"{pending} mensaje(s) pendiente(s) de {from_agent}")
317
328
  try:
318
329
  if sys.platform == "darwin":
330
+ # osascript: pass as -e argument, quotes are stripped by sanitizer
319
331
  subprocess.run(['osascript', '-e',
320
332
  f'display notification "{body}" with title "{title}" sound name "Ping"'],
321
333
  capture_output=True, timeout=3)
322
334
  elif sys.platform == "win32":
335
+ # PowerShell: use single-quoted strings (no variable expansion)
336
+ # and pass title/body via -replace to avoid any interpolation
323
337
  ps_script = (
324
- f'[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null; '
325
- f'$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent(0); '
326
- f'$text = $xml.GetElementsByTagName("text"); '
327
- f'$text[0].AppendChild($xml.CreateTextNode("{title}")); '
328
- f'$text[1].AppendChild($xml.CreateTextNode("{body}")); '
329
- f'$toast = [Windows.UI.Notifications.ToastNotification]::new($xml); '
330
- f'[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("MeshCode").Show($toast)'
338
+ '[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null; '
339
+ '$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent(0); '
340
+ '$text = $xml.GetElementsByTagName("text"); '
341
+ f"$text[0].AppendChild($xml.CreateTextNode('{title}')); "
342
+ f"$text[1].AppendChild($xml.CreateTextNode('{body}')); "
343
+ '$toast = [Windows.UI.Notifications.ToastNotification]::new($xml); '
344
+ '[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("MeshCode").Show($toast)'
331
345
  )
332
- subprocess.run(['powershell', '-Command', ps_script],
346
+ subprocess.run(['powershell', '-NoProfile', '-Command', ps_script],
333
347
  capture_output=True, timeout=5)
334
348
  else:
335
- # Linux
349
+ # Linux: notify-send takes title and body as separate args (no shell)
336
350
  subprocess.run(['notify-send', title, body], capture_output=True, timeout=3)
337
351
  except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
338
352
  pass
@@ -23,49 +23,114 @@ from urllib.request import Request, urlopen
23
23
  # ── Circuit Breaker ──────────────────────────────────────────────
24
24
  # Protects against cascading failures when Supabase is down.
25
25
  # States: CLOSED (normal) → OPEN (reject fast) → HALF_OPEN (probe)
26
+ #
27
+ # v2 fixes (2026-04-18):
28
+ # 1. Rolling failure window — only recent failures count toward threshold
29
+ # 2. Exponential backoff — half-open probe failure doubles recovery timeout
30
+ # 3. Max open duration — force-resets after 5 min regardless of probe results
31
+ # 4. Pool flush on recovery — clears stale sockets before half-open probe
32
+ # 5. sb_rpc records ONE failure per call, not one per retry attempt
26
33
  class _CircuitBreaker:
27
34
  CLOSED = "closed"
28
35
  OPEN = "open"
29
36
  HALF_OPEN = "half_open"
30
37
 
31
- def __init__(self, failure_threshold: int = 5, recovery_timeout: float = 30.0):
38
+ def __init__(self, failure_threshold: int = 5, recovery_timeout: float = 10.0,
39
+ max_recovery_timeout: float = 60.0, window_seconds: float = 60.0,
40
+ max_open_seconds: float = 300.0):
32
41
  self.failure_threshold = failure_threshold
33
- self.recovery_timeout = recovery_timeout
42
+ self._base_recovery_timeout = recovery_timeout
43
+ self._max_recovery_timeout = max_recovery_timeout
44
+ self._window_seconds = window_seconds
45
+ self._max_open_seconds = max_open_seconds
34
46
  self.state = self.CLOSED
35
- self.failure_count = 0
47
+ self._failures: list = []
48
+ self._current_recovery_timeout = recovery_timeout
49
+ self._opened_at = 0.0
36
50
  self.last_failure_time = 0.0
37
51
  self._lock = _threading.Lock()
52
+ self._pool_ref = None
53
+
54
+ def set_pool(self, pool):
55
+ self._pool_ref = pool
56
+
57
+ def _prune_old_failures(self):
58
+ cutoff = _time.monotonic() - self._window_seconds
59
+ self._failures = [t for t in self._failures if t > cutoff]
60
+
61
+ def _flush_pool(self):
62
+ if self._pool_ref:
63
+ try:
64
+ self._pool_ref.close()
65
+ except Exception:
66
+ pass
38
67
 
39
68
  def can_execute(self) -> bool:
40
69
  with self._lock:
41
70
  if self.state == self.CLOSED:
42
71
  return True
72
+ now = _time.monotonic()
43
73
  if self.state == self.OPEN:
44
- if _time.monotonic() - self.last_failure_time >= self.recovery_timeout:
74
+ if now - self._opened_at >= self._max_open_seconds:
75
+ log.warning("circuit breaker: max open duration (%ds) reached — force half-open",
76
+ int(self._max_open_seconds))
45
77
  self.state = self.HALF_OPEN
78
+ self._failures.clear()
79
+ self._current_recovery_timeout = self._base_recovery_timeout
80
+ self._flush_pool()
81
+ return True
82
+ if now - self.last_failure_time >= self._current_recovery_timeout:
83
+ log.info("circuit breaker: recovery timeout elapsed — half-open probe")
84
+ self.state = self.HALF_OPEN
85
+ self._flush_pool()
46
86
  return True
47
87
  return False
48
- # HALF_OPEN: allow one probe
49
88
  return True
50
89
 
51
90
  def record_success(self) -> None:
52
91
  with self._lock:
53
- self.failure_count = 0
92
+ prev = self.state
93
+ self._failures.clear()
54
94
  self.state = self.CLOSED
95
+ self._current_recovery_timeout = self._base_recovery_timeout
96
+ if prev != self.CLOSED:
97
+ log.info("circuit breaker: recovered — back to CLOSED")
55
98
 
56
99
  def record_failure(self) -> None:
57
100
  with self._lock:
58
- self.failure_count += 1
59
- self.last_failure_time = _time.monotonic()
60
- if self.failure_count >= self.failure_threshold:
101
+ now = _time.monotonic()
102
+ self._failures.append(now)
103
+ self.last_failure_time = now
104
+ self._prune_old_failures()
105
+ if self.state == self.HALF_OPEN:
106
+ self.state = self.OPEN
107
+ self._opened_at = now
108
+ self._current_recovery_timeout = min(
109
+ self._current_recovery_timeout * 2,
110
+ self._max_recovery_timeout
111
+ )
112
+ log.warning("circuit breaker: half-open probe failed — OPEN (next probe in %ds)",
113
+ int(self._current_recovery_timeout))
114
+ elif len(self._failures) >= self.failure_threshold:
61
115
  self.state = self.OPEN
116
+ self._opened_at = now
117
+ log.warning("circuit breaker: %d failures in %ds window — OPEN",
118
+ len(self._failures), int(self._window_seconds))
62
119
 
63
120
  @property
64
121
  def is_open(self) -> bool:
65
122
  return self.state == self.OPEN
66
123
 
124
+ @property
125
+ def failure_count(self) -> int:
126
+ with self._lock:
127
+ self._prune_old_failures()
128
+ return len(self._failures)
129
+
67
130
 
68
- _circuit = _CircuitBreaker(failure_threshold=5, recovery_timeout=10.0)
131
+ _circuit = _CircuitBreaker(failure_threshold=5, recovery_timeout=10.0,
132
+ max_recovery_timeout=60.0, window_seconds=60.0,
133
+ max_open_seconds=300.0)
69
134
 
70
135
  # Bake in production defaults — RLS-protected publishable key, safe to ship.
71
136
  _DEFAULT_SUPABASE_URL = "https://gjinagyyjttyxnaoavnz.supabase.co"
@@ -180,6 +245,7 @@ class _ConnectionPool:
180
245
 
181
246
 
182
247
  _pool = _ConnectionPool(SUPABASE_URL, pool_size=3)
248
+ _circuit.set_pool(_pool)
183
249
 
184
250
 
185
251
  def _now_iso() -> str:
@@ -309,16 +375,15 @@ def sb_rpc(fn_name: str, params: Dict, *, _max_retries: int = 3) -> Any:
309
375
  _bg_record("error", {"rpc": fn_name, "error": raw[:200]})
310
376
  return result
311
377
  else:
312
- _circuit.record_failure()
313
378
  last_err = raw[:200]
314
379
  except (URLError, OSError, TimeoutError, http.client.HTTPException) as e:
315
- _circuit.record_failure()
316
380
  last_err = str(getattr(e, 'reason', e))
317
381
  # Retry with jitter for transient errors (5xx, network)
318
382
  if attempt < _max_retries - 1:
319
383
  delay = (2 ** attempt) + _random.uniform(0, 1)
320
384
  _time.sleep(delay)
321
- # All retries exhausted
385
+ # All retries exhausted — record ONE failure for the entire call
386
+ _circuit.record_failure()
322
387
  if _recording_enabled and fn_name not in _SKIP_RECORDING:
323
388
  _bg_record("error", {"rpc": fn_name, "error": str(last_err)[:200], "retries_exhausted": True})
324
389
  return {"_error": str(last_err)[:200] if last_err else "request failed after retries"}
@@ -409,34 +474,75 @@ def register_agent(project: str, name: str, role: str = "", api_key: Optional[st
409
474
  }
410
475
 
411
476
 
477
+ # TTL cache for resolve_agent_name — avoids N+1 queries on every send().
478
+ # Key: (project_id, name) → (resolved_name, expiry_ts). Max 256 entries.
479
+ _AGENT_NAME_CACHE: Dict[tuple, tuple] = {}
480
+ _AGENT_NAME_CACHE_LOCK = _threading.Lock()
481
+ _AGENT_NAME_CACHE_TTL = 300 # 5 minutes
482
+ _AGENT_NAME_CACHE_MAX = 256
483
+
484
+
485
+ def _agent_name_cache_get(project_id: str, name: str) -> Optional[str]:
486
+ key = (project_id, name)
487
+ with _AGENT_NAME_CACHE_LOCK:
488
+ entry = _AGENT_NAME_CACHE.get(key)
489
+ if entry and entry[1] > _time.time():
490
+ return entry[0]
491
+ _AGENT_NAME_CACHE.pop(key, None)
492
+ return None
493
+
494
+
495
+ def _agent_name_cache_set(project_id: str, name: str, resolved: str) -> None:
496
+ key = (project_id, name)
497
+ with _AGENT_NAME_CACHE_LOCK:
498
+ if len(_AGENT_NAME_CACHE) >= _AGENT_NAME_CACHE_MAX:
499
+ # Evict oldest entries
500
+ now = _time.time()
501
+ expired = [k for k, v in _AGENT_NAME_CACHE.items() if v[1] <= now]
502
+ for k in expired:
503
+ del _AGENT_NAME_CACHE[k]
504
+ if len(_AGENT_NAME_CACHE) >= _AGENT_NAME_CACHE_MAX:
505
+ # Still full — evict first quarter by insertion order
506
+ to_evict = list(_AGENT_NAME_CACHE.keys())[:_AGENT_NAME_CACHE_MAX // 4]
507
+ for k in to_evict:
508
+ del _AGENT_NAME_CACHE[k]
509
+ _AGENT_NAME_CACHE[key] = (resolved, _time.time() + _AGENT_NAME_CACHE_TTL)
510
+
511
+
412
512
  def resolve_agent_name(project_id: str, name: str) -> str:
413
513
  """Resolve a partial or exact agent name to the full registered name.
414
514
 
415
515
  Returns the exact match if found, or a unique prefix/substring match.
416
516
  Returns the original name unchanged if no match or ambiguous.
417
517
  Broadcast target '*' is passed through unchanged.
518
+ Uses a TTL cache to avoid repeated DB lookups.
418
519
  """
419
520
  if not name or name == "*":
420
521
  return name
522
+ cached = _agent_name_cache_get(project_id, name)
523
+ if cached is not None:
524
+ return cached
421
525
  # Exact match — fast path
422
526
  exact = sb_select("mc_agents",
423
527
  f"project_id=eq.{project_id}&name=eq.{quote(name)}",
424
528
  limit=1)
425
529
  if exact:
530
+ _agent_name_cache_set(project_id, name, name)
426
531
  return name
427
532
  # Fuzzy: prefix or substring match among registered agents
428
- agents = sb_select("mc_agents", f"project_id=eq.{project_id}",
429
- columns="name")
533
+ agents = sb_select("mc_agents", f"project_id=eq.{project_id}&select=name")
430
534
  if not agents:
431
535
  return name
432
536
  all_names = [a["name"] for a in agents]
433
537
  # Try prefix match first
434
538
  prefix_matches = [n for n in all_names if n.startswith(name)]
435
539
  if len(prefix_matches) == 1:
540
+ _agent_name_cache_set(project_id, name, prefix_matches[0])
436
541
  return prefix_matches[0]
437
542
  # Try substring match
438
543
  sub_matches = [n for n in all_names if name in n]
439
544
  if len(sub_matches) == 1:
545
+ _agent_name_cache_set(project_id, name, sub_matches[0])
440
546
  return sub_matches[0]
441
547
  # Ambiguous or no match — return original
442
548
  return name
@@ -651,20 +757,34 @@ def count_pending(project_id: str, agent: str, api_key: Optional[str] = None) ->
651
757
  # Encryption helpers for sensitive mesh messages
652
758
  # ============================================================
653
759
 
654
- _mesh_key_cache: Dict[str, str] = {} # project_id -> hex key
760
+ # TTL + max-size cache for mesh encryption keys. Previous version was unbounded.
761
+ _mesh_key_cache: Dict[str, tuple] = {} # project_id -> (hex_key, expiry_ts)
762
+ _MESH_KEY_CACHE_TTL = 600 # 10 minutes
763
+ _MESH_KEY_CACHE_MAX = 64
655
764
 
656
765
 
657
766
  def get_mesh_key(api_key: str, project_id: str) -> Optional[str]:
658
767
  """Retrieve the per-meshwork encryption key (hex-encoded AES-256 key)."""
659
- if project_id in _mesh_key_cache:
660
- return _mesh_key_cache[project_id]
768
+ entry = _mesh_key_cache.get(project_id)
769
+ if entry and entry[1] > _time.time():
770
+ return entry[0]
771
+ _mesh_key_cache.pop(project_id, None)
661
772
  result = sb_rpc("mc_get_mesh_key", {
662
773
  "p_api_key": api_key,
663
774
  "p_project_id": project_id,
664
775
  })
665
776
  if isinstance(result, dict) and result.get("ok"):
666
777
  key = result["key"]
667
- _mesh_key_cache[project_id] = key
778
+ if len(_mesh_key_cache) >= _MESH_KEY_CACHE_MAX:
779
+ # Evict expired, then oldest if still full
780
+ now = _time.time()
781
+ expired = [k for k, v in _mesh_key_cache.items() if v[1] <= now]
782
+ for k in expired:
783
+ del _mesh_key_cache[k]
784
+ if len(_mesh_key_cache) >= _MESH_KEY_CACHE_MAX:
785
+ oldest = min(_mesh_key_cache, key=lambda k: _mesh_key_cache[k][1])
786
+ del _mesh_key_cache[oldest]
787
+ _mesh_key_cache[project_id] = (key, _time.time() + _MESH_KEY_CACHE_TTL)
668
788
  return key
669
789
  return None
670
790
 
@@ -726,7 +846,11 @@ def decrypt_payload(encrypted_b64: str, hex_key: str, aad: Optional[str] = None)
726
846
 
727
847
 
728
848
  def get_board(project_id: str) -> List[Dict]:
729
- return sb_select("mc_agents", f"project_id=eq.{project_id}", order="registered_at.asc")
849
+ return sb_select(
850
+ "mc_agents",
851
+ f"project_id=eq.{project_id}&select=name,role,status,task,last_heartbeat,registered_at",
852
+ order="registered_at.asc",
853
+ )
730
854
 
731
855
 
732
856
  def heartbeat(project_id: str, agent: str) -> Dict:
@@ -382,6 +382,30 @@ if not PROJECT_NAME or not AGENT_NAME:
382
382
  )
383
383
  sys.exit(2)
384
384
 
385
+ # ============================================================
386
+ # CWD check — warn if user opened Claude from the wrong directory
387
+ # ============================================================
388
+ # On Windows (and sometimes macOS), users open Claude Code from their home
389
+ # directory instead of the workspace where .mcp.json lives. The MCP server
390
+ # still loads (env vars come from .mcp.json), but the agent can't access
391
+ # workspace files. Warn early so the user knows to use `meshcode run`.
392
+
393
+ _cwd = os.getcwd()
394
+ _cwd_has_mcp = os.path.exists(os.path.join(_cwd, ".mcp.json"))
395
+ _home = os.path.expanduser("~")
396
+ if not _cwd_has_mcp and os.path.normpath(_cwd) == os.path.normpath(_home):
397
+ _mc_log(
398
+ f"Current directory is your home folder ({_cwd}). "
399
+ f"This agent's workspace may be elsewhere. "
400
+ f"For best results, use: meshcode run {AGENT_NAME}",
401
+ "warn",
402
+ )
403
+ elif not _cwd_has_mcp:
404
+ _mc_log(
405
+ f"No .mcp.json found in {_cwd}. "
406
+ f"If tools can't find your files, try: meshcode run {AGENT_NAME}",
407
+ "warn",
408
+ )
385
409
 
386
410
  # ============================================================
387
411
  # API key resolution — keychain first, env var fallback
@@ -1120,24 +1144,69 @@ async def _on_new_message(msg: Dict[str, Any]) -> None:
1120
1144
  # Stashed MCP session for silent auto-wake (works when agent is idle, no active tool call)
1121
1145
  _STASHED_SESSION = None
1122
1146
 
1147
+ # Store the main asyncio event loop reference for use in the heartbeat daemon
1148
+ # thread. On Windows (ProactorEventLoop), asyncio.get_event_loop() inside a
1149
+ # non-main thread returns a NEW, non-running loop — so is_running() is always
1150
+ # False and run_coroutine_threadsafe silently does nothing. By capturing the
1151
+ # loop at lifespan start we guarantee the heartbeat thread can schedule
1152
+ # Realtime restarts on the correct loop regardless of platform.
1153
+ _MAIN_LOOP: asyncio.AbstractEventLoop | None = None
1154
+
1123
1155
  _heartbeat_stop = _threading.Event()
1124
1156
 
1157
+ # Windows CPU tracking: (Get-Process).CPU returns cumulative seconds, not a
1158
+ # real-time percentage like Unix `ps -o %cpu`. We track the previous reading
1159
+ # and timestamp so we can derive Δcpu/Δtime as a percentage.
1160
+ _win_prev_cpu: float | None = None
1161
+ _win_prev_ts: float | None = None
1162
+
1125
1163
 
1126
1164
  def _get_parent_cpu() -> float:
1127
- """Check parent process CPU usage to detect if LLM is actively generating."""
1165
+ """Return approximate real-time CPU usage (%) of the parent process.
1166
+
1167
+ On Unix this delegates to ``ps -o %cpu`` which returns a percentage
1168
+ directly. On Windows, ``(Get-Process).CPU`` returns *total CPU seconds*
1169
+ since process start — a monotonically increasing number. To get a
1170
+ meaningful percentage we compute ``(Δcpu / Δtime) * 100`` between two
1171
+ consecutive heartbeat calls.
1172
+ """
1173
+ global _win_prev_cpu, _win_prev_ts
1128
1174
  try:
1129
1175
  import subprocess as _sp, platform as _pl
1130
1176
  ppid = os.getppid()
1131
1177
  if _pl.system() == "Windows":
1132
- r = _sp.run(["wmic", "process", "where", f"processid={ppid}", "get", "percentprocessortime"],
1133
- capture_output=True, text=True, timeout=5)
1134
- lines = [l.strip() for l in r.stdout.strip().split("\n") if l.strip()]
1135
- return float(lines[-1]) if len(lines) > 1 else 0.0
1178
+ now = _time.time()
1179
+ r = _sp.run(
1180
+ ["powershell", "-NoProfile", "-Command",
1181
+ f"(Get-Process -Id {ppid} -ErrorAction SilentlyContinue).CPU"],
1182
+ capture_output=True, text=True, timeout=5,
1183
+ )
1184
+ val = r.stdout.strip()
1185
+ if not val:
1186
+ log.debug(f"Windows CPU: empty output for ppid={ppid}")
1187
+ return 0.0
1188
+ cpu_total = float(val)
1189
+ if _win_prev_cpu is None or _win_prev_ts is None:
1190
+ # First reading — store baseline, return 0
1191
+ _win_prev_cpu = cpu_total
1192
+ _win_prev_ts = now
1193
+ log.debug(f"Windows CPU baseline: {cpu_total:.2f}s (ppid={ppid})")
1194
+ return 0.0
1195
+ dt = now - _win_prev_ts
1196
+ dcpu = cpu_total - _win_prev_cpu
1197
+ _win_prev_cpu = cpu_total
1198
+ _win_prev_ts = now
1199
+ if dt <= 0:
1200
+ return 0.0
1201
+ pct = (dcpu / dt) * 100.0
1202
+ log.debug(f"Windows CPU: Δcpu={dcpu:.3f}s Δt={dt:.1f}s → {pct:.1f}%")
1203
+ return max(pct, 0.0)
1136
1204
  else:
1137
1205
  r = _sp.run(["ps", "-p", str(ppid), "-o", "%cpu"], capture_output=True, text=True, timeout=5)
1138
1206
  lines = [l.strip() for l in r.stdout.strip().split("\n") if l.strip()]
1139
1207
  return float(lines[-1]) if len(lines) > 1 else 0.0
1140
- except Exception:
1208
+ except Exception as e:
1209
+ log.debug(f"CPU detection failed: {e}")
1141
1210
  return 0.0
1142
1211
 
1143
1212
 
@@ -1171,6 +1240,15 @@ def _heartbeat_thread_fn():
1171
1240
  def _heartbeat_loop_inner():
1172
1241
  """Single iteration of the heartbeat loop. Separated so the outer
1173
1242
  wrapper can catch any exception and restart cleanly."""
1243
+ import platform as _pl
1244
+ _is_windows = _pl.system() == "Windows"
1245
+ if _is_windows:
1246
+ log.info(
1247
+ f"[WIN-DEBUG] heartbeat thread started on Windows — "
1248
+ f"agent={AGENT_NAME} ppid={os.getppid()} "
1249
+ f"loop_captured={_MAIN_LOOP is not None} "
1250
+ f"realtime={'connected' if _REALTIME and _REALTIME.is_connected else 'none/disconnected'}"
1251
+ )
1174
1252
  lease_counter = 0
1175
1253
  while not _heartbeat_stop.is_set():
1176
1254
  try:
@@ -1182,6 +1260,16 @@ def _heartbeat_loop_inner():
1182
1260
  in_wait = _IN_WAIT
1183
1261
  idle_secs = _time.time() - _last_tool_at
1184
1262
 
1263
+ if _is_windows and lease_counter % 12 == 0:
1264
+ # Periodic Windows debug dump (every ~60s on pro, ~180s on free)
1265
+ log.info(
1266
+ f"[WIN-DEBUG] cpu={parent_cpu:.1f}% state={cur_state} "
1267
+ f"in_wait={in_wait} idle={idle_secs:.0f}s "
1268
+ f"rt_conn={_REALTIME.is_connected if _REALTIME else 'N/A'} "
1269
+ f"rt_sub={_REALTIME.is_subscribed if _REALTIME else 'N/A'} "
1270
+ f"loop_running={_MAIN_LOOP.is_running() if _MAIN_LOOP else False}"
1271
+ )
1272
+
1185
1273
  if in_wait:
1186
1274
  # Actually in meshcode_wait right now — listening for messages
1187
1275
  if cur_state != "waiting":
@@ -1206,26 +1294,29 @@ def _heartbeat_loop_inner():
1206
1294
  try:
1207
1295
  be.set_status(_PROJECT_ID, AGENT_NAME, _current_state, _current_tool, api_key=_get_api_key())
1208
1296
  except Exception as e:
1209
- log.debug(f"status sync failed: {e}")
1297
+ log.warning(f"status sync failed (agent may show stale status): {e}")
1210
1298
  # Realtime subscription recovery: if the WebSocket is connected but
1211
1299
  # the channel subscription dropped (e.g. Supabase maintenance), or
1212
1300
  # the WebSocket itself disconnected, trigger a restart so agents
1213
1301
  # don't stay stuck in slow DB polling for the entire session.
1302
+ #
1303
+ # Use _MAIN_LOOP (captured at lifespan start) instead of
1304
+ # asyncio.get_event_loop() — on Windows the latter returns a new,
1305
+ # non-running loop inside daemon threads, silently preventing
1306
+ # Realtime restarts.
1214
1307
  if _REALTIME:
1215
1308
  if not _REALTIME.is_connected:
1216
1309
  log.warning("heartbeat ok (HTTP) but WebSocket disconnected — scheduling Realtime restart")
1217
1310
  try:
1218
- loop = asyncio.get_event_loop()
1219
- if loop.is_running():
1220
- asyncio.run_coroutine_threadsafe(_REALTIME.restart(), loop)
1311
+ if _MAIN_LOOP and _MAIN_LOOP.is_running():
1312
+ asyncio.run_coroutine_threadsafe(_REALTIME.restart(), _MAIN_LOOP)
1221
1313
  except Exception as e:
1222
1314
  log.debug(f"Realtime restart scheduling failed: {e}")
1223
1315
  elif not _REALTIME.is_subscribed:
1224
1316
  log.warning("WebSocket connected but subscription lost — scheduling Realtime restart")
1225
1317
  try:
1226
- loop = asyncio.get_event_loop()
1227
- if loop.is_running():
1228
- asyncio.run_coroutine_threadsafe(_REALTIME.restart(), loop)
1318
+ if _MAIN_LOOP and _MAIN_LOOP.is_running():
1319
+ asyncio.run_coroutine_threadsafe(_REALTIME.restart(), _MAIN_LOOP)
1229
1320
  except Exception as e:
1230
1321
  log.debug(f"Realtime restart scheduling failed: {e}")
1231
1322
  else:
@@ -1258,7 +1349,17 @@ def _heartbeat_loop_inner():
1258
1349
  @asynccontextmanager
1259
1350
  async def lifespan(_app):
1260
1351
  """Start the Realtime listener when the MCP server boots; stop on shutdown."""
1261
- global _REALTIME
1352
+ global _REALTIME, _MAIN_LOOP
1353
+ _MAIN_LOOP = asyncio.get_running_loop()
1354
+
1355
+ import platform as _pl
1356
+ if _pl.system() == "Windows":
1357
+ log.info(
1358
+ f"[WIN-DEBUG] lifespan start — loop={type(_MAIN_LOOP).__name__} "
1359
+ f"policy={type(asyncio.get_event_loop_policy()).__name__} "
1360
+ f"agent={AGENT_NAME}"
1361
+ )
1362
+
1262
1363
  _REALTIME = RealtimeListener(
1263
1364
  supabase_url=be.SUPABASE_URL,
1264
1365
  supabase_key=be.SUPABASE_KEY,
@@ -1914,7 +2015,6 @@ async def _meshcode_wait_inner(actual_timeout: int, include_acks: bool) -> Dict[
1914
2015
  _mark_realtime_msgs_read_in_db(deduped)
1915
2016
  out: Dict[str, Any] = {
1916
2017
  "got_message": True,
1917
- "source": "realtime",
1918
2018
  **split,
1919
2019
  }
1920
2020
  done = _detect_global_done(deduped)
@@ -1971,7 +2071,7 @@ async def _meshcode_wait_inner(actual_timeout: int, include_acks: bool) -> Dict[
1971
2071
  if not include_acks:
1972
2072
  split["acks"] = []
1973
2073
  if split["messages"] or split["done_signals"]:
1974
- return {"got_message": True, "source": "db_poll_fallback", **split}
2074
+ return {"got_message": True, **split}
1975
2075
  except Exception as e:
1976
2076
  log.debug(f"DB poll fallback error: {e}")
1977
2077
 
@@ -1995,7 +2095,7 @@ async def _meshcode_wait_inner(actual_timeout: int, include_acks: bool) -> Dict[
1995
2095
  if not include_acks:
1996
2096
  split["acks"] = []
1997
2097
  if split["messages"] or split["done_signals"]:
1998
- return {"got_message": True, "source": "db_fallback", **split}
2098
+ return {"got_message": True, **split}
1999
2099
  except Exception as e:
2000
2100
  log.debug(f"final DB fallback error: {e}")
2001
2101
 
@@ -2081,10 +2181,6 @@ def meshcode_check(include_acks: bool = False, since: Optional[str] = None) -> D
2081
2181
  split["acks"] = []
2082
2182
  result = {
2083
2183
  "pending": pending if not effective_since else len(split.get("messages", [])),
2084
- "agent": AGENT_NAME,
2085
- "project": PROJECT_NAME,
2086
- "realtime_connected": _REALTIME.is_connected if _REALTIME else False,
2087
- "realtime_subscribed": _REALTIME.is_subscribed if _REALTIME else False,
2088
2184
  **split,
2089
2185
  }
2090
2186
  # Auto-inject pending tasks
@@ -2100,7 +2196,6 @@ def meshcode_status() -> Dict[str, Any]:
2100
2196
  """List all agents with status, role, and current task."""
2101
2197
  agents = be.get_board(_PROJECT_ID)
2102
2198
  return {
2103
- "project": PROJECT_NAME,
2104
2199
  "agents": [
2105
2200
  {
2106
2201
  "name": a["name"],
@@ -2871,7 +2966,7 @@ def meshcode_recall(key: Optional[str] = None) -> Dict[str, Any]:
2871
2966
  if isinstance(reference, dict) and reference.get("ok"):
2872
2967
  ref_keys = [m.get("key") for m in reference.get("memories", [])]
2873
2968
  critical["reference_keys"] = ref_keys
2874
- critical["tip"] = "Use meshcode_recall_search(query) to search episodic memories."
2969
+ # tip removed already in system prompt, saves ~20 tokens per call
2875
2970
  return critical
2876
2971
 
2877
2972
 
@@ -2938,8 +3033,6 @@ def meshcode_health() -> Dict[str, Any]:
2938
3033
  """Check MCP server health: DB connectivity, Realtime status, circuit breaker state, uptime."""
2939
3034
  import time as _t
2940
3035
  health: Dict[str, Any] = {
2941
- "agent": AGENT_NAME,
2942
- "project": PROJECT_NAME,
2943
3036
  "instance_id": _INSTANCE_ID,
2944
3037
  "sdk_version": _SDK_VERSION,
2945
3038
  }
@@ -44,17 +44,34 @@ def load_prefs() -> Dict[str, Any]:
44
44
  return {}
45
45
 
46
46
 
47
+ def _restrict_file_permissions(path: "Path") -> None:
48
+ """Restrict file to owner-only access on all platforms."""
49
+ try:
50
+ if sys.platform == "win32":
51
+ import subprocess
52
+ # icacls: remove inherited ACLs, grant only current user Full Control
53
+ username = os.environ.get("USERNAME", os.environ.get("USER", ""))
54
+ if username:
55
+ subprocess.run(
56
+ ["icacls", str(path), "/inheritance:r",
57
+ "/grant:r", f"{username}:(F)"],
58
+ capture_output=True, timeout=5,
59
+ )
60
+ else:
61
+ os.chmod(path, 0o600)
62
+ except Exception:
63
+ pass
64
+
65
+
47
66
  def save_prefs(prefs: Dict[str, Any]) -> bool:
48
- """Atomic write of the prefs file with mode 600."""
67
+ """Atomic write of the prefs file with restricted permissions."""
49
68
  try:
50
69
  PREFS_PATH.parent.mkdir(parents=True, exist_ok=True)
51
70
  tmp = PREFS_PATH.with_suffix(".tmp")
52
71
  tmp.write_text(json.dumps(prefs, indent=2))
53
- try:
54
- os.chmod(tmp, 0o600)
55
- except Exception:
56
- pass
72
+ _restrict_file_permissions(tmp)
57
73
  tmp.replace(PREFS_PATH)
74
+ _restrict_file_permissions(PREFS_PATH)
58
75
  return True
59
76
  except Exception as e:
60
77
  print(f"[meshcode] WARNING: could not save prefs: {e}", file=sys.stderr)
@@ -351,6 +351,28 @@ def _list_local_projects_for_agent(agent: str) -> list:
351
351
  return out
352
352
 
353
353
 
354
+ def _claude_supports_bypass(editor_cmd: str) -> bool:
355
+ """Check if the Claude Code binary supports --dangerously-skip-permissions.
356
+
357
+ Runs ``claude --help`` and looks for the flag. Returns False on any
358
+ error (timeout, not found, old version) so the caller can degrade
359
+ gracefully instead of crashing.
360
+
361
+ On Windows, .cmd/.bat wrappers need shell=True to execute correctly —
362
+ without it the subprocess silently fails and bypass never activates.
363
+ """
364
+ try:
365
+ use_shell = sys.platform == "win32" and editor_cmd.lower().endswith((".cmd", ".bat"))
366
+ r = subprocess.run(
367
+ [editor_cmd, "--help"],
368
+ capture_output=True, text=True, timeout=10,
369
+ shell=use_shell,
370
+ )
371
+ return "--dangerously-skip-permissions" in r.stdout
372
+ except Exception:
373
+ return False
374
+
375
+
354
376
  def _detect_editor() -> Optional[str]:
355
377
  """Pick the user's preferred MCP-aware editor."""
356
378
  override = os.environ.get("MESHCODE_EDITOR", "").strip().lower()
@@ -397,6 +419,77 @@ def _detect_editor() -> Optional[str]:
397
419
  return None
398
420
 
399
421
 
422
+ def _preflight_heartbeat(agent: str, project: str) -> None:
423
+ """Send a heartbeat + set status before the editor launches.
424
+
425
+ On Windows without bypass mode, Claude Code may delay spawning the
426
+ MCP server until the user approves the first tool call. During that
427
+ gap the agent shows as 'offline' in the dashboard. This direct RPC
428
+ call ensures the agent appears online immediately.
429
+
430
+ Best-effort: any failure is logged and swallowed — it must never
431
+ block or crash the launch.
432
+ """
433
+ try:
434
+ from .setup_clients import _load_supabase_env
435
+ import importlib
436
+ secrets_mod = importlib.import_module("meshcode.secrets")
437
+ from urllib.request import Request, urlopen
438
+
439
+ profile = os.environ.get("MESHCODE_KEYCHAIN_PROFILE") or "default"
440
+ api_key = secrets_mod.get_api_key(profile=profile)
441
+ if not api_key:
442
+ return
443
+
444
+ sb = _load_supabase_env()
445
+ headers = {
446
+ "apikey": sb["SUPABASE_KEY"],
447
+ "Authorization": f"Bearer {sb['SUPABASE_KEY']}",
448
+ "Content-Type": "application/json",
449
+ "Content-Profile": "meshcode",
450
+ }
451
+
452
+ # Resolve project_id
453
+ resolve_body = json.dumps({"p_api_key": api_key, "p_project_name": project})
454
+ req = Request(
455
+ f"{sb['SUPABASE_URL']}/rest/v1/rpc/mc_resolve_project",
456
+ data=resolve_body.encode(), method="POST", headers=headers,
457
+ )
458
+ with urlopen(req, timeout=5) as resp:
459
+ proj_data = json.loads(resp.read().decode())
460
+ project_id = proj_data.get("project_id") if proj_data else None
461
+ if not project_id:
462
+ return
463
+
464
+ # Send heartbeat
465
+ hb_body = json.dumps({"p_project_id": project_id, "p_agent_name": agent})
466
+ hb_req = Request(
467
+ f"{sb['SUPABASE_URL']}/rest/v1/rpc/mc_heartbeat",
468
+ data=hb_body.encode(), method="POST", headers=headers,
469
+ )
470
+ with urlopen(hb_req, timeout=5) as resp:
471
+ resp.read()
472
+
473
+ # Set status to 'online'
474
+ status_body = json.dumps({
475
+ "p_api_key": api_key,
476
+ "p_project_id": project_id,
477
+ "p_agent_name": agent,
478
+ "p_status": "online",
479
+ "p_task": "launching editor",
480
+ })
481
+ status_req = Request(
482
+ f"{sb['SUPABASE_URL']}/rest/v1/rpc/mc_agent_set_status_by_api_key",
483
+ data=status_body.encode(), method="POST", headers=headers,
484
+ )
485
+ with urlopen(status_req, timeout=5) as resp:
486
+ resp.read()
487
+
488
+ print(f"[meshcode] Pre-flight heartbeat sent — agent visible in dashboard")
489
+ except Exception as e:
490
+ print(f"[meshcode] Pre-flight heartbeat skipped: {e}", file=sys.stderr)
491
+
492
+
400
493
  def run(agent: str, project: Optional[str] = None, editor_override: Optional[str] = None, permission_override: Optional[str] = None) -> int:
401
494
  """Launch the user's editor with ONLY the named agent's MCP server loaded."""
402
495
  # Non-blocking self-update check (consumes prior result, may spawn bg pip)
@@ -509,7 +602,22 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
509
602
  # ── Welcome banner with unique ASCII art + personality ──────────
510
603
  try:
511
604
  from .ascii_art import generate_art, render_welcome
512
- from . import __version__ as cli_version
605
+ cli_version = ""
606
+ try:
607
+ from . import __version__ as cli_version
608
+ except ImportError:
609
+ try:
610
+ from meshcode import __version__ as cli_version
611
+ except ImportError:
612
+ # Last resort: read __version__ directly from __init__.py
613
+ try:
614
+ _init_path = Path(__file__).resolve().parent / "__init__.py"
615
+ for _line in _init_path.read_text(encoding="utf-8").splitlines():
616
+ if _line.startswith("__version__"):
617
+ cli_version = _line.split("=", 1)[1].strip().strip('"').strip("'")
618
+ break
619
+ except Exception:
620
+ pass
513
621
  ascii_art, agent_role, profile_color = _fetch_or_generate_art(agent, resolved_project)
514
622
  _leader_haystack = (agent + ' ' + agent_role).lower()
515
623
  _LEADER_KW = ('commander', 'lead', 'orchestrat', 'coordinator', 'coordinat',
@@ -521,9 +629,11 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
521
629
  agent, resolved_project, ascii_art, cli_version,
522
630
  is_commander=is_cmd, role=agent_role, stats=agent_stats,
523
631
  profile_color=profile_color,
524
- ))
525
- except Exception:
526
- pass # Non-critical skip banner on error
632
+ ), file=sys.stderr, flush=True)
633
+ except Exception as _banner_err:
634
+ import traceback as _tb
635
+ print(f"[meshcode] WARN: banner failed: {_banner_err}", file=sys.stderr)
636
+ _tb.print_exc(file=sys.stderr)
527
637
 
528
638
  print(f"[meshcode] Launching {editor} for agent '{agent}' (project: {resolved_project})")
529
639
  print(f"[meshcode] Workspace: {ws}")
@@ -547,8 +657,13 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
547
657
  mode = resolve_permission_mode(permission_override)
548
658
  cmd = [editor]
549
659
  if mode == "bypass":
550
- cmd.append("--dangerously-skip-permissions")
551
- print(f"[meshcode] Permission mode: bypass (autonomous loop, no prompts)")
660
+ if _claude_supports_bypass(editor):
661
+ cmd.append("--dangerously-skip-permissions")
662
+ print(f"[meshcode] Permission mode: bypass (autonomous loop, no prompts)")
663
+ else:
664
+ print(f"[meshcode] Permission mode: bypass requested but --dangerously-skip-permissions")
665
+ print(f"[meshcode] not supported by this Claude Code version. Agent will prompt for tools.")
666
+ print(f"[meshcode] Upgrade Claude Code: npm install -g @anthropic-ai/claude-code@latest")
552
667
  else:
553
668
  print(f"[meshcode] Permission mode: safe (Claude will prompt for every tool call)")
554
669
  print(f"[meshcode] Tip: change with `meshcode prefs permission-mode bypass`")
@@ -557,10 +672,12 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
557
672
  # messages, claim tasks, greet peers) without waiting for user input.
558
673
  # Uses -- to separate options from the positional prompt argument.
559
674
  cmd.extend(["--", "boot"])
675
+ if not os.path.isdir(ws):
676
+ print(f"[meshcode] WARNING: workspace dir does not exist: {ws}", file=sys.stderr)
560
677
  try:
561
678
  os.chdir(ws)
562
- except Exception:
563
- pass
679
+ except Exception as e:
680
+ print(f"[meshcode] WARNING: chdir to workspace failed: {e}", file=sys.stderr)
564
681
  elif editor == "cursor":
565
682
  # Cursor reads .cursor/mcp.json from the workspace cwd.
566
683
  cmd = [editor, str(ws)]
@@ -575,10 +692,12 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
575
692
  # Codex CLI reads ~/.codex/config.toml globally. There's no per-
576
693
  # workspace flag, so launching is just `codex` with cwd set to the
577
694
  # workspace dir so any file ops the agent does land there.
695
+ if not os.path.isdir(ws):
696
+ print(f"[meshcode] WARNING: workspace dir does not exist: {ws}", file=sys.stderr)
578
697
  try:
579
698
  os.chdir(ws)
580
- except Exception:
581
- pass
699
+ except Exception as e:
700
+ print(f"[meshcode] WARNING: chdir to workspace failed: {e}", file=sys.stderr)
582
701
  cmd = [editor]
583
702
  else:
584
703
  cmd = [editor, str(ws)]
@@ -598,11 +717,39 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
598
717
  except OSError:
599
718
  pass # No devnull (unlikely) — let the editor handle it
600
719
 
720
+ # Pre-flight heartbeat: mark the agent as online BEFORE the editor
721
+ # subprocess spawns. On Windows without bypass mode, Claude Code may
722
+ # delay starting the MCP server until the user interacts — leaving the
723
+ # agent stuck as 'offline' in the dashboard. This direct RPC call
724
+ # ensures immediate visibility regardless of MCP spawn timing.
725
+ _preflight_heartbeat(agent, resolved_project)
726
+
727
+ # Flush all output before exec replaces this process — execvp does NOT
728
+ # flush Python file buffers, so any buffered stdout (e.g. the banner)
729
+ # would be silently lost.
730
+ sys.stdout.flush()
731
+ sys.stderr.flush()
732
+
733
+ # Effective cwd for the editor — log it for debugging (especially
734
+ # Windows where shell=True + os.chdir may not propagate).
735
+ effective_cwd = os.getcwd()
736
+ print(f"[meshcode] Effective cwd: {effective_cwd}")
737
+
601
738
  try:
602
739
  if sys.platform == "win32":
603
- # Windows: no execvp, use subprocess and wait
740
+ # Windows: no execvp, use subprocess and wait.
741
+ # .cmd/.bat files need shell=True for proper argument passing —
742
+ # without it, flags like --dangerously-skip-permissions can be
743
+ # mangled by Windows' CreateProcess argument splitting.
744
+ # Explicit cwd=str(ws) ensures the child process starts in the
745
+ # workspace even if os.chdir() didn't stick (shell=True can
746
+ # reset cwd via cmd.exe).
604
747
  import subprocess as _sp
605
- result = _sp.run(cmd)
748
+ use_shell = cmd[0].lower().endswith((".cmd", ".bat"))
749
+ if use_shell:
750
+ print(f"[meshcode] (Windows: launching via shell for .cmd wrapper)")
751
+ launch_cwd = str(ws) if os.path.isdir(ws) else None
752
+ result = _sp.run(cmd, shell=use_shell, cwd=launch_cwd)
606
753
  sys.exit(result.returncode)
607
754
  else:
608
755
  # Unix: replace this process with the editor
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 2.10.41
3
+ Version: 2.10.42
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.41"
7
+ version = "2.10.42"
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