meshcode 2.10.57__tar.gz → 2.10.59__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 (46) hide show
  1. {meshcode-2.10.57 → meshcode-2.10.59}/PKG-INFO +1 -1
  2. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/__init__.py +1 -1
  3. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/comms_v4.py +158 -23
  4. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/invites.py +2 -2
  5. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/meshcode_mcp/backend.py +70 -19
  6. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/meshcode_mcp/realtime.py +33 -5
  7. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/meshcode_mcp/server.py +193 -619
  8. meshcode-2.10.59/meshcode/supervisor.py +186 -0
  9. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode.egg-info/PKG-INFO +1 -1
  10. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode.egg-info/SOURCES.txt +6 -0
  11. {meshcode-2.10.57 → meshcode-2.10.59}/pyproject.toml +5 -1
  12. {meshcode-2.10.57 → meshcode-2.10.59}/tests/test_core.py +3 -3
  13. meshcode-2.10.59/tests/test_mark_read_batch.py +200 -0
  14. meshcode-2.10.59/tests/test_migration_integrity.py +176 -0
  15. meshcode-2.10.59/tests/test_rls_cross_tenant.py +255 -0
  16. {meshcode-2.10.57 → meshcode-2.10.59}/tests/test_rpc_migrations.py +6 -4
  17. meshcode-2.10.59/tests/test_security_regressions.py +171 -0
  18. meshcode-2.10.59/tests/test_sentinel.py +148 -0
  19. {meshcode-2.10.57 → meshcode-2.10.59}/README.md +0 -0
  20. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/ascii_art.py +0 -0
  21. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/cli.py +0 -0
  22. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/compat.py +0 -0
  23. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/exceptions.py +0 -0
  24. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/launcher.py +0 -0
  25. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/launcher_install.py +0 -0
  26. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/meshcode_mcp/__init__.py +0 -0
  27. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/meshcode_mcp/__main__.py +0 -0
  28. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/meshcode_mcp/test_backend.py +0 -0
  29. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/meshcode_mcp/test_realtime.py +0 -0
  30. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
  31. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/preferences.py +0 -0
  32. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/protocol_v2.py +0 -0
  33. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/run_agent.py +0 -0
  34. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/secrets.py +0 -0
  35. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/self_update.py +0 -0
  36. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/setup_clients.py +0 -0
  37. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode.egg-info/dependency_links.txt +0 -0
  38. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode.egg-info/entry_points.txt +0 -0
  39. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode.egg-info/requires.txt +0 -0
  40. {meshcode-2.10.57 → meshcode-2.10.59}/meshcode.egg-info/top_level.txt +0 -0
  41. {meshcode-2.10.57 → meshcode-2.10.59}/setup.cfg +0 -0
  42. {meshcode-2.10.57 → meshcode-2.10.59}/tests/test_cross_agent_messaging.py +0 -0
  43. {meshcode-2.10.57 → meshcode-2.10.59}/tests/test_esc_deaf_state.py +0 -0
  44. {meshcode-2.10.57 → meshcode-2.10.59}/tests/test_exceptions.py +0 -0
  45. {meshcode-2.10.57 → meshcode-2.10.59}/tests/test_realtime_event_freshness.py +0 -0
  46. {meshcode-2.10.57 → meshcode-2.10.59}/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.57
3
+ Version: 2.10.59
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,5 +1,5 @@
1
1
  """MeshCode — Real-time communication between AI agents."""
2
- __version__ = "2.10.57"
2
+ __version__ = "2.10.59"
3
3
 
4
4
  # Exception hierarchy — eagerly imported (lightweight, no deps)
