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.
- {meshcode-2.10.57 → meshcode-2.10.59}/PKG-INFO +1 -1
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/__init__.py +1 -1
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/comms_v4.py +158 -23
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/invites.py +2 -2
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/meshcode_mcp/backend.py +70 -19
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/meshcode_mcp/realtime.py +33 -5
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/meshcode_mcp/server.py +193 -619
- meshcode-2.10.59/meshcode/supervisor.py +186 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode.egg-info/PKG-INFO +1 -1
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode.egg-info/SOURCES.txt +6 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/pyproject.toml +5 -1
- {meshcode-2.10.57 → meshcode-2.10.59}/tests/test_core.py +3 -3
- meshcode-2.10.59/tests/test_mark_read_batch.py +200 -0
- meshcode-2.10.59/tests/test_migration_integrity.py +176 -0
- meshcode-2.10.59/tests/test_rls_cross_tenant.py +255 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/tests/test_rpc_migrations.py +6 -4
- meshcode-2.10.59/tests/test_security_regressions.py +171 -0
- meshcode-2.10.59/tests/test_sentinel.py +148 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/README.md +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/ascii_art.py +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/cli.py +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/compat.py +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/exceptions.py +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/launcher.py +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/launcher_install.py +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/meshcode_mcp/__init__.py +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/meshcode_mcp/__main__.py +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/meshcode_mcp/test_backend.py +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/meshcode_mcp/test_realtime.py +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/preferences.py +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/protocol_v2.py +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/run_agent.py +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/secrets.py +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/self_update.py +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode/setup_clients.py +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode.egg-info/dependency_links.txt +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode.egg-info/entry_points.txt +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode.egg-info/requires.txt +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/meshcode.egg-info/top_level.txt +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/setup.cfg +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/tests/test_cross_agent_messaging.py +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/tests/test_esc_deaf_state.py +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/tests/test_exceptions.py +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/tests/test_realtime_event_freshness.py +0 -0
- {meshcode-2.10.57 → meshcode-2.10.59}/tests/test_status_enum_coverage.py +0 -0
|
@@ -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"[
|
|
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:
|
|
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"[
|
|
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"[
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
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("
|
|
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",
|
|
2629
|
-
"prefs", "launcher", "--version", "-V", "version",
|
|
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("[
|
|
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("[
|
|
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"[
|
|
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("[
|
|
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"[
|
|
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("[
|
|
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
|
|
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
|
|
374
|
+
return _make_error(err_obj.get("message", raw[:200]),
|
|
375
|
+
code=str(status), source="http")
|
|
328
376
|
except Exception:
|
|
329
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
474
|
+
msg = json.loads(raw).get("message", raw[:200])
|
|
425
475
|
except Exception:
|
|
426
|
-
|
|
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
|
|
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
|
|
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 (
|
|
523
|
-
return
|
|
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
|
|
698
|
-
return {"error": result
|
|
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
|
|
721
|
-
return {"error": result
|
|
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
|
|
1017
|
-
return {"error": result
|
|
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
|
|
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).
|
|
393
|
-
|
|
400
|
+
dropped (is_subscribed=False). Has a circuit breaker: after 3
|
|
401
|
+
restarts in 120s, enters 60s cooldown to prevent cascade.
|
|
394
402
|
"""
|
|
395
|
-
|
|
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 >
|
|
428
|
-
|
|
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:
|