meshcode 2.10.41__tar.gz → 2.10.43__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.43}/PKG-INFO +1 -1
  2. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/__init__.py +1 -1
  3. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/comms_v4.py +71 -13
  4. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/meshcode_mcp/backend.py +144 -20
  5. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/meshcode_mcp/server.py +147 -33
  6. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/preferences.py +22 -5
  7. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/run_agent.py +187 -16
  8. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/secrets.py +29 -8
  9. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/setup_clients.py +20 -0
  10. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode.egg-info/PKG-INFO +1 -1
  11. {meshcode-2.10.41 → meshcode-2.10.43}/pyproject.toml +1 -1
  12. {meshcode-2.10.41 → meshcode-2.10.43}/README.md +0 -0
  13. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/ascii_art.py +0 -0
  14. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/cli.py +0 -0
  15. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/invites.py +0 -0
  16. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/launcher.py +0 -0
  17. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/launcher_install.py +0 -0
  18. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/meshcode_mcp/__init__.py +0 -0
  19. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/meshcode_mcp/__main__.py +0 -0
  20. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/meshcode_mcp/realtime.py +0 -0
  21. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/meshcode_mcp/test_backend.py +0 -0
  22. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/meshcode_mcp/test_realtime.py +0 -0
  23. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
  24. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/protocol_v2.py +0 -0
  25. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode/self_update.py +0 -0
  26. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode.egg-info/SOURCES.txt +0 -0
  27. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode.egg-info/dependency_links.txt +0 -0
  28. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode.egg-info/entry_points.txt +0 -0
  29. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode.egg-info/requires.txt +0 -0
  30. {meshcode-2.10.41 → meshcode-2.10.43}/meshcode.egg-info/top_level.txt +0 -0
  31. {meshcode-2.10.41 → meshcode-2.10.43}/setup.cfg +0 -0
  32. {meshcode-2.10.41 → meshcode-2.10.43}/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.43
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.43"
@@ -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,34 +314,86 @@ 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
339
353
 
340
354
 
355
+ def _nudge_windows_terminal(name, pending, tty, pid):
356
+ """Activate the agent's terminal window on Windows.
357
+
358
+ Uses PowerShell + kernel32 to find the console window by process ID and
359
+ bring it to the foreground. Does NOT send keystrokes — that would risk
360
+ confirming prompts or interrupting bypass mode. The window activation
361
+ combined with the desktop notification is enough to alert the user.
362
+ """
363
+ if not pid:
364
+ return
365
+ try:
366
+ pid = int(pid)
367
+ except (ValueError, TypeError):
368
+ return
369
+ try:
370
+ ps_script = f'''
371
+ $proc = Get-Process -Id {pid} -ErrorAction SilentlyContinue
372
+ if ($proc -and $proc.MainWindowHandle -ne 0) {{
373
+ Add-Type @"
374
+ using System;
375
+ using System.Runtime.InteropServices;
376
+ public class WinApi {{
377
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
378
+ [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
379
+ }}
380
+ "@
381
+ [WinApi]::ShowWindow($proc.MainWindowHandle, 9)
382
+ [WinApi]::SetForegroundWindow($proc.MainWindowHandle)
383
+ }}
384
+ '''
385
+ subprocess.run(
386
+ ["powershell", "-NoProfile", "-Command", ps_script],
387
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
388
+ timeout=10,
389
+ )
390
+ log_msg(f"[nudge] Windows: activated terminal for {name} (pid={pid})")
391
+ except subprocess.TimeoutExpired:
392
+ log_msg(f"[nudge] Windows: PowerShell timed out for {name}")
393
+ except Exception as e:
394
+ log_msg(f"[nudge] Windows: failed for {name}: {e}")
395
+
396
+
341
397
  def _is_pid_alive(pid):
342
398
  """Return True iff the given pid corresponds to a live process."""
343
399
  if not pid:
@@ -782,8 +838,10 @@ def nudge_agent(project, name, from_agent=""):
782
838
  message = f"Tienes {pending} mensaje(s). Revisa: meshcode read {project} {name}"
783
839
  escaped_message = message.replace('\\', '\\\\').replace('"', '\\"')
784
840
 
785
- # Only attempt AppleScript nudge on macOS
841
+ # Non-macOS: use platform-specific terminal nudge
786
842
  if sys.platform != "darwin":