5
5
  from meshcode.exceptions import ( # noqa: F401
@@ -118,14 +118,14 @@ def _request(method, path, *, data=None, prefer=None):
118
118
  msg = err_obj.get("message", err[:200])
119
119
  # Daily message limit trigger raises a friendly message
120
120
  if "Daily message limit reached" in msg:
121
- print(f"[QUOTA] {msg}", file=sys.stderr)
121
+ print(f"[meshcode] QUOTA: {msg}", file=sys.stderr)
122
122
  else:
123
123
  print(f"[meshcode] ERROR: {method} {path}: {e.code} {msg}", file=sys.stderr)
124
124
  except Exception:
125
125
  print(f"[meshcode] ERROR: {method} {path}: {e.code} {err[:200]}", file=sys.stderr)
126
126
  return None
127
127
  except URLError as e:
128
- print(f"[meshcode] ERROR: Network: {e.reason}", file=sys.stderr)
128
+ print(f"[meshcode] ERROR: network: {e.reason}", file=sys.stderr)
129
129
  return None
130
130
 
131
131
 
@@ -188,11 +188,11 @@ def sb_rpc(fn_name, params):
188
188
  msg = err_obj.get("message", err_body[:200])
189
189
  except Exception:
190
190
  msg = err_body[:200] if err_body else str(e)
191
- print(f"[ERROR] rpc:{fn_name}: {e.code} {msg}", file=sys.stderr)
191
+ print(f"[meshcode] ERROR: rpc:{fn_name}: {e.code} {msg}", file=sys.stderr)
192
192
  log_error(f"rpc:{fn_name}", f"{e.code} {msg}", json.dumps(params, default=str)[:200])
193
193
  return None
194
194
  except URLError as e:
195
- print(f"[ERROR] rpc:{fn_name}: network error: {e.reason}", file=sys.stderr)
195
+ print(f"[meshcode] ERROR: rpc:{fn_name}: network error: {e.reason}", file=sys.stderr)
196
196
  log_error(f"rpc:{fn_name}", f"network: {e.reason}", json.dumps(params, default=str)[:200])
197
197
  return None
198
198
 
@@ -2028,16 +2028,26 @@ def cmd_doctor(flags, pos):
2028
2028
  api_key = _load_api_key_for_cli()
2029
2029
  if api_key:
2030
2030
  try:
2031
- r = sb_rpc("mc_resolve_project", {"p_api_key": api_key, "p_project_name": "*"})
2032
2031
  # Try listing all projects for this user
2033
- projects_data = sb_rpc("mc_list_user_projects", {"p_api_key": api_key})
2032
+ raw = sb_rpc("mc_list_user_projects", {"p_api_key": api_key})
2033
+ # RPC returns {"projects": [...]} or a list directly
2034
+ if isinstance(raw, dict) and "projects" in raw:
2035
+ projects_data = raw["projects"]
2036
+ elif isinstance(raw, list):
2037
+ projects_data = raw
2038
+ else:
2039
+ projects_data = None
2034
2040
  if isinstance(projects_data, list):
2035
2041
  stale_agents = [] # (project_id, agent_name)
2036
2042
  for proj in projects_data[:5]:
2037
2043
  pid = proj.get("id") or proj.get("project_id")
2038
2044
  if not pid:
2039
2045
  continue
2040
- agents = sb_select("mc_agents", f"project_id=eq.{pid}")
2046
+ # mc_list_user_projects includes agents inline
2047
+ agents = proj.get("agents", [])
2048
+ if not agents:
2049
+ # Fallback: fetch from table if RPC didn't include agents
2050
+ agents = sb_select("mc_agents", f"project_id=eq.{pid}")
2041
2051
  if isinstance(agents, list):
2042
2052
  for a in agents:
2043
2053
  hb = a.get("last_heartbeat", "")
@@ -2230,6 +2240,7 @@ MESSAGING:
2230
2240
 
2231
2241
  STATUS:
2232
2242
  board <proj> Agent status board
2243
+ update <proj> <name> <status> [task] Update agent status
2233
2244
  status [proj] Overview
2234
2245
  projects | list | ls List your meshworks
2235
2246
 
@@ -2260,12 +2271,12 @@ AGENT CONTROL:
2260
2271
  DIAGNOSTICS:
2261
2272
  doctor [--fix] Diagnose setup issues
2262
2273
  compat Claude Code version compatibility
2274
+ upgrade Upgrade meshcode to latest version
2263
2275
 
2264
2276
  ADMIN:
2265
2277
  clear <proj> <name> Clear inbox
2266
2278
  unregister <proj> <name> Leave project
2267
2279
  prefs View/set preferences
2268
- update Check for package updates
2269
2280
 
2270
2281
  PROFILES (multi-account):
2271
2282
  profiles List stored keychain profiles
@@ -2289,6 +2300,14 @@ an account, then prompts you to paste your API key.
2289
2300
 
2290
2301
  After init, run:
2291
2302
  meshcode go <agent-name> --project <meshwork-name>
2303
+ """,
2304
+ "upgrade": """meshcode upgrade
2305
+
2306
+ Check PyPI for the latest version and upgrade interactively.
2307
+ Supports both pip and pipx installs.
2308
+
2309
+ EXAMPLES:
2310
+ meshcode upgrade # check + prompt to upgrade
2292
2311
  """,
2293
2312
  "doctor": """meshcode doctor [--fix]
2294
2313
 
@@ -2589,19 +2608,63 @@ if __name__ == "__main__":
2589
2608
  pass
2590
2609
 
2591
2610
  if cmd == "init":
2592
- # Guided onboarding: open signup page + prompt for API key
2611
+ # Guided onboarding: try zero-config pairing first, then manual fallback
2593
2612
  import webbrowser
2613
+ import time as _t
2594
2614
  print()
2595
2615
  print(" Welcome to MeshCode!")
2596
2616
  print(" " + "=" * 40)
2597
2617
  print()
2598
- print(" Step 1: Create your account")
2599
- print(" Opening meshcode.io in your browser...")
2600
- print()
2618
+
2619
+ # Try device-code pairing first
2620
+ pairing = sb_rpc("mc_create_pairing_session", {})
2621
+ if pairing and isinstance(pairing, dict) and pairing.get("ok"):
2622
+ session_id = pairing["session_id"]
2623
+ code = pairing["code"]
2624
+ url = pairing["url"]
2625
+ print(f" Step 1: Open this link in your browser:")
2626
+ print(f" {url}")
2627
+ print()
2628
+ print(f" Or go to meshcode.io/pair and enter code: {code}")
2629
+ print()
2630
+ try:
2631
+ webbrowser.open(url)
2632
+ except Exception:
2633
+ pass
2634
+ print(" Waiting for approval", end="", flush=True)
2635
+ api_key = None
2636
+ for _ in range(150): # 150 * 2s = 5 minutes
2637
+ _t.sleep(2)
2638
+ print(".", end="", flush=True)
2639
+ poll = sb_rpc("mc_poll_pairing_session", {"p_session_id": session_id})
2640
+ if poll and isinstance(poll, dict):
2641
+ if poll.get("status") == "approved" and poll.get("api_key"):
2642
+ api_key = poll["api_key"]
2643
+ break
2644
+ elif poll.get("status") in ("expired", "consumed"):
2645
+ break
2646
+ print()
2647
+ if api_key:
2648
+ print()
2649
+ login(api_key)
2650
+ print()
2651
+ print(" Step 2: Create your first meshwork")
2652
+ print(" Run: meshcode go <agent-name> --project <meshwork-name>")
2653
+ print()
2654
+ print(" Example:")
2655
+ print(" meshcode go backend --project my-app")
2656
+ print()
2657
+ sys.exit(0)
2658
+ else:
2659
+ print(" Pairing timed out or was not approved.")
2660
+ print()
2661
+
2662
+ # Fallback: manual API key paste
2663
+ print(" Step 1: Create your account at meshcode.io")
2601
2664
  try:
2602
2665
  webbrowser.open("https://meshcode.io")
2603
2666
  except Exception:
2604
- print(" Could not open browser. Visit: https://meshcode.io")
2667
+ print(" Visit: https://meshcode.io")
2605
2668
  print(" Step 2: Copy your API key from Settings")
2606
2669
  print()
2607
2670
  try:
@@ -2625,9 +2688,9 @@ if __name__ == "__main__":
2625
2688
 
2626
2689
  # Auth guard: commands that talk to Supabase need a valid API key.
2627
2690
  # doctor, help, version, login, prefs, launcher don't need auth.
2628
- _NO_AUTH_CMDS = {"doctor", "compat", "help", "--help", "-h", "login", "init",
2629
- "prefs", "launcher", "--version", "-V", "version", "whoami",
2630
- "profiles", "scan"}
2691
+ _NO_AUTH_CMDS = {"doctor", "compat", "upgrade", "help", "--help", "-h", "login",
2692
+ "init", "prefs", "launcher", "--version", "-V", "version",
2693
+ "whoami", "profiles", "scan"}
2631
2694
  if cmd not in _NO_AUTH_CMDS:
2632
2695
  _cli_key = _load_api_key_for_cli()
2633
2696
  if not _cli_key:
@@ -2719,6 +2782,9 @@ if __name__ == "__main__":
2719
2782
  show_board(proj)
2720
2783
 
2721
2784
  elif cmd == "update":
2785
+ if len(pos) < 3 and not flags.get("project"):
2786
+ print(HELP_TEXTS.get("update", "meshcode update <project> <name> <status> [task]"))
2787
+ sys.exit(0)
2722
2788
  proj = flags.get("project", pos[0] if len(pos) > 0 else "default")
2723
2789
  name = flags.get("name", pos[1] if len(pos) > 1 else "agent")
2724
2790
  st = flags.get("status", pos[2] if len(pos) > 2 else "online")
@@ -2753,7 +2819,7 @@ if __name__ == "__main__":
2753
2819
  elif cmd == "validate-sessions":
2754
2820
  proj = pos[0] if len(pos) > 0 else (sys.argv[2] if len(sys.argv) > 2 else "")
2755
2821
  if not proj:
2756
- print("[ERROR] Usage: meshcode validate-sessions <project>")
2822
+ print("[meshcode] ERROR: Usage: meshcode validate-sessions <project>")
2757
2823
  sys.exit(1)
2758
2824
  ensure_sessions()
2759
2825
  prefix = f"{proj}_"
@@ -2779,7 +2845,7 @@ if __name__ == "__main__":
2779
2845
  proj = pos[0] if len(pos) > 0 else ""
2780
2846
  name = pos[1] if len(pos) > 1 else ""
2781
2847
  if not proj or not name:
2782
- print("[ERROR] Usage: meshcode wake-headless <project> <agent>")
2848
+ print("[meshcode] ERROR: Usage: meshcode wake-headless <project> <agent>")
2783
2849
  sys.exit(1)
2784
2850
  project_id = get_project_id(proj)
2785
2851
  if not project_id:
@@ -2795,7 +2861,7 @@ if __name__ == "__main__":
2795
2861
  proj = pos[0] if len(pos) > 0 else "default"
2796
2862
  name = pos[1] if len(pos) > 1 else ""
2797
2863
  if not name:
2798
- print(f"[ERROR] Usage: meshcode {cmd} <project> <agent>")
2864
+ print(f"[meshcode] ERROR: Usage: meshcode {cmd} <project> <agent>")
2799
2865
  sys.exit(1)
2800
2866
  project_id = get_project_id(proj)
2801
2867
  if not project_id:
@@ -2814,7 +2880,7 @@ if __name__ == "__main__":
2814
2880
  proj = pos[1] if len(pos) > 1 else "default"
2815
2881
  name = pos[2] if len(pos) > 2 else ""
2816
2882
  if not name:
2817
- print("[ERROR] Usage: meshcode profile get|set <project> <agent> [flags]")
2883
+ print("[meshcode] ERROR: Usage: meshcode profile get|set <project> <agent> [flags]")
2818
2884
  sys.exit(1)
2819
2885
  project_id = get_project_id(proj)
2820
2886
  if not project_id:
@@ -2839,7 +2905,7 @@ if __name__ == "__main__":
2839
2905
  sys.exit(1)
2840
2906
  print(f"[{proj}] profile updated for {name}")
2841
2907
  else:
2842
- print(f"[ERROR] Unknown subcommand: {sub}. Use 'get' or 'set'.")
2908
+ print(f"[meshcode] ERROR: Unknown subcommand: {sub}. Use 'get' or 'set'.")
2843
2909
  sys.exit(1)
2844
2910
 
2845
2911
  elif cmd == "connect":
@@ -3027,7 +3093,7 @@ if __name__ == "__main__":
3027
3093
  elif cmd == "login":
3028
3094
  key = sys.argv[2] if len(sys.argv) > 2 else ""
3029
3095
  if not key:
3030
- print("[ERROR] Usage: meshcode login <api_key>")
3096
+ print("[meshcode] ERROR: Usage: meshcode login <api_key>")
3031
3097
  sys.exit(1)
3032
3098
  login(key)
3033
3099
 
@@ -3192,6 +3258,49 @@ if __name__ == "__main__":
3192
3258
  print(" meshcode prefs reset")
3193
3259
  sys.exit(1)
3194
3260
 
3261
+ elif cmd == "upgrade":
3262
+ # meshcode upgrade — interactive foreground upgrade
3263
+ import importlib
3264
+ _su = importlib.import_module("meshcode.self_update")
3265
+ cur = _su._current_version()
3266
+ print(f"\n meshcode upgrade")
3267
+ print(f" {'=' * 40}")
3268
+ print(f" Current version: {cur}")
3269
+ latest = _su.fetch_latest_version(timeout=5.0)
3270
+ if not latest:
3271
+ print(f" Could not reach PyPI. Check your internet connection.")
3272
+ sys.exit(1)
3273
+ if not _su._is_newer(latest, cur):
3274
+ print(f" Already up to date ({cur}).")
3275
+ sys.exit(0)
3276
+ print(f" Latest version: {latest}")
3277
+ print()
3278
+ try:
3279
+ confirm = input(f" Upgrade to {latest}? [Y/n] ").strip().lower()
3280
+ except (EOFError, KeyboardInterrupt):
3281
+ confirm = "n"
3282
+ print()
3283
+ if confirm in ("", "y", "yes"):
3284
+ import subprocess as _sp
3285
+ print(f" Installing meshcode=={latest}...")
3286
+ mode = "pipx" if _su.is_pipx_install() else "pip"
3287
+ if mode == "pipx":
3288
+ cmd_list = ["pipx", "upgrade", "meshcode"]
3289
+ else:
3290
+ cmd_list = [sys.executable, "-m", "pip", "install", "--disable-pip-version-check",
3291
+ f"meshcode=={latest}"]
3292
+ result = _sp.run(cmd_list, capture_output=True, text=True, timeout=180)
3293
+ if result.returncode == 0:
3294
+ print(f" Upgraded to {latest}. Restart your editor to use the new version.")
3295
+ else:
3296
+ print(f" Upgrade failed (exit {result.returncode}).")
3297
+ if result.stderr:
3298
+ print(f" {result.stderr[:200]}")
3299
+ sys.exit(1)
3300
+ else:
3301
+ print(f" Upgrade cancelled.")
3302
+ sys.exit(0)
3303
+
3195
3304
  elif cmd == "compat":
3196
3305
  from meshcode.compat import check as cc_check, format_report, RECOMMENDED_VERSION
3197
3306
  version, status, entry = cc_check()
@@ -3218,6 +3327,31 @@ if __name__ == "__main__":
3218
3327
  elif cmd in ("help", "--help", "-h"):
3219
3328
  show_help()
3220
3329
 
3330
+ elif cmd == "supervisor":
3331
+ # meshcode supervisor start|stop|status [project] [agent]
3332
+ import importlib
3333
+ _sup = importlib.import_module("meshcode.supervisor")
3334
+ sub = pos[0] if pos else "status"
3335
+ if sub == "start":
3336
+ if len(pos) < 3:
3337
+ print("Usage: meshcode supervisor start <project> <agent>")
3338
+ sys.exit(1)
3339
+ _sup.start(pos[1], pos[2])
3340
+ elif sub == "stop":
3341
+ if len(pos) < 3:
3342
+ print("Usage: meshcode supervisor stop <project> <agent>")
3343
+ sys.exit(1)
3344
+ _sup.stop(pos[1], pos[2])
3345
+ elif sub == "status":
3346
+ _sup.status()
3347
+ elif sub == "--simple":
3348
+ if len(pos) < 3:
3349
+ print("Usage: meshcode supervisor --simple <project> <agent>")
3350
+ sys.exit(1)
3351
+ _sup.simple(pos[1], pos[2])
3352
+ else:
3353
+ print(f"Usage: meshcode supervisor [start|stop|status|--simple] <project> <agent>")
3354
+
3221
3355
  else:
3222
3356
  known_cmds = [
3223
3357
  "register", "send", "broadcast", "read", "check", "watch", "inbox", "next",
@@ -3225,7 +3359,8 @@ if __name__ == "__main__":
3225
3359
  "history", "clear", "unregister", "connect", "disconnect",
3226
3360
  "setup", "run", "go", "invite", "join", "invites", "members",
3227
3361
  "revoke-invite", "revoke-member", "login", "prefs", "launcher",
3228
- "help", "init", "doctor", "compat", "profile", "validate-sessions", "wake-headless",
3362
+ "help", "init", "doctor", "compat", "upgrade", "profile", "validate-sessions", "wake-headless",
3363
+ "supervisor",
3229
3364
  ]
3230
3365
  # Simple fuzzy: prefix match + Levenshtein-like best match
3231
3366
  suggestions = [c for c in known_cmds if c.startswith(cmd)]
@@ -71,10 +71,10 @@ def _rpc(name: str, body: Dict[str, Any]) -> Optional[Dict[str, Any]]:
71
71
  err_body = e.read().decode()
72
72
  return json.loads(err_body)
73
73
  except Exception:
74
- print(f"[meshcode] HTTP {e.code} from {name}: {e.reason}", file=sys.stderr)
74
+ print(f"[meshcode] ERROR: HTTP {e.code} from {name}: {e.reason}", file=sys.stderr)
75
75
  return None
76
76
  except URLError as e:
77
- print(f"[meshcode] network error calling {name}: {e.reason}", file=sys.stderr)
77
+ print(f"[meshcode] ERROR: network error calling {name}: {e.reason}", file=sys.stderr)
78
78
  return None
79
79
 
80
80
 
@@ -20,6 +20,38 @@ from urllib.parse import quote, urlparse
20
20
  from urllib.request import Request, urlopen
21
21
 
22
22
 
23
+ # ── Normalised error envelope ───────────────────────────────────
24
+ # Every internal error from _request() and sb_rpc() now returns:
25
+ # {"error": {"code": str, "message": str, "source": str}}
26
+ # where:
27
+ # code — HTTP status as string, "0" for network errors, "503" for circuit breaker
28
+ # message — human-readable description
29
+ # source — "circuit_breaker", "http", "network", "rpc"
30
+ #
31
+ # Callers check with: is_error(result)
32
+ # Extract message: get_error_message(result)
33
+
34
+ def _make_error(message: str, *, code: str = "0", source: str = "unknown") -> Dict[str, Any]:
35
+ """Build a normalised error envelope."""
36
+ return {"error": {"code": str(code), "message": message, "source": source}}
37
+
38
+
39
+ def is_error(result: Any) -> bool:
40
+ """Return True if *result* is a normalised error envelope."""
41
+ return (isinstance(result, dict)
42
+ and isinstance(result.get("error"), dict)
43
+ and "message" in result["error"])
44
+
45
+
46
+ def get_error_message(result: Any) -> str:
47
+ """Extract the human-readable message from an error envelope (or '')."""
48
+ if is_error(result):
49
+ return result["error"]["message"]
50
+ if isinstance(result, dict) and isinstance(result.get("error"), str):
51
+ return result["error"]
52
+ return ""
53
+
54
+
23
55
  # ── Circuit Breaker ──────────────────────────────────────────────
24
56
  # Protects against cascading failures when Supabase is down.
25
57
  # States: CLOSED (normal) → OPEN (reject fast) → HALF_OPEN (probe)
@@ -126,11 +158,13 @@ class _CircuitBreaker:
126
158
  )
