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.
- {meshcode-2.10.41 → meshcode-2.10.42}/PKG-INFO +1 -1
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/__init__.py +1 -1
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/comms_v4.py +26 -12
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/meshcode_mcp/backend.py +144 -20
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/meshcode_mcp/server.py +118 -25
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/preferences.py +22 -5
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/run_agent.py +159 -12
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode.egg-info/PKG-INFO +1 -1
- {meshcode-2.10.41 → meshcode-2.10.42}/pyproject.toml +1 -1
- {meshcode-2.10.41 → meshcode-2.10.42}/README.md +0 -0
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/ascii_art.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/cli.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/invites.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/launcher.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/launcher_install.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/meshcode_mcp/__init__.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/meshcode_mcp/__main__.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/meshcode_mcp/realtime.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/meshcode_mcp/test_backend.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/meshcode_mcp/test_realtime.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/protocol_v2.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/secrets.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/self_update.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode/setup_clients.py +0 -0
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode.egg-info/SOURCES.txt +0 -0
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode.egg-info/dependency_links.txt +0 -0
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode.egg-info/entry_points.txt +0 -0
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode.egg-info/requires.txt +0 -0
- {meshcode-2.10.41 → meshcode-2.10.42}/meshcode.egg-info/top_level.txt +0 -0
- {meshcode-2.10.41 → meshcode-2.10.42}/setup.cfg +0 -0
- {meshcode-2.10.41 → meshcode-2.10.42}/tests/test_status_enum_coverage.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""MeshCode — Real-time communication between AI agents."""
|
|
2
|
-
__version__ = "2.10.
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
f
|
|
328
|
-
f
|
|
329
|
-
|
|
330
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
self.
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
660
|
-
|
|
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
|
|
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(
|
|
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
|
-
"""
|
|
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
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
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.
|
|
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
|
-
|
|
1219
|
-
|
|
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
|
-
|
|
1227
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
551
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|