843
+ if sys.platform == "win32":
844
+ _nudge_windows_terminal(name, pending, tty, cached_pid)
787
845
  send_notification(project, name, from_agent, pending)
788
846
  mark_nudged(project, name)
789
847
  return True
@@ -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:
@@ -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
 
@@ -197,8 +197,10 @@ def _try_auto_wake(from_agent: str, preview: str) -> None:
197
197
  Add-Type -AssemblyName System.Windows.Forms
198
198
  [System.Windows.Forms.SendKeys]::SendWait("{sk_safe}{{ENTER}}")
199
199
  '''
200
- subprocess.Popen(["powershell", "-Command", ps_script],
201
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
200
+ # Use subprocess.run with timeout to prevent orphaned PowerShell processes
201
+ subprocess.run(["powershell", "-NoProfile", "-Command", ps_script],
202
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
203
+ timeout=10)
202
204
  log.info("auto-wake: injected nudge via PowerShell SendKeys")
203
205
  else:
204
206
  # Linux — xdotool takes args directly, not shell-interpolated (safe)
@@ -382,6 +384,30 @@ if not PROJECT_NAME or not AGENT_NAME:
382
384
  )
383
385
  sys.exit(2)
384
386
 
387
+ # ============================================================
388
+ # CWD check — warn if user opened Claude from the wrong directory
389
+ # ============================================================
390
+ # On Windows (and sometimes macOS), users open Claude Code from their home
391
+ # directory instead of the workspace where .mcp.json lives. The MCP server
392
+ # still loads (env vars come from .mcp.json), but the agent can't access
393
+ # workspace files. Warn early so the user knows to use `meshcode run`.
394
+
395
+ _cwd = os.getcwd()
396
+ _cwd_has_mcp = os.path.exists(os.path.join(_cwd, ".mcp.json"))
397
+ _home = os.path.expanduser("~")
398
+ if not _cwd_has_mcp and os.path.normpath(_cwd) == os.path.normpath(_home):
399
+ _mc_log(
400
+ f"Current directory is your home folder ({_cwd}). "
401
+ f"This agent's workspace may be elsewhere. "
402
+ f"For best results, use: meshcode run {AGENT_NAME}",
403
+ "warn",
404
+ )
405
+ elif not _cwd_has_mcp:
406
+ _mc_log(
407
+ f"No .mcp.json found in {_cwd}. "
408
+ f"If tools can't find your files, try: meshcode run {AGENT_NAME}",
409
+ "warn",
410
+ )
385
411
 
386
412
  # ============================================================
387
413
  # API key resolution — keychain first, env var fallback
@@ -1120,24 +1146,69 @@ async def _on_new_message(msg: Dict[str, Any]) -> None:
1120
1146
  # Stashed MCP session for silent auto-wake (works when agent is idle, no active tool call)
1121
1147
  _STASHED_SESSION = None
1122
1148
 
1149
+ # Store the main asyncio event loop reference for use in the heartbeat daemon
1150
+ # thread. On Windows (ProactorEventLoop), asyncio.get_event_loop() inside a
1151
+ # non-main thread returns a NEW, non-running loop — so is_running() is always
1152
+ # False and run_coroutine_threadsafe silently does nothing. By capturing the
1153
+ # loop at lifespan start we guarantee the heartbeat thread can schedule
1154
+ # Realtime restarts on the correct loop regardless of platform.
1155
+ _MAIN_LOOP: asyncio.AbstractEventLoop | None = None
1156
+
1123
1157
  _heartbeat_stop = _threading.Event()
1124
1158
 
1159
+ # Windows CPU tracking: (Get-Process).CPU returns cumulative seconds, not a
1160
+ # real-time percentage like Unix `ps -o %cpu`. We track the previous reading
1161
+ # and timestamp so we can derive Δcpu/Δtime as a percentage.
1162
+ _win_prev_cpu: float | None = None
1163
+ _win_prev_ts: float | None = None
1164
+
1125
1165
 
1126
1166
  def _get_parent_cpu() -> float:
1127
- """Check parent process CPU usage to detect if LLM is actively generating."""
1167
+ """Return approximate real-time CPU usage (%) of the parent process.
1168
+
1169
+ On Unix this delegates to ``ps -o %cpu`` which returns a percentage
1170
+ directly. On Windows, ``(Get-Process).CPU`` returns *total CPU seconds*
1171
+ since process start — a monotonically increasing number. To get a
1172
+ meaningful percentage we compute ``(Δcpu / Δtime) * 100`` between two
1173
+ consecutive heartbeat calls.
1174
+ """
1175
+ global _win_prev_cpu, _win_prev_ts
1128
1176
  try:
1129
1177
  import subprocess as _sp, platform as _pl
1130
1178
  ppid = os.getppid()
1131
1179
  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
1180
+ now = _time.time()
1181
+ r = _sp.run(
1182
+ ["powershell", "-NoProfile", "-Command",
1183
+ f"(Get-Process -Id {ppid} -ErrorAction SilentlyContinue).CPU"],
1184
+ capture_output=True, text=True, timeout=5,
1185
+ )
1186
+ val = r.stdout.strip()
1187
+ if not val:
1188
+ log.debug(f"Windows CPU: empty output for ppid={ppid}")
1189
+ return 0.0
1190
+ cpu_total = float(val)
1191
+ if _win_prev_cpu is None or _win_prev_ts is None:
1192
+ # First reading — store baseline, return 0
1193
+ _win_prev_cpu = cpu_total
1194
+ _win_prev_ts = now
1195
+ log.debug(f"Windows CPU baseline: {cpu_total:.2f}s (ppid={ppid})")
1196
+ return 0.0
1197
+ dt = now - _win_prev_ts
1198
+ dcpu = cpu_total - _win_prev_cpu
1199
+ _win_prev_cpu = cpu_total
1200
+ _win_prev_ts = now
1201
+ if dt <= 0:
1202
+ return 0.0
1203
+ pct = (dcpu / dt) * 100.0
1204
+ log.debug(f"Windows CPU: Δcpu={dcpu:.3f}s Δt={dt:.1f}s → {pct:.1f}%")
1205
+ return max(pct, 0.0)
1136
1206
  else:
1137
1207
  r = _sp.run(["ps", "-p", str(ppid), "-o", "%cpu"], capture_output=True, text=True, timeout=5)
1138
1208
  lines = [l.strip() for l in r.stdout.strip().split("\n") if l.strip()]
1139
1209
  return float(lines[-1]) if len(lines) > 1 else 0.0
1140
- except Exception:
1210
+ except Exception as e:
1211
+ log.debug(f"CPU detection failed: {e}")
1141
1212
  return 0.0
1142
1213
 
1143
1214
 
@@ -1171,6 +1242,15 @@ def _heartbeat_thread_fn():
1171
1242
  def _heartbeat_loop_inner():
1172
1243
  """Single iteration of the heartbeat loop. Separated so the outer
1173
1244
  wrapper can catch any exception and restart cleanly."""
1245
+ import platform as _pl
1246
+ _is_windows = _pl.system() == "Windows"
1247
+ if _is_windows:
1248
+ log.info(
1249
+ f"[WIN-DEBUG] heartbeat thread started on Windows — "
1250
+ f"agent={AGENT_NAME} ppid={os.getppid()} "
1251
+ f"loop_captured={_MAIN_LOOP is not None} "
1252
+ f"realtime={'connected' if _REALTIME and _REALTIME.is_connected else 'none/disconnected'}"
1253
+ )
1174
1254
  lease_counter = 0
1175
1255
  while not _heartbeat_stop.is_set():
1176
1256
  try:
@@ -1182,6 +1262,16 @@ def _heartbeat_loop_inner():
1182
1262
  in_wait = _IN_WAIT
1183
1263
  idle_secs = _time.time() - _last_tool_at
1184
1264
 
1265
+ if _is_windows and lease_counter % 12 == 0:
1266
+ # Periodic Windows debug dump (every ~60s on pro, ~180s on free)
1267
+ log.info(
1268
+ f"[WIN-DEBUG] cpu={parent_cpu:.1f}% state={cur_state} "
1269
+ f"in_wait={in_wait} idle={idle_secs:.0f}s "
1270
+ f"rt_conn={_REALTIME.is_connected if _REALTIME else 'N/A'} "
1271
+ f"rt_sub={_REALTIME.is_subscribed if _REALTIME else 'N/A'} "
1272
+ f"loop_running={_MAIN_LOOP.is_running() if _MAIN_LOOP else False}"
1273
+ )
1274
+
1185
1275
  if in_wait:
1186
1276
  # Actually in meshcode_wait right now — listening for messages
1187
1277
  if cur_state != "waiting":
@@ -1206,26 +1296,29 @@ def _heartbeat_loop_inner():
1206
1296
  try:
1207
1297
  be.set_status(_PROJECT_ID, AGENT_NAME, _current_state, _current_tool, api_key=_get_api_key())
1208
1298
  except Exception as e:
1209
- log.debug(f"status sync failed: {e}")
1299
+ log.warning(f"status sync failed (agent may show stale status): {e}")
1210
1300
  # Realtime subscription recovery: if the WebSocket is connected but
1211
1301
  # the channel subscription dropped (e.g. Supabase maintenance), or
1212
1302
  # the WebSocket itself disconnected, trigger a restart so agents
1213
1303
  # don't stay stuck in slow DB polling for the entire session.
1304
+ #
1305
+ # Use _MAIN_LOOP (captured at lifespan start) instead of
1306
+ # asyncio.get_event_loop() — on Windows the latter returns a new,
1307
+ # non-running loop inside daemon threads, silently preventing
1308
+ # Realtime restarts.
1214
1309
  if _REALTIME:
1215
1310
  if not _REALTIME.is_connected:
1216
1311
  log.warning("heartbeat ok (HTTP) but WebSocket disconnected — scheduling Realtime restart")
1217
1312
  try:
1218
- loop = asyncio.get_event_loop()
1219
- if loop.is_running():
1220
- asyncio.run_coroutine_threadsafe(_REALTIME.restart(), loop)
1313
+ if _MAIN_LOOP and _MAIN_LOOP.is_running():
1314
+ asyncio.run_coroutine_threadsafe(_REALTIME.restart(), _MAIN_LOOP)
1221
1315
  except Exception as e:
1222
1316
  log.debug(f"Realtime restart scheduling failed: {e}")
1223
1317
  elif not _REALTIME.is_subscribed:
1224
1318
  log.warning("WebSocket connected but subscription lost — scheduling Realtime restart")
1225
1319
  try:
1226
- loop = asyncio.get_event_loop()
1227
- if loop.is_running():
1228
- asyncio.run_coroutine_threadsafe(_REALTIME.restart(), loop)
1320
+ if _MAIN_LOOP and _MAIN_LOOP.is_running():
1321
+ asyncio.run_coroutine_threadsafe(_REALTIME.restart(), _MAIN_LOOP)
1229
1322
  except Exception as e:
1230
1323
  log.debug(f"Realtime restart scheduling failed: {e}")
1231
1324
  else:
@@ -1258,7 +1351,17 @@ def _heartbeat_loop_inner():
1258
1351
  @asynccontextmanager
1259
1352
  async def lifespan(_app):
1260
1353
  """Start the Realtime listener when the MCP server boots; stop on shutdown."""
1261
- global _REALTIME
1354
+ global _REALTIME, _MAIN_LOOP
1355
+ _MAIN_LOOP = asyncio.get_running_loop()
1356
+
1357
+ import platform as _pl
1358
+ if _pl.system() == "Windows":
1359
+ log.info(
1360
+ f"[WIN-DEBUG] lifespan start — loop={type(_MAIN_LOOP).__name__} "
1361
+ f"policy={type(asyncio.get_event_loop_policy()).__name__} "
1362
+ f"agent={AGENT_NAME}"
1363
+ )
1364
+
1262
1365
  _REALTIME = RealtimeListener(
1263
1366
  supabase_url=be.SUPABASE_URL,
1264
1367
  supabase_key=be.SUPABASE_KEY,
@@ -1914,7 +2017,6 @@ async def _meshcode_wait_inner(actual_timeout: int, include_acks: bool) -> Dict[
1914
2017
  _mark_realtime_msgs_read_in_db(deduped)
1915
2018
  out: Dict[str, Any] = {
1916
2019
  "got_message": True,
1917
- "source": "realtime",
1918
2020
  **split,
1919
2021
  }
1920
2022
  done = _detect_global_done(deduped)
@@ -1971,7 +2073,7 @@ async def _meshcode_wait_inner(actual_timeout: int, include_acks: bool) -> Dict[
1971
2073
  if not include_acks:
1972
2074
  split["acks"] = []
1973
2075
  if split["messages"] or split["done_signals"]:
1974
- return {"got_message": True, "source": "db_poll_fallback", **split}
2076
+ return {"got_message": True, **split}
1975
2077
  except Exception as e:
1976
2078
  log.debug(f"DB poll fallback error: {e}")
1977
2079
 
@@ -1995,7 +2097,7 @@ async def _meshcode_wait_inner(actual_timeout: int, include_acks: bool) -> Dict[
1995
2097
  if not include_acks:
1996
2098
  split["acks"] = []
1997
2099
  if split["messages"] or split["done_signals"]:
1998
- return {"got_message": True, "source": "db_fallback", **split}
2100
+ return {"got_message": True, **split}
1999
2101
  except Exception as e:
2000
2102
  log.debug(f"final DB fallback error: {e}")
2001
2103
 
@@ -2081,10 +2183,6 @@ def meshcode_check(include_acks: bool = False, since: Optional[str] = None) -> D
2081
2183
  split["acks"] = []
2082
2184
  result = {
2083
2185
  "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
2186
  **split,
2089
2187
  }
2090
2188
  # Auto-inject pending tasks
@@ -2100,7 +2198,6 @@ def meshcode_status() -> Dict[str, Any]:
2100
2198
  """List all agents with status, role, and current task."""
2101
2199
  agents = be.get_board(_PROJECT_ID)
2102
2200
  return {
2103
- "project": PROJECT_NAME,
2104
2201
  "agents": [
2105
2202
  {
2106
2203
  "name": a["name"],
@@ -2871,7 +2968,7 @@ def meshcode_recall(key: Optional[str] = None) -> Dict[str, Any]:
2871
2968
  if isinstance(reference, dict) and reference.get("ok"):
2872
2969
  ref_keys = [m.get("key") for m in reference.get("memories", [])]
2873
2970
  critical["reference_keys"] = ref_keys
2874
- critical["tip"] = "Use meshcode_recall_search(query) to search episodic memories."
2971
+ # tip removed already in system prompt, saves ~20 tokens per call
2875
2972
  return critical
2876
2973
 
2877
2974
 
@@ -2931,6 +3028,25 @@ def meshcode_recall_search(query: str) -> Dict[str, Any]:
2931
3028
  })
2932
3029
 
2933
3030
 
3031
+ # ----------------- BUG REPORTING -----------------
3032
+
3033
+ @mcp.tool()
3034
+ @with_working_status
3035
+ def meshcode_report_bug(description: str) -> Dict[str, Any]:
3036
+ """Report a bug from within the mesh. Visible in admin panel.
3037
+
3038
+ Args:
3039
+ description: What went wrong — include steps to reproduce if possible.
3040
+ """
3041
+ api_key = _get_api_key()
3042
+ return be.sb_rpc("mc_report_bug", {
3043
+ "p_api_key": api_key,
3044
+ "p_description": description,
3045
+ "p_meshwork_name": PROJECT_NAME,
3046
+ "p_agent_name": AGENT_NAME,
3047
+ })
3048
+
3049
+
2934
3050
  # ----------------- HEALTH CHECK -----------------
2935
3051
 
2936
3052
  @mcp.tool()
@@ -2938,8 +3054,6 @@ def meshcode_health() -> Dict[str, Any]:
2938
3054
  """Check MCP server health: DB connectivity, Realtime status, circuit breaker state, uptime."""
2939
3055
  import time as _t
2940
3056
  health: Dict[str, Any] = {
2941
- "agent": AGENT_NAME,
2942
- "project": PROJECT_NAME,
2943
3057
  "instance_id": _INSTANCE_ID,
2944
3058
  "sdk_version": _SDK_VERSION,
2945
3059
  }
@@ -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,21 +351,65 @@ 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
+ print(f"[meshcode] bypass-detect: checking '{editor_cmd}' (shell={use_shell})", file=sys.stderr)
367
+ r = subprocess.run(
368
+ [editor_cmd, "--help"],
369
+ capture_output=True, text=True, timeout=10,
370
+ shell=use_shell,
371
+ )
372
+ found = "--dangerously-skip-permissions" in r.stdout
373
+ if found:
374
+ print(f"[meshcode] bypass-detect: ✓ flag found — bypass mode available", file=sys.stderr)
375
+ else:
376
+ stdout_len = len(r.stdout)
377
+ stderr_preview = (r.stderr or "")[:200]
378
+ print(f"[meshcode] bypass-detect: ✗ flag NOT found in --help output ({stdout_len} chars, exit={r.returncode})", file=sys.stderr)
379
+ if stderr_preview:
380
+ print(f"[meshcode] bypass-detect: stderr: {stderr_preview}", file=sys.stderr)
381
+ return found
382
+ except subprocess.TimeoutExpired:
383
+ print(f"[meshcode] bypass-detect: ✗ '{editor_cmd} --help' timed out after 10s", file=sys.stderr)
384
+ return False
385
+ except FileNotFoundError:
386
+ print(f"[meshcode] bypass-detect: ✗ '{editor_cmd}' not found in PATH", file=sys.stderr)
387
+ return False
388
+ except Exception as e:
389
+ print(f"[meshcode] bypass-detect: ✗ unexpected error: {e}", file=sys.stderr)
390
+ return False
391
+
392
+
354
393
  def _detect_editor() -> Optional[str]:
355
394
  """Pick the user's preferred MCP-aware editor."""
356
395
  override = os.environ.get("MESHCODE_EDITOR", "").strip().lower()
357
396
  if override:
358
- if shutil.which(override):
359
- return override
397
+ resolved = shutil.which(override)
398
+ if resolved:
399
+ # On Windows, return resolved path to preserve .cmd/.bat extension
400
+ return resolved if sys.platform == "win32" else override
360
401
  print(f"[meshcode] WARNING: MESHCODE_EDITOR='{override}' not found in PATH", file=sys.stderr)
361
402
 
362
403
  # Detection order: most likely to be installed + best per-workspace MCP
363
404
  # support first. codex is last because its MCP config is GLOBAL only —
364
405
  # launching it doesn't take a workspace-scoped .mcp.json, so it should
365
406
  # only be picked if nothing else is available.
407
+ # On Windows, return the fully-resolved path so .cmd/.bat extension is
408
+ # preserved — needed for shell=True detection in bypass and launch paths.
366
409
  for cmd in ("claude", "cursor", "code", "windsurf", "codex"):
367
- if shutil.which(cmd):
368
- return cmd
410
+ resolved = shutil.which(cmd)
411
+ if resolved:
412
+ return resolved if sys.platform == "win32" else cmd
369
413
 
370
414
  # Windows fallback: check common install locations not in PATH
371
415
  if sys.platform == "win32":
@@ -397,6 +441,77 @@ def _detect_editor() -> Optional[str]:
397
441
  return None
398
442
 
399
443
 
444
+ def _preflight_heartbeat(agent: str, project: str) -> None:
445
+ """Send a heartbeat + set status before the editor launches.
446
+
447
+ On Windows without bypass mode, Claude Code may delay spawning the
448
+ MCP server until the user approves the first tool call. During that
449
+ gap the agent shows as 'offline' in the dashboard. This direct RPC
450
+ call ensures the agent appears online immediately.
451
+
452
+ Best-effort: any failure is logged and swallowed — it must never
453
+ block or crash the launch.
454
+ """
455
+ try:
456
+ from .setup_clients import _load_supabase_env
457
+ import importlib
458
+ secrets_mod = importlib.import_module("meshcode.secrets")
459
+ from urllib.request import Request, urlopen
460
+
461
+ profile = os.environ.get("MESHCODE_KEYCHAIN_PROFILE") or "default"
462
+ api_key = secrets_mod.get_api_key(profile=profile)
463
+ if not api_key:
464
+ return
465
+
466
+ sb = _load_supabase_env()
467
+ headers = {
468
+ "apikey": sb["SUPABASE_KEY"],
469
+ "Authorization": f"Bearer {sb['SUPABASE_KEY']}",
470
+ "Content-Type": "application/json",
471
+ "Content-Profile": "meshcode",
472
+ }
473
+
474
+ # Resolve project_id
475
+ resolve_body = json.dumps({"p_api_key": api_key, "p_project_name": project})
476
+ req = Request(
477
+ f"{sb['SUPABASE_URL']}/rest/v1/rpc/mc_resolve_project",
478
+ data=resolve_body.encode(), method="POST", headers=headers,
479
+ )
480
+ with urlopen(req, timeout=5) as resp:
481
+ proj_data = json.loads(resp.read().decode())
482
+ project_id = proj_data.get("project_id") if proj_data else None
483
+ if not project_id:
484
+ return
485
+
486
+ # Send heartbeat
487
+ hb_body = json.dumps({"p_project_id": project_id, "p_agent_name": agent})
488
+ hb_req = Request(
489
+ f"{sb['SUPABASE_URL']}/rest/v1/rpc/mc_heartbeat",
490
+ data=hb_body.encode(), method="POST", headers=headers,
491
+ )
492
+ with urlopen(hb_req, timeout=5) as resp:
493
+ resp.read()
494
+
495
+ # Set status to 'online'
496
+ status_body = json.dumps({
497
+ "p_api_key": api_key,
498
+ "p_project_id": project_id,
499
+ "p_agent_name": agent,
500
+ "p_status": "online",
501
+ "p_task": "launching editor",
502
+ })
503
+ status_req = Request(
504
+ f"{sb['SUPABASE_URL']}/rest/v1/rpc/mc_agent_set_status_by_api_key",
505
+ data=status_body.encode(), method="POST", headers=headers,
506
+ )
507
+ with urlopen(status_req, timeout=5) as resp:
508
+ resp.read()
509
+
510
+ print(f"[meshcode] Pre-flight heartbeat sent — agent visible in dashboard")
511
+ except Exception as e:
512
+ print(f"[meshcode] Pre-flight heartbeat skipped: {e}", file=sys.stderr)
513
+
514
+
400
515
  def run(agent: str, project: Optional[str] = None, editor_override: Optional[str] = None, permission_override: Optional[str] = None) -> int:
401
516
  """Launch the user's editor with ONLY the named agent's MCP server loaded."""
402
517
  # Non-blocking self-update check (consumes prior result, may spawn bg pip)
@@ -509,7 +624,22 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
509
624
  # ── Welcome banner with unique ASCII art + personality ──────────
510
625
  try:
511
626
  from .ascii_art import generate_art, render_welcome
512
- from . import __version__ as cli_version
627
+ cli_version = ""
628
+ try:
629
+ from . import __version__ as cli_version
630
+ except ImportError:
631
+ try:
632
+ from meshcode import __version__ as cli_version
633
+ except ImportError:
634
+ # Last resort: read __version__ directly from __init__.py
635
+ try:
636
+ _init_path = Path(__file__).resolve().parent / "__init__.py"
637
+ for _line in _init_path.read_text(encoding="utf-8").splitlines():
638
+ if _line.startswith("__version__"):
639
+ cli_version = _line.split("=", 1)[1].strip().strip('"').strip("'")
640
+ break
641
+ except Exception:
642
+ pass
513
643
  ascii_art, agent_role, profile_color = _fetch_or_generate_art(agent, resolved_project)
514
644
  _leader_haystack = (agent + ' ' + agent_role).lower()
515
645
  _LEADER_KW = ('commander', 'lead', 'orchestrat', 'coordinator', 'coordinat',
@@ -521,9 +651,11 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
521
651
  agent, resolved_project, ascii_art, cli_version,
522
652
  is_commander=is_cmd, role=agent_role, stats=agent_stats,
523
653
  profile_color=profile_color,
524
- ))
525
- except Exception:
526
- pass # Non-critical skip banner on error
654
+ ), file=sys.stderr, flush=True)
655
+ except Exception as _banner_err:
656
+ import traceback as _tb
657
+ print(f"[meshcode] WARN: banner failed: {_banner_err}", file=sys.stderr)
658
+ _tb.print_exc(file=sys.stderr)
527
659
 
528
660
  print(f"[meshcode] Launching {editor} for agent '{agent}' (project: {resolved_project})")
529
661
  print(f"[meshcode] Workspace: {ws}")
@@ -545,10 +677,17 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
545
677
  # MCP" bucket and survive ESC. Verified with a minimal FastMCP
546
678
  # repro side-by-side: Built-in dies, Local survives, same SDK code.
547
679
  mode = resolve_permission_mode(permission_override)
680
+ print(f"[meshcode] Editor resolved: {editor}", file=sys.stderr)
681
+ print(f"[meshcode] Permission mode resolved: {mode}", file=sys.stderr)
548
682
  cmd = [editor]
549
683
  if mode == "bypass":
550
- cmd.append("--dangerously-skip-permissions")
551
- print(f"[meshcode] Permission mode: bypass (autonomous loop, no prompts)")
684
+ if _claude_supports_bypass(editor):
685
+ cmd.append("--dangerously-skip-permissions")
686
+ print(f"[meshcode] Permission mode: bypass (autonomous loop, no prompts)")
687
+ else:
688
+ print(f"[meshcode] Permission mode: bypass requested but --dangerously-skip-permissions")
689
+ print(f"[meshcode] not supported by this Claude Code version. Agent will prompt for tools.")
690
+ print(f"[meshcode] Upgrade Claude Code: npm install -g @anthropic-ai/claude-code@latest")
552
691
  else:
553
692
  print(f"[meshcode] Permission mode: safe (Claude will prompt for every tool call)")
554
693
  print(f"[meshcode] Tip: change with `meshcode prefs permission-mode bypass`")
@@ -557,10 +696,12 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
557
696
  # messages, claim tasks, greet peers) without waiting for user input.
558
697
  # Uses -- to separate options from the positional prompt argument.
559
698
  cmd.extend(["--", "boot"])
699
+ if not os.path.isdir(ws):
700
+ print(f"[meshcode] WARNING: workspace dir does not exist: {ws}", file=sys.stderr)
560
701
  try:
561
702
  os.chdir(ws)
562
- except Exception:
563
- pass
703
+ except Exception as e:
704
+ print(f"[meshcode] WARNING: chdir to workspace failed: {e}", file=sys.stderr)
564
705
  elif editor == "cursor":
565
706
  # Cursor reads .cursor/mcp.json from the workspace cwd.
566
707
  cmd = [editor, str(ws)]
@@ -575,10 +716,12 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
575
716
  # Codex CLI reads ~/.codex/config.toml globally. There's no per-
576
717
  # workspace flag, so launching is just `codex` with cwd set to the
577
718
  # workspace dir so any file ops the agent does land there.
719
+ if not os.path.isdir(ws):
720
+ print(f"[meshcode] WARNING: workspace dir does not exist: {ws}", file=sys.stderr)
578
721
  try:
579
722
  os.chdir(ws)
580
- except Exception:
581
- pass
723
+ except Exception as e:
724
+ print(f"[meshcode] WARNING: chdir to workspace failed: {e}", file=sys.stderr)
582
725
  cmd = [editor]
583
726
  else:
584
727
  cmd = [editor, str(ws)]
@@ -598,11 +741,39 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
598
741
  except OSError:
599
742
  pass # No devnull (unlikely) — let the editor handle it
600
743
 
744
+ # Pre-flight heartbeat: mark the agent as online BEFORE the editor
745
+ # subprocess spawns. On Windows without bypass mode, Claude Code may
746
+ # delay starting the MCP server until the user interacts — leaving the
747
+ # agent stuck as 'offline' in the dashboard. This direct RPC call
748
+ # ensures immediate visibility regardless of MCP spawn timing.
749
+ _preflight_heartbeat(agent, resolved_project)
750
+
751
+ # Flush all output before exec replaces this process — execvp does NOT
752
+ # flush Python file buffers, so any buffered stdout (e.g. the banner)
753
+ # would be silently lost.
754
+ sys.stdout.flush()
755
+ sys.stderr.flush()
756
+
757
+ # Effective cwd for the editor — log it for debugging (especially
758
+ # Windows where shell=True + os.chdir may not propagate).
759
+ effective_cwd = os.getcwd()
760
+ print(f"[meshcode] Effective cwd: {effective_cwd}")
761
+
601
762
  try:
602
763
  if sys.platform == "win32":
603
- # Windows: no execvp, use subprocess and wait
764
+ # Windows: no execvp, use subprocess and wait.
765
+ # .cmd/.bat files need shell=True for proper argument passing —
766
+ # without it, flags like --dangerously-skip-permissions can be
767
+ # mangled by Windows' CreateProcess argument splitting.
768
+ # Explicit cwd=str(ws) ensures the child process starts in the
769
+ # workspace even if os.chdir() didn't stick (shell=True can
770
+ # reset cwd via cmd.exe).
604
771
  import subprocess as _sp
605
- result = _sp.run(cmd)
772
+ use_shell = cmd[0].lower().endswith((".cmd", ".bat"))
773
+ if use_shell:
774
+ print(f"[meshcode] (Windows: launching via shell for .cmd wrapper)")
775
+ launch_cwd = str(ws) if os.path.isdir(ws) else None
776
+ result = _sp.run(cmd, shell=use_shell, cwd=launch_cwd)
606
777
  sys.exit(result.returncode)
607
778
  else:
608
779
  # Unix: replace this process with the editor
@@ -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.41
3
+ Version: 2.10.43
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.43"
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