127
159
  log.warning("circuit breaker: half-open probe failed — OPEN (next probe in %ds)",
128
160
  int(self._current_recovery_timeout))
161
+ self._emit_cb_telemetry("half_open_failed", len(self._failures))
129
162
  elif len(self._failures) >= self.failure_threshold:
130
163
  self.state = self.OPEN
131
164
  self._opened_at = now
132
165
  log.warning("circuit breaker: %d failures in %ds window — OPEN",
133
166
  len(self._failures), int(self._window_seconds))
167
+ self._emit_cb_telemetry("threshold_exceeded", len(self._failures))
134
168
 
135
169
  @property
136
170
  def is_open(self) -> bool:
@@ -165,6 +199,18 @@ class _CircuitBreaker:
165
199
  def _failures_suppressed(self) -> bool:
166
200
  return getattr(self._suppress_failures, "active", False)
167
201
 
202
+ @staticmethod
203
+ def _emit_cb_telemetry(reason: str, failure_count: int) -> None:
204
+ """JSON telemetry for circuit breaker state changes."""
205
+ import json as _j, sys as _sys
206
+ try:
207
+ record = {"event": "circuit_breaker", "reason": reason,
208
+ "failure_count": failure_count, "ts": _time.time()}
209
+ _sys.stderr.write(f"[meshcode-telemetry] {_j.dumps(record)}\n")
210
+ _sys.stderr.flush()
211
+ except Exception:
212
+ pass
213
+
168
214
 
169
215
  _circuit = _CircuitBreaker(failure_threshold=5, recovery_timeout=10.0,
170
216
  max_recovery_timeout=60.0, window_seconds=60.0,
@@ -308,7 +354,8 @@ def _request(method: str, path: str, *, data: Any = None, prefer: Optional[str]
308
354
  _max_retries: int = 3) -> Any:
309
355
  import random as _random
310
356
  if not _circuit.can_execute():
311
- return {"_error": "circuit breaker open — Supabase temporarily unavailable", "_code": 503}
357
+ return _make_error("circuit breaker open — Supabase temporarily unavailable",
358
+ code="503", source="circuit_breaker")
312
359
  rest_path = f"/rest/v1/{path}"
313
360
  body = json.dumps(data).encode("utf-8") if data else None
314
361
  hdrs = _headers(prefer=prefer)
@@ -324,9 +371,10 @@ def _request(method: str, path: str, *, data: Any = None, prefer: Optional[str]
324
371
  _circuit.record_success()
325
372
  try:
326
373
  err_obj = json.loads(raw)
327
- return {"_error": err_obj.get("message", raw[:200]), "_code": status}
374
+ return _make_error(err_obj.get("message", raw[:200]),
375
+ code=str(status), source="http")
328
376
  except Exception:
329
- return {"_error": raw[:200], "_code": status}
377
+ return _make_error(raw[:200], code=str(status), source="http")
330
378
  else:
331
379
  # 5xx — transient, retry
332
380
  last_err = raw[:200]
@@ -340,7 +388,8 @@ def _request(method: str, path: str, *, data: Any = None, prefer: Optional[str]
340
388
  delay = (2 ** attempt) + _random.uniform(0, 1)
341
389
  _time.sleep(delay)
342
390
  _circuit.record_failure()
343
- return {"_error": str(last_err)[:200] if last_err else "request failed after retries", "_code": 0}
391
+ return _make_error(str(last_err)[:200] if last_err else "request failed after retries",
392
+ code="0", source="network")
344
393
 
345
394
 
346
395
  def sb_select(table: str, filters: str = "", order: Optional[str] = None, limit: Optional[int] = None) -> List[Dict]:
@@ -353,7 +402,7 @@ def sb_select(table: str, filters: str = "", order: Optional[str] = None, limit:
353
402
  params.append(f"limit={limit}")
354
403
  path = f"{table}?{'&'.join(params)}" if params else table
355
404
  result = _request("GET", path)
356
- if isinstance(result, dict) and result.get("_error"):
405
+ if is_error(result):
357
406
  return []
358
407
  return result or []
359
408
 
@@ -404,7 +453,8 @@ def enable_recording(api_key: str, project_id: str, agent_name: str, session_id:
404
453
  def sb_rpc(fn_name: str, params: Dict, *, _max_retries: int = 3) -> Any:
405
454
  import random as _random
406
455
  if not _circuit.can_execute():
407
- return {"_error": "circuit breaker open — Supabase temporarily unavailable", "_circuit": "open"}
456
+ return _make_error("circuit breaker open — Supabase temporarily unavailable",
457
+ code="503", source="circuit_breaker")
408
458
  last_err = None
409
459
  rpc_path = f"/rest/v1/rpc/{fn_name}"
410
460
  body = json.dumps(params).encode("utf-8")
@@ -421,12 +471,12 @@ def sb_rpc(fn_name: str, params: Dict, *, _max_retries: int = 3) -> Any:
421
471
  elif 400 <= status < 500:
422
472
  _circuit.record_success()
423
473
  try:
424
- result = {"_error": json.loads(raw).get("message", raw[:200])}
474
+ msg = json.loads(raw).get("message", raw[:200])
425
475
  except Exception:
426
- result = {"_error": raw[:200]}
476
+ msg = raw[:200]
427
477
  if _recording_enabled and fn_name not in _SKIP_RECORDING:
428
478
  _bg_record("error", {"rpc": fn_name, "error": raw[:200]})
429
- return result
479
+ return _make_error(msg, code=str(status), source="rpc")
430
480
  else:
431
481
  last_err = raw[:200]
432
482
  except (KeyboardInterrupt, SystemExit):
@@ -444,7 +494,8 @@ def sb_rpc(fn_name: str, params: Dict, *, _max_retries: int = 3) -> Any:
444
494
  _circuit.record_failure()
445
495
  if _recording_enabled and fn_name not in _SKIP_RECORDING:
446
496
  _bg_record("error", {"rpc": fn_name, "error": str(last_err)[:200], "retries_exhausted": True})
447
- return {"_error": str(last_err)[:200] if last_err else "request failed after retries"}
497
+ return _make_error(str(last_err)[:200] if last_err else "request failed after retries",
498
+ code="0", source="network")
448
499
 
449
500
 
450
501
  def _bg_record(event_type: str, payload: dict):
@@ -519,8 +570,8 @@ def register_agent(project: str, name: str, role: str = "", api_key: Optional[st
519
570
  params["p_api_key"] = api_key
520
571
  result = sb_rpc("mc_register_agent", params)
521
572
 
522
- if not result or (isinstance(result, dict) and result.get("error")):
523
- return result or {"error": "Failed to register agent"}
573
+ if not result or is_error(result):
574
+ return {"error": get_error_message(result) or "Failed to register agent"}
524
575
 
525
576
  # Prefer SECURITY DEFINER RPC for mc_agents update
526
577
  _ak = api_key or _get_api_key()
@@ -694,8 +745,8 @@ def send_message(project_id: str, from_agent: str, to_agent: str, payload: Any,
694
745
  return out
695
746
  # If RPC returned an error, propagate it — do NOT fall through to
696
747
  # direct insert (RLS blocks anon INSERT since migration 089).
697
- if isinstance(result, dict) and result.get("error"):
698
- return {"error": result["error"]}
748
+ if is_error(result):
749
+ return {"error": get_error_message(result)}
699
750
  # RPC returned unexpected shape but didn't error — treat as success
700
751
  if result is not None:
701
752
  return {"sent": True}
@@ -717,8 +768,8 @@ def send_message(project_id: str, from_agent: str, to_agent: str, payload: Any,
717
768
  if sensitive:
718
769
  msg["is_sensitive"] = True
719
770
  result = sb_insert("mc_messages", msg)
720
- if isinstance(result, dict) and result.get("_error"):
721
- return {"error": result["_error"]}
771
+ if is_error(result):
772
+ return {"error": get_error_message(result)}
722
773
  if isinstance(result, list) and result:
723
774
  return {"sent": True, "msg_id": result[0].get("id")}
724
775
  return {"sent": True}
@@ -1013,8 +1064,8 @@ def set_status(project_id: str, agent: str, status: str, task: str = "", api_key
1013
1064
  return {"ok": True, "status": status}
1014
1065
  # Final fallback: direct PATCH (may silently fail with anon key if RLS blocks)
1015
1066
  result = sb_update("mc_agents", f"project_id=eq.{project_id}&name=eq.{quote(agent)}", fields)
1016
- if isinstance(result, dict) and result.get("_error"):
1017
- return {"error": result["_error"]}
1067
+ if is_error(result):
1068
+ return {"error": get_error_message(result)}
1018
1069
  return {"ok": True, "status": status}
1019
1070
 
1020
1071
 
@@ -1107,7 +1158,7 @@ def get_message_by_id(project_id: str, msg_id: str, api_key: Optional[str] = Non
1107
1158
  })
1108
1159
  if isinstance(rpc_result, dict) and rpc_result.get("ok"):
1109
1160
  return rpc_result.get("message")
1110
- if isinstance(rpc_result, dict) and rpc_result.get("error"):
1161
+ if is_error(rpc_result):
1111
1162
  return None
1112
1163
  # Fall through to direct query if RPC doesn't exist yet
1113
1164
 
@@ -385,14 +385,41 @@ class RealtimeListener:
385
385
  except asyncio.TimeoutError:
386
386
  return False
387
387
 
388
+ # Restart circuit breaker: prevent infinite restart cascades.
389
+ # After 3 restarts in 120s, enter cooldown (no restarts for 60s).
390
+ _restart_times: list = []
391
+ _restart_cooldown_until: float = 0.0
392
+ _RESTART_MAX = 3
393
+ _RESTART_WINDOW = 120.0
394
+ _RESTART_COOLDOWN = 60.0
395
+
388
396
  async def restart(self) -> None:
389
397
  """Stop and restart the Realtime connection.
390
398
 
391
399
  Called by the heartbeat thread when it detects the subscription
392
- dropped (is_subscribed=False). The outer _run() loop handles
393
- reconnect with exponential backoff.
400
+ dropped (is_subscribed=False). Has a circuit breaker: after 3
401
+ restarts in 120s, enters 60s cooldown to prevent cascade.
394
402
  """
395
- log.info(f"Realtime restart requested for {self.agent_name}")
403
+ import time as _t
404
+ now = _t.time()
405
+ # Cooldown check
406
+ if now < self._restart_cooldown_until:
407
+ log.warning(
408
+ f"Realtime restart SUPPRESSED — in cooldown until "
409
+ f"{self._restart_cooldown_until - now:.0f}s from now"
410
+ )
411
+ return
412
+ # Prune old restart times
413
+ self._restart_times = [t for t in self._restart_times if now - t < self._RESTART_WINDOW]
414
+ if len(self._restart_times) >= self._RESTART_MAX:
415
+ self._restart_cooldown_until = now + self._RESTART_COOLDOWN
416
+ log.error(
417
+ f"Realtime restart cascade detected ({self._RESTART_MAX} restarts in "
418
+ f"{self._RESTART_WINDOW}s) — entering {self._RESTART_COOLDOWN}s cooldown"
419
+ )
420
+ return
421
+ self._restart_times.append(now)
422
+ log.info(f"Realtime restart requested for {self.agent_name} (#{len(self._restart_times)})")
396
423
  await self.stop()
397
424
  await self.start()
398
425
 
@@ -424,8 +451,9 @@ class RealtimeListener:
424
451
  @property
425
452
  def is_deaf(self) -> bool:
426
453
  """True if Realtime claims to be subscribed but hasn't received
427
- any events for >60s. This indicates a silent WebSocket failure."""
428
- return self.is_subscribed and self.seconds_since_last_event > 60.0
454
+ any events for >120s. Raised from 60s to prevent restart cascades
455
+ for agents in persistent wait (e.g. commander always in meshcode_wait)."""
456
+ return self.is_subscribed and self.seconds_since_last_event > 120.0
429
457
 
430
458
  @property
431
459
  def deaf_restart_count(self) -> int: