meshcode 1.8.4__tar.gz → 1.8.8__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 (30) hide show
  1. {meshcode-1.8.4 → meshcode-1.8.8}/PKG-INFO +1 -1
  2. {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/__init__.py +1 -1
  3. {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/comms_v4.py +61 -4
  4. {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/invites.py +2 -2
  5. meshcode-1.8.8/meshcode/meshcode_mcp/__main__.py +39 -0
  6. {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/meshcode_mcp/backend.py +16 -9
  7. {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/meshcode_mcp/realtime.py +41 -5
  8. {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/meshcode_mcp/server.py +68 -2
  9. {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/run_agent.py +28 -1
  10. {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/secrets.py +15 -8
  11. {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/setup_clients.py +143 -20
  12. {meshcode-1.8.4 → meshcode-1.8.8}/meshcode.egg-info/PKG-INFO +1 -1
  13. {meshcode-1.8.4 → meshcode-1.8.8}/pyproject.toml +1 -1
  14. meshcode-1.8.4/meshcode/meshcode_mcp/__main__.py +0 -17
  15. {meshcode-1.8.4 → meshcode-1.8.8}/README.md +0 -0
  16. {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/cli.py +0 -0
  17. {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/launcher.py +0 -0
  18. {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/launcher_install.py +0 -0
  19. {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/meshcode_mcp/__init__.py +0 -0
  20. {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/meshcode_mcp/test_backend.py +0 -0
  21. {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/meshcode_mcp/test_realtime.py +0 -0
  22. {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/preferences.py +0 -0
  23. {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/protocol_v2.py +0 -0
  24. {meshcode-1.8.4 → meshcode-1.8.8}/meshcode/self_update.py +0 -0
  25. {meshcode-1.8.4 → meshcode-1.8.8}/meshcode.egg-info/SOURCES.txt +0 -0
  26. {meshcode-1.8.4 → meshcode-1.8.8}/meshcode.egg-info/dependency_links.txt +0 -0
  27. {meshcode-1.8.4 → meshcode-1.8.8}/meshcode.egg-info/entry_points.txt +0 -0
  28. {meshcode-1.8.4 → meshcode-1.8.8}/meshcode.egg-info/requires.txt +0 -0
  29. {meshcode-1.8.4 → meshcode-1.8.8}/meshcode.egg-info/top_level.txt +0 -0
  30. {meshcode-1.8.4 → meshcode-1.8.8}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 1.8.4
3
+ Version: 1.8.8
4
4
  Summary: Real-time communication between AI agents — Supabase-backed CLI
5
5
  Author-email: MeshCode <hello@meshcode.io>
6
6
  License: MIT
@@ -1,2 +1,2 @@
1
1
  """MeshCode — Real-time communication between AI agents."""
2
- __version__ = "1.8.4"
2
+ __version__ = "1.8.8"
@@ -28,6 +28,14 @@ import sys
28
28
  import time
29
29
  import subprocess
30
30
  from datetime import datetime
31
+
32
+ # Force UTF-8 stdio so unicode chars (✓, →, etc.) in CLI output don't crash
33
+ # on Windows cp1252. Safe no-op on POSIX.
34
+ try:
35
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
36
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
37
+ except Exception:
38
+ pass
31
39
  from pathlib import Path
32
40
  from urllib.request import Request, urlopen
33
41
  from urllib.error import URLError, HTTPError
@@ -39,8 +47,8 @@ from urllib.parse import quote
39
47
  # Production defaults baked in. The publishable key is the anon/public key
40
48
  # (RLS-protected, safe to ship — same one the frontend at meshcode.io uses
41
49
  # in the browser). Override via env vars or ~/.meshcode/env if you self-host.
42
- _DEFAULT_SUPABASE_URL = "https://wwgzzmydrwrjgaebspdo.supabase.co"
43
- _DEFAULT_SUPABASE_KEY = "sb_publishable_0qf0U1GURopPIxLR8Vu7eQ_5grflPP4"
50
+ _DEFAULT_SUPABASE_URL = "https://gjinagyyjttyxnaoavnz.supabase.co"
51
+ _DEFAULT_SUPABASE_KEY = "sb_publishable_qwN9PO1L7jUXhhbhhVk2CQ_z1FXG2Qf"
44
52
 
45
53
  def _load_env_file():
46
54
  """Read SUPABASE_URL/KEY from ~/.meshcode/env if present (overrides defaults)."""
@@ -190,12 +198,61 @@ def ensure_sessions():
190
198
  SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
191
199
 
192
200
 
201
+ def _load_api_key_for_cli() -> str:
202
+ """Pull the user's api key from the keychain (or env fallback) so the
203
+ CLI can call SECURITY DEFINER RPCs that use api_key auth instead of
204
+ relying on the publishable anon key + RLS, which is what CLI verbs
205
+ used to do and which always failed because the CLI has no JWT context.
206
+ """
207
+ # 1) explicit env override
208
+ k = os.environ.get("MESHCODE_API_KEY", "").strip()
209
+ if k:
210
+ return k
211
+ # 2) keychain via secrets module
212
+ try:
213
+ import importlib
214
+ secrets_mod = importlib.import_module("meshcode.secrets")
215
+ profile = os.environ.get("MESHCODE_KEYCHAIN_PROFILE") or secrets_mod.DEFAULT_PROFILE
216
+ key = secrets_mod.get_api_key(profile=profile) or ""
217
+ if key:
218
+ return key
219
+ except Exception:
220
+ pass
221
+ return ""
222
+
223
+
193
224
  def get_project_id(project_name):
194
- """Get or create project, return its UUID."""
225
+ """Resolve a project's UUID for the authenticated CLI user.
226
+
227
+ Resolution order:
228
+ 1. api_key + mc_resolve_project RPC (SECURITY DEFINER, the only
229
+ path that actually works for an authenticated CLI session)
230
+ 2. legacy publishable-anon SELECT (only useful for shared/public
231
+ projects; will fail under RLS for owned projects)
232
+ 3. legacy publishable-anon INSERT (will fail under RLS — kept as
233
+ last resort for backwards compat with very old test scripts)
234
+ """
235
+ api_key = _load_api_key_for_cli()
236
+ if api_key:
237
+ try:
238
+ r = sb_rpc("mc_resolve_project", {
239
+ "p_api_key": api_key,
240
+ "p_project_name": project_name,
241
+ })
242
+ if isinstance(r, dict) and r.get("project_id"):
243
+ return r["project_id"]
244
+ # Some deployments return rows
245
+ if isinstance(r, list) and r and r[0].get("project_id"):
246
+ return r[0]["project_id"]
247
+ except Exception:
248
+ pass
249
+
195
250
  rows = sb_select("mc_projects", f"name=eq.{quote(project_name)}")
196
251
  if rows:
197
252
  return rows[0]["id"]
198
- # Create project
253
+
254
+ # Last resort: try to create. Will fail under RLS for unauthenticated
255
+ # contexts, but kept for legacy callers + admin tooling.
199
256
  result = sb_insert("mc_projects", {"name": project_name})
200
257
  if result and len(result) > 0:
201
258
  return result[0]["id"]
@@ -41,8 +41,8 @@ from urllib.request import Request, urlopen
41
41
  # Supabase RPC helpers
42
42
  # ============================================================
43
43
 
44
- _DEFAULT_SUPABASE_URL = "https://wwgzzmydrwrjgaebspdo.supabase.co"
45
- _DEFAULT_SUPABASE_KEY = "sb_publishable_0qf0U1GURopPIxLR8Vu7eQ_5grflPP4"
44
+ _DEFAULT_SUPABASE_URL = "https://gjinagyyjttyxnaoavnz.supabase.co"
45
+ _DEFAULT_SUPABASE_KEY = "sb_publishable_qwN9PO1L7jUXhhbhhVk2CQ_z1FXG2Qf"
46
46
 
47
47
 
48
48
  def _sb() -> Dict[str, str]:
@@ -0,0 +1,39 @@
1
+ """Run via: python -m meshcode_mcp serve"""
2
+ import os
3
+ import sys
4
+
5
+ # CRITICAL Windows fix: the MCP protocol uses stdio JSON-RPC. On Windows
6
+ # stdin/stdout default to cp1252 encoding and block-buffered when piped.
7
+ # - cp1252 → any non-ASCII char in a JSON message raises UnicodeEncodeError
8
+ # and corrupts the byte stream → Claude Code times out the handshake.
9
+ # - Block-buffered → JSON-RPC bytes get stuck in the kernel buffer until
10
+ # the buffer fills, so Claude Code never sees the handshake response.
11
+ # Both must be fixed BEFORE FastMCP touches stdio.
12
+ try:
13
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace", newline="\n", line_buffering=True)
14
+ sys.stdin.reconfigure(encoding="utf-8", errors="replace", newline="\n")
15
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
16
+ except Exception:
17
+ pass
18
+ # Belt and suspenders: also export PYTHONIOENCODING in case any subprocess
19
+ # we spawn inherits this env.
20
+ os.environ.setdefault("PYTHONIOENCODING", "utf-8")
21
+ # Tell self_update / other modules: we are the MCP serve subprocess; do
22
+ # NOT auto-update from inside this process.
23
+ os.environ.setdefault("MESHCODE_MCP_SERVE", "1")
24
+
25
+ from .server import run_server
26
+
27
+
28
+ def main():
29
+ args = sys.argv[1:]
30
+ if args and args[0] == "serve":
31
+ run_server()
32
+ else:
33
+ print("Usage: python -m meshcode_mcp serve", file=sys.stderr)
34
+ print(" (env vars: MESHCODE_PROJECT, MESHCODE_AGENT, SUPABASE_URL, SUPABASE_KEY)", file=sys.stderr)
35
+ sys.exit(1)
36
+
37
+
38
+ if __name__ == "__main__":
39
+ main()
@@ -13,8 +13,8 @@ from urllib.parse import quote
13
13
  from urllib.request import Request, urlopen
14
14
 
15
15
  # Bake in production defaults — RLS-protected publishable key, safe to ship.
16
- _DEFAULT_SUPABASE_URL = "https://wwgzzmydrwrjgaebspdo.supabase.co"
17
- _DEFAULT_SUPABASE_KEY = "sb_publishable_0qf0U1GURopPIxLR8Vu7eQ_5grflPP4"
16
+ _DEFAULT_SUPABASE_URL = "https://gjinagyyjttyxnaoavnz.supabase.co"
17
+ _DEFAULT_SUPABASE_KEY = "sb_publishable_qwN9PO1L7jUXhhbhhVk2CQ_z1FXG2Qf"
18
18
 
19
19
  def _load_env_file() -> Dict[str, str]:
20
20
  env_path = Path.home() / ".meshcode" / "env"
@@ -22,7 +22,7 @@ def _load_env_file() -> Dict[str, str]:
22
22
  return {}
23
23
  out: Dict[str, str] = {}
24
24
  try:
25
- for line in env_path.read_text().splitlines():
25
+ for line in env_path.read_text(encoding="utf-8").splitlines():
26
26
  line = line.strip()
27
27
  if line.startswith("export "):
28
28
  line = line[7:]
@@ -59,14 +59,14 @@ def _headers(*, prefer: Optional[str] = None, content_profile: bool = True) -> D
59
59
 
60
60
  def _request(method: str, path: str, *, data: Any = None, prefer: Optional[str] = None) -> Any:
61
61
  url = f"{SUPABASE_URL}/rest/v1/{path}"
62
- body = json.dumps(data).encode() if data else None
62
+ body = json.dumps(data).encode("utf-8") if data else None
63
63
  req = Request(url, data=body, method=method, headers=_headers(prefer=prefer))
64
64
  try:
65
65
  with urlopen(req, timeout=10) as resp:
66
- raw = resp.read().decode()
66
+ raw = resp.read().decode("utf-8")
67
67
  return json.loads(raw) if raw.strip() else None
68
68
  except HTTPError as e:
69
- err = e.read().decode()
69
+ err = e.read().decode("utf-8", errors="replace")
70
70
  try:
71
71
  err_obj = json.loads(err)
72
72
  return {"_error": err_obj.get("message", err[:200]), "_code": e.code}
@@ -107,14 +107,14 @@ def sb_update(table: str, filters: str, updates: Dict) -> Any:
107
107
 
108
108
  def sb_rpc(fn_name: str, params: Dict) -> Any:
109
109
  url = f"{SUPABASE_URL}/rest/v1/rpc/{fn_name}"
110
- body = json.dumps(params).encode()
110
+ body = json.dumps(params).encode("utf-8")
111
111
  req = Request(url, data=body, method="POST", headers=_headers(content_profile=False))
112
112
  try:
113
113
  with urlopen(req, timeout=10) as resp:
114
- raw = resp.read().decode()
114
+ raw = resp.read().decode("utf-8")
115
115
  return json.loads(raw) if raw.strip() else None
116
116
  except HTTPError as e:
117
- err = e.read().decode()
117
+ err = e.read().decode("utf-8", errors="replace")
118
118
  try:
119
119
  return {"_error": json.loads(err).get("message", err[:200])}
120
120
  except Exception:
@@ -139,6 +139,13 @@ def get_project_id(project_name: str) -> Optional[str]:
139
139
 
140
140
  def register_agent(project: str, name: str, role: str = "") -> Dict:
141
141
  project_id = get_project_id(project)
142
+ if not project_id:
143
+ # Fallback: many spawn paths (setup_clients.py) bake the project
144
+ # uuid into the env so we can register even when the name lookup
145
+ # fails (RLS, schema cache, network blip, anon-key visibility).
146
+ env_pid = os.environ.get("MESHCODE_PROJECT_ID", "").strip()
147
+ if env_pid:
148
+ project_id = env_pid
142
149
  if not project_id:
143
150
  return {"error": f"Project '{project}' not found"}
144
151
 
@@ -10,9 +10,20 @@ When a new mc_message INSERT arrives for the current agent, it:
10
10
  import asyncio
11
11
  import json
12
12
  import logging
13
+ import ssl
13
14
  from collections import deque
14
15
  from typing import Any, Awaitable, Callable, Deque, Dict, Optional
15
16
 
17
+ try:
18
+ import certifi
19
+ _SSL_CTX = ssl.create_default_context(cafile=certifi.where())
20
+ except Exception:
21
+ # Fallback to system trust store if certifi isn't available.
22
+ try:
23
+ _SSL_CTX = ssl.create_default_context()
24
+ except Exception:
25
+ _SSL_CTX = None
26
+
16
27
  try:
17
28
  import websockets
18
29
  WEBSOCKETS_AVAILABLE = True
@@ -42,11 +53,15 @@ class RealtimeListener:
42
53
  # Last 100 unread messages — drained by meshcode_check tool
43
54
  self.queue: Deque[Dict] = deque(maxlen=100)
44
55
  self._task: Optional[asyncio.Task] = None
45
- self._stop = asyncio.Event()
56
+ # asyncio.Event() in Py3.10+ no longer requires a running loop, but
57
+ # on older Python or certain Windows event-loop policies it can
58
+ # raise. Defer creation to start() which is always called from
59
+ # inside the running event loop.
60
+ self._stop: Optional[asyncio.Event] = None
46
61
  self._connected = False
47
62
  # Event fired whenever a new message is appended to the queue.
48
- # meshcode_wait awaits this instead of polling zero-cost idle.
49
- self.message_event = asyncio.Event()
63
+ # meshcode_wait awaits this instead of polling -> zero-cost idle.
64
+ self.message_event: Optional[asyncio.Event] = None
50
65
 
51
66
  @property
52
67
  def ws_url(self) -> str:
@@ -57,13 +72,18 @@ class RealtimeListener:
57
72
  if not WEBSOCKETS_AVAILABLE:
58
73
  log.warning("websockets package not installed — Realtime disabled")
59
74
  return
75
+ if self._stop is None:
76
+ self._stop = asyncio.Event()
77
+ if self.message_event is None:
78
+ self.message_event = asyncio.Event()
60
79
  if self._task and not self._task.done():
61
80
  return
62
81
  self._stop.clear()
63
82
  self._task = asyncio.create_task(self._run(), name="meshcode-realtime")
64
83
 
65
84
  async def stop(self) -> None:
66
- self._stop.set()
85
+ if self._stop is not None:
86
+ self._stop.set()
67
87
  if self._task:
68
88
  self._task.cancel()
69
89
  try:
@@ -91,7 +111,18 @@ class RealtimeListener:
91
111
 
92
112
  async def _connect_and_listen(self) -> None:
93
113
  """Single connection lifecycle: connect, subscribe, listen."""
94
- async with websockets.connect(self.ws_url, ping_interval=20, ping_timeout=10) as ws:
114
+ # Cap the initial TLS handshake at 10s. Without this, websockets.connect
115
+ # can hang indefinitely behind a corporate firewall / DNS stall, which
116
+ # blocks the MCP lifespan startup -> Claude Code times out the spawn
117
+ # -> "Failed to reconnect".
118
+ connect_kwargs = {"ping_interval": 20, "ping_timeout": 10}
119
+ if _SSL_CTX is not None and self.ws_url.startswith("wss://"):
120
+ connect_kwargs["ssl"] = _SSL_CTX
121
+ ws = await asyncio.wait_for(
122
+ websockets.connect(self.ws_url, **connect_kwargs),
123
+ timeout=10.0,
124
+ )
125
+ try:
95
126
  self._connected = True
96
127
  log.info(f"Realtime connected for agent={self.agent_name}")
97
128
 
@@ -130,6 +161,11 @@ class RealtimeListener:
130
161
  finally:
131
162
  heartbeat_task.cancel()
132
163
  self._connected = False
164
+ finally:
165
+ try:
166
+ await ws.close()
167
+ except Exception:
168
+ pass
133
169
 
134
170
  async def _heartbeat(self, ws) -> None:
135
171
  """Send phoenix heartbeats every 25s to keep the channel alive."""
@@ -212,8 +212,61 @@ def _flip_status(status: str, task: str = "") -> bool:
212
212
  log.warning(f"set_status RPC threw: {e}")
213
213
  return False
214
214
 
215
- if not _flip_status("online", "MCP session active"):
216
- print(f"[meshcode-mcp] WARNING: could not flip status to online", file=sys.stderr)
215
+ if not _flip_status("idle", ""):
216
+ print(f"[meshcode-mcp] WARNING: could not flip status to idle", file=sys.stderr)
217
+
218
+
219
+ # ============================================================
220
+ # Working/idle status decorator for @mcp.tool() functions.
221
+ # Fire-and-forget flips so tool execution is never blocked.
222
+ # ============================================================
223
+ import functools as _functools
224
+
225
+
226
+ async def _async_flip_status(status: str, task: str = "") -> None:
227
+ try:
228
+ await asyncio.to_thread(_flip_status, status, task)
229
+ except Exception as e:
230
+ log.debug(f"flip_status({status}) failed: {e}")
231
+
232
+
233
+ def _schedule_flip(status: str, task: str = "") -> None:
234
+ try:
235
+ loop = asyncio.get_running_loop()
236
+ loop.create_task(_async_flip_status(status, task))
237
+ except RuntimeError:
238
+ # No running loop (sync context) — run in a throwaway thread
239
+ import threading
240
+ threading.Thread(
241
+ target=_flip_status, args=(status, task), daemon=True
242
+ ).start()
243
+
244
+
245
+ def with_working_status(func):
246
+ name = func.__name__
247
+ skip = (name == "meshcode_wait")
248
+ if asyncio.iscoroutinefunction(func):
249
+ @_functools.wraps(func)
250
+ async def awrapper(*args, **kwargs):
251
+ if not skip:
252
+ _schedule_flip("working", name)
253
+ try:
254
+ return await func(*args, **kwargs)
255
+ finally:
256
+ if not skip:
257
+ _schedule_flip("idle", "")
258
+ return awrapper
259
+ else:
260
+ @_functools.wraps(func)
261
+ def swrapper(*args, **kwargs):
262
+ if not skip:
263
+ _schedule_flip("working", name)
264
+ try:
265
+ return func(*args, **kwargs)
266
+ finally:
267
+ if not skip:
268
+ _schedule_flip("idle", "")
269
+ return swrapper
217
270
 
218
271
 
219
272
  # ============================================================
@@ -513,6 +566,7 @@ except Exception:
513
566
  # ----------------- TOOLS -----------------
514
567
 
515
568
  @mcp.tool()
569
+ @with_working_status
516
570
  def meshcode_send(to: str, message: Any, in_reply_to: Optional[str] = None,
517
571
  sensitive: bool = False) -> Dict[str, Any]:
518
572
  """Send a message to another agent in the meshwork.
@@ -548,6 +602,7 @@ def meshcode_send(to: str, message: Any, in_reply_to: Optional[str] = None,
548
602
 
549
603
 
550
604
  @mcp.tool()
605
+ @with_working_status
551
606
  def meshcode_broadcast(payload: Dict[str, Any]) -> Dict[str, Any]:
552
607
  """Send a message to ALL agents in the meshwork (except yourself).
553
608
 
@@ -564,6 +619,7 @@ def meshcode_broadcast(payload: Dict[str, Any]) -> Dict[str, Any]:
564
619
 
565
620
 
566
621
  @mcp.tool()
622
+ @with_working_status
567
623
  def meshcode_read(include_acks: bool = False) -> Dict[str, Any]:
568
624
  """Read all pending (unread) messages for this agent. Marks them as read and ACKs senders.
569
625
 
@@ -608,6 +664,7 @@ def _detect_global_done(messages: List[Dict[str, Any]]) -> Optional[Dict[str, An
608
664
 
609
665
 
610
666
  @mcp.tool()
667
+ @with_working_status
611
668
  async def meshcode_wait(timeout_seconds: int = 120, include_acks: bool = False) -> Dict[str, Any]:
612
669
  """LONG-POLL: Block until a new message arrives for this agent (or timeout).
613
670
 
@@ -689,6 +746,7 @@ async def meshcode_wait(timeout_seconds: int = 120, include_acks: bool = False)
689
746
 
690
747
 
691
748
  @mcp.tool()
749
+ @with_working_status
692
750
  def meshcode_done(reason: str) -> Dict[str, Any]:
693
751
  """Broadcast a GLOBAL DONE signal to every other agent in the meshwork.
694
752
 
@@ -716,6 +774,7 @@ def meshcode_done(reason: str) -> Dict[str, Any]:
716
774
 
717
775
 
718
776
  @mcp.tool()
777
+ @with_working_status
719
778
  def meshcode_check(include_acks: bool = False) -> Dict[str, Any]:
720
779
  """Quick poll: returns pending message count + any messages buffered by the
721
780
  Realtime listener since the last check.
@@ -739,6 +798,7 @@ def meshcode_check(include_acks: bool = False) -> Dict[str, Any]:
739
798
 
740
799
 
741
800
  @mcp.tool()
801
+ @with_working_status
742
802
  def meshcode_status() -> Dict[str, Any]:
743
803
  """Get the meshwork status board: all agents with their status, role, and current task."""
744
804
  agents = be.get_board(_PROJECT_ID)
@@ -758,6 +818,7 @@ def meshcode_status() -> Dict[str, Any]:
758
818
 
759
819
 
760
820
  @mcp.tool()
821
+ @with_working_status
761
822
  def meshcode_register(role: str = "") -> Dict[str, Any]:
762
823
  """Re-register this agent in the meshwork. Use if you got disconnected or
763
824
  want to update your role description.
@@ -777,6 +838,7 @@ def meshcode_set_status(status: str, task: str = "") -> Dict[str, Any]:
777
838
 
778
839
 
779
840
  @mcp.tool()
841
+ @with_working_status
780
842
  def meshcode_init(project: str, agent: str, role: str = "") -> Dict[str, Any]:
781
843
  """Re-initialize this MCP session for a different (project, agent). Use ONLY
782
844
  if you need to dynamically switch context — normally the env vars set at
@@ -795,6 +857,7 @@ def meshcode_init(project: str, agent: str, role: str = "") -> Dict[str, Any]:
795
857
 
796
858
 
797
859
  @mcp.tool()
860
+ @with_working_status
798
861
  def meshcode_task_create(title: str, description: str = "", assignee: str = "*",
799
862
  priority: str = "normal", parent_task_id: Optional[str] = None) -> Dict[str, Any]:
800
863
  """Create a task in the meshwork's shared backlog. Use this when you (typically
@@ -816,6 +879,7 @@ def meshcode_task_create(title: str, description: str = "", assignee: str = "*",
816
879
 
817
880
 
818
881
  @mcp.tool()
882
+ @with_working_status
819
883
  def meshcode_tasks(status_filter: Optional[str] = None) -> Dict[str, Any]:
820
884
  """List tasks in the meshwork. Use this to discover work you can pick up.
821
885
 
@@ -829,6 +893,7 @@ def meshcode_tasks(status_filter: Optional[str] = None) -> Dict[str, Any]:
829
893
 
830
894
 
831
895
  @mcp.tool()
896
+ @with_working_status
832
897
  def meshcode_task_claim(task_id: str) -> Dict[str, Any]:
833
898
  """Atomically claim an open task. If the task is already claimed by someone
834
899
  else (or assigned to a different agent), this returns an error and another
@@ -842,6 +907,7 @@ def meshcode_task_claim(task_id: str) -> Dict[str, Any]:
842
907
 
843
908
 
844
909
  @mcp.tool()
910
+ @with_working_status
845
911
  def meshcode_task_complete(task_id: str, summary: str = "") -> Dict[str, Any]:
846
912
  """Mark a task as done. Only the claimer can complete it.
847
913
 
@@ -94,7 +94,11 @@ def _detect_editor() -> Optional[str]:
94
94
  return override
95
95
  print(f"[meshcode] WARNING: MESHCODE_EDITOR='{override}' not found in PATH", file=sys.stderr)
96
96
 
97
- for cmd in ("claude", "cursor", "code"):
97
+ # Detection order: most likely to be installed + best per-workspace MCP
98
+ # support first. codex is last because its MCP config is GLOBAL only —
99
+ # launching it doesn't take a workspace-scoped .mcp.json, so it should
100
+ # only be picked if nothing else is available.
101
+ for cmd in ("claude", "cursor", "code", "windsurf", "codex"):
98
102
  if shutil.which(cmd):
99
103
  return cmd
100
104
  return None
@@ -108,6 +112,16 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
108
112
  except Exception:
109
113
  pass
110
114
 
115
+ # Detect: are we already inside a Claude Code session? os.execvp(claude)
116
+ # from inside an existing claude won't work — claude needs a fresh
117
+ # interactive terminal it owns. Refuse with a clear message.
118
+ if os.environ.get("CLAUDECODE") == "1" or os.environ.get("CLAUDE_CODE_SESSION"):
119
+ print("[meshcode] ERROR: meshcode run cannot bootstrap a new agent from inside an", file=sys.stderr)
120
+ print("[meshcode] existing Claude Code session.", file=sys.stderr)
121
+ print("[meshcode] Open a fresh Terminal / iTerm window and run the command there.", file=sys.stderr)
122
+ print(f"[meshcode] (or copy this exact line into a new terminal: meshcode run {agent})", file=sys.stderr)
123
+ return 2
124
+
111
125
  found = _find_agent_workspace(agent, project)
112
126
  if not found:
113
127
  return 2
@@ -152,6 +166,19 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
152
166
  elif editor == "code":
153
167
  # VS Code (Cline reads .vscode/mcp.json from workspace cwd).
154
168
  cmd = [editor, str(ws)]
169
+ elif editor == "windsurf":
170
+ # Windsurf (Codeium fork of VS Code) — reads workspace cwd the same
171
+ # way VS Code does; global MCP config at ~/.codeium/windsurf/mcp_config.json.
172
+ cmd = [editor, str(ws)]
173
+ elif editor == "codex":
174
+ # Codex CLI reads ~/.codex/config.toml globally. There's no per-
175
+ # workspace flag, so launching is just `codex` with cwd set to the
176
+ # workspace dir so any file ops the agent does land there.
177
+ try:
178
+ os.chdir(ws)
179
+ except Exception:
180
+ pass
181
+ cmd = [editor]
155
182
  else:
156
183
  cmd = [editor, str(ws)]
157
184
 
@@ -84,6 +84,13 @@ def _probe_keyring() -> bool:
84
84
  except ImportError:
85
85
  _KEYRING_AVAILABLE = False
86
86
  _KEYRING_BACKEND_NAME = "(keyring not installed)"
87
+ except Exception:
88
+ # Defensive: a broken Win32 Credential Manager, locked profile,
89
+ # or weird security policy can make even keyring.get_keyring()
90
+ # raise. Never let it bubble up — secrets module must always
91
+ # import cleanly so the MCP server can boot.
92
+ _KEYRING_AVAILABLE = False
93
+ _KEYRING_BACKEND_NAME = "(keyring probe failed)"
87
94
  return _KEYRING_AVAILABLE
88
95
 
89
96
 
@@ -113,7 +120,7 @@ def _load_index() -> Dict[str, Any]:
113
120
  if not PROFILE_INDEX.exists():
114
121
  return {"profiles": {}}
115
122
  try:
116
- return json.loads(PROFILE_INDEX.read_text())
123
+ return json.loads(PROFILE_INDEX.read_text(encoding="utf-8"))
117
124
  except Exception:
118
125
  return {"profiles": {}}
119
126
 
@@ -121,7 +128,7 @@ def _load_index() -> Dict[str, Any]:
121
128
  def _save_index(idx: Dict[str, Any]) -> None:
122
129
  PROFILE_INDEX.parent.mkdir(parents=True, exist_ok=True)
123
130
  tmp = PROFILE_INDEX.with_suffix(".tmp")
124
- tmp.write_text(json.dumps(idx, indent=2))
131
+ tmp.write_text(json.dumps(idx, indent=2), encoding="utf-8")
125
132
  try:
126
133
  os.chmod(tmp, 0o600)
127
134
  except Exception:
@@ -174,7 +181,7 @@ def set_api_key(api_key: str, profile: str = DEFAULT_PROFILE,
174
181
  # Fallback: mode-600 JSON file (legacy path, secured)
175
182
  fallback_path = Path.home() / ".meshcode" / f"credentials.{profile}.json"
176
183
  fallback_path.parent.mkdir(parents=True, exist_ok=True)
177
- fallback_path.write_text(json.dumps({"api_key": api_key, **(meta or {})}, indent=2))
184
+ fallback_path.write_text(json.dumps({"api_key": api_key, **(meta or {})}, indent=2), encoding="utf-8")
178
185
  try:
179
186
  os.chmod(fallback_path, 0o600)
180
187
  except Exception:
@@ -204,7 +211,7 @@ def get_api_key(profile: str = DEFAULT_PROFILE) -> Optional[str]:
204
211
  fallback_path = Path.home() / ".meshcode" / f"credentials.{profile}.json"
205
212
  if fallback_path.exists():
206
213
  try:
207
- data = json.loads(fallback_path.read_text())
214
+ data = json.loads(fallback_path.read_text(encoding="utf-8"))
208
215
  if isinstance(data, dict) and data.get("api_key"):
209
216
  return data["api_key"]
210
217
  except Exception:
@@ -213,7 +220,7 @@ def get_api_key(profile: str = DEFAULT_PROFILE) -> Optional[str]:
213
220
  # Legacy pre-1.4.1 path: only valid for the default profile
214
221
  if profile == DEFAULT_PROFILE and LEGACY_CREDS_FILE.exists():
215
222
  try:
216
- data = json.loads(LEGACY_CREDS_FILE.read_text())
223
+ data = json.loads(LEGACY_CREDS_FILE.read_text(encoding="utf-8"))
217
224
  if isinstance(data, dict) and data.get("api_key"):
218
225
  return data["api_key"]
219
226
  except Exception:
@@ -299,13 +306,13 @@ def migrate_legacy_credentials() -> bool:
299
306
  # Nothing to migrate, but still drop the flag so we don't re-check forever
300
307
  try:
301
308
  MIGRATION_FLAG.parent.mkdir(parents=True, exist_ok=True)
302
- MIGRATION_FLAG.write_text("no legacy file")
309
+ MIGRATION_FLAG.write_text("no legacy file", encoding="utf-8")
303
310
  except Exception:
304
311
  pass
305
312
  return False
306
313
 
307
314
  try:
308
- data = json.loads(LEGACY_CREDS_FILE.read_text())
315
+ data = json.loads(LEGACY_CREDS_FILE.read_text(encoding="utf-8"))
309
316
  except Exception:
310
317
  return False
311
318
  api_key = (data or {}).get("api_key") if isinstance(data, dict) else None
@@ -327,7 +334,7 @@ def migrate_legacy_credentials() -> bool:
327
334
  MIGRATION_FLAG.write_text(json.dumps({
328
335
  "migrated_at_unix": int(__import__("time").time()),
329
336
  "backend": _KEYRING_BACKEND_NAME or "(unknown)",
330
- }))
337
+ }), encoding="utf-8")
331
338
  except Exception:
332
339
  pass
333
340
 
@@ -93,9 +93,9 @@ def _load_supabase_env() -> Dict[str, str]:
93
93
  elif k == "SUPABASE_KEY" and not key:
94
94
  key = v
95
95
  if not url:
96
- url = "https://wwgzzmydrwrjgaebspdo.supabase.co"
96
+ url = "https://gjinagyyjttyxnaoavnz.supabase.co"
97
97
  if not key:
98
- key = "sb_publishable_0qf0U1GURopPIxLR8Vu7eQ_5grflPP4"
98
+ key = "sb_publishable_qwN9PO1L7jUXhhbhhVk2CQ_z1FXG2Qf"
99
99
  return {"SUPABASE_URL": url, "SUPABASE_KEY": key}
100
100
 
101
101
 
@@ -147,6 +147,12 @@ def _build_server_block(project: str, project_id: str, agent: str, role: str,
147
147
  "MESHCODE_KEYCHAIN_PROFILE": keychain_profile,
148
148
  "SUPABASE_URL": sb["SUPABASE_URL"],
149
149
  "SUPABASE_KEY": sb["SUPABASE_KEY"],
150
+ # Force UTF-8 stdio so the MCP JSON-RPC channel doesn't crash on
151
+ # Windows cp1252 when the server emits any non-ASCII char.
152
+ "PYTHONIOENCODING": "utf-8",
153
+ # Mark this Python process as the MCP serve subprocess so other
154
+ # parts of the package (e.g. self_update) skip auto-update inside it.
155
+ "MESHCODE_MCP_SERVE": "1",
150
156
  }
151
157
  # Belt-and-suspenders fallback: if the OS doesn't have a keychain backend
152
158
  # the MCP server can't read the key from there, so we still need to pass
@@ -172,6 +178,93 @@ def _atomic_write_json(path: Path, data: dict) -> None:
172
178
  tmp.replace(path)
173
179
 
174
180
 
181
+ def _toml_escape(s: str) -> str:
182
+ """Escape a string for a TOML basic string literal."""
183
+ return s.replace("\\", "\\\\").replace("\"", "\\\"")
184
+
185
+
186
+ def _render_codex_block(server_id: str, server_block: Dict[str, Any]) -> str:
187
+ """Render a [mcp_servers.<server_id>] section (plus [.env] subtable) for
188
+ Codex CLI's ~/.codex/config.toml format. Returns a text block starting
189
+ with a leading newline so it appends cleanly to existing files."""
190
+ cmd = _toml_escape(str(server_block.get("command", "")))
191
+ args = server_block.get("args", []) or []
192
+ env = server_block.get("env", {}) or {}
193
+ # Quote the section name only if it contains special chars. server_id is
194
+ # our deterministic "meshcode-<project>-<agent>" slug; hyphens require
195
+ # quoting in TOML bare keys, so always quote.
196
+ section = f'"{_toml_escape(server_id)}"'
197
+ lines = []
198
+ lines.append("")
199
+ lines.append(f"[mcp_servers.{section}]")
200
+ lines.append(f'command = "{cmd}"')
201
+ args_rendered = ", ".join(f'"{_toml_escape(str(a))}"' for a in args)
202
+ lines.append(f"args = [{args_rendered}]")
203
+ if env:
204
+ lines.append("")
205
+ lines.append(f"[mcp_servers.{section}.env]")
206
+ for k, v in env.items():
207
+ lines.append(f'{k} = "{_toml_escape(str(v))}"')
208
+ lines.append("")
209
+ return "\n".join(lines)
210
+
211
+
212
+ def _atomic_write_toml_codex(path: Path, server_id: str, server_block: Dict[str, Any]) -> None:
213
+ """Read/replace/write ~/.codex/config.toml preserving all other sections.
214
+
215
+ Codex CLI uses TOML with [mcp_servers.<name>] tables (optionally a
216
+ [mcp_servers.<name>.env] subtable). We don't want to round-trip the
217
+ entire TOML file (stdlib can only READ TOML via tomllib, not write),
218
+ so we do surgical text replacement:
219
+
220
+ * If [mcp_servers."<server_id>"] already exists, find that section
221
+ header and delete everything from it up to (but not including) the
222
+ next top-level-ish section header that is NOT a sub-table of the
223
+ same server (i.e. next `[` that doesn't start with
224
+ `[mcp_servers."<server_id>".`), or EOF.
225
+ * Then append the freshly rendered block.
226
+ * If no prior section, just append to end.
227
+
228
+ Atomic via tempfile + replace.
229
+ """
230
+ path.parent.mkdir(parents=True, exist_ok=True)
231
+ text = path.read_text() if path.exists() else ""
232
+
233
+ quoted = f'"{_toml_escape(server_id)}"'
234
+ header = f"[mcp_servers.{quoted}]"
235
+ subheader_prefix = f"[mcp_servers.{quoted}."
236
+
237
+ lines = text.splitlines(keepends=True)
238
+ out: list = []
239
+ i = 0
240
+ removed = False
241
+ while i < len(lines):
242
+ stripped = lines[i].lstrip()
243
+ if not removed and (stripped.startswith(header) or stripped.startswith(subheader_prefix)):
244
+ # Skip this section and any following lines until we hit a new
245
+ # top-level `[` section that is NOT our server's sub-table.
246
+ i += 1
247
+ while i < len(lines):
248
+ s2 = lines[i].lstrip()
249
+ if s2.startswith("[") and not s2.startswith(subheader_prefix) and not s2.startswith(header):
250
+ break
251
+ i += 1
252
+ removed = True
253
+ continue
254
+ out.append(lines[i])
255
+ i += 1
256
+
257
+ new_text = "".join(out)
258
+ # Ensure exactly one trailing newline before we append our block.
259
+ if new_text and not new_text.endswith("\n"):
260
+ new_text += "\n"
261
+ new_text += _render_codex_block(server_id, server_block)
262
+
263
+ tmp = path.with_suffix(path.suffix + ".tmp")
264
+ tmp.write_text(new_text)
265
+ tmp.replace(path)
266
+
267
+
175
268
  # ============================================================
176
269
  # WORKSPACE MODEL (1.3.0+) — one isolated dir per agent
177
270
  # ============================================================
@@ -325,6 +418,21 @@ CLIENT_CONFIG_PATHS = {
325
418
  "Linux": Path.home() / ".config" / "Claude" / "claude_desktop_config.json",
326
419
  "Windows": Path.home() / "AppData" / "Roaming" / "Claude" / "claude_desktop_config.json",
327
420
  },
421
+ # Windsurf (Codeium): global MCP config in ~/.codeium/windsurf/mcp_config.json
422
+ # (JSON, same {"mcpServers": {...}} shape as Cursor/Cline). Cross-platform —
423
+ # Windsurf stores user config under ~/.codeium regardless of OS.
424
+ "windsurf": {
425
+ "Darwin": Path.home() / ".codeium" / "windsurf" / "mcp_config.json",
426
+ "Linux": Path.home() / ".codeium" / "windsurf" / "mcp_config.json",
427
+ "Windows": Path.home() / ".codeium" / "windsurf" / "mcp_config.json",
428
+ },
429
+ # OpenAI Codex CLI: ~/.codex/config.toml with [mcp_servers.<name>] tables.
430
+ # This is NOT JSON — handled by the custom TOML writer below.
431
+ "codex": {
432
+ "Darwin": Path.home() / ".codex" / "config.toml",
433
+ "Linux": Path.home() / ".codex" / "config.toml",
434
+ "Windows": Path.home() / ".codex" / "config.toml",
435
+ },
328
436
  }
329
437
 
330
438
  CLIENT_DISPLAY_NAMES = {
@@ -332,8 +440,13 @@ CLIENT_DISPLAY_NAMES = {
332
440
  "cursor": "Cursor",
333
441
  "cline": "Cline (VS Code)",
334
442
  "claude-desktop": "Claude Desktop",
443
+ "windsurf": "Windsurf (Codeium)",
444
+ "codex": "OpenAI Codex CLI",
335
445
  }
336
446
 
447
+ # Clients whose config file is NOT JSON and need a custom writer.
448
+ NON_JSON_CLIENTS = {"codex"}
449
+
337
450
 
338
451
  def setup_global(client: str, project: str, agent: str, role: str = "") -> int:
339
452
  """LEGACY: write to the user's GLOBAL MCP config file. All agents that
@@ -355,25 +468,35 @@ def setup_global(client: str, project: str, agent: str, role: str = "") -> int:
355
468
  print(f"[meshcode] ERROR: '{client}' is not supported on {os_name}", file=sys.stderr)
356
469
  return 2
357
470
 
358
- existing: Dict[str, Any] = {}
359
- if config_path.exists():
360
- try:
361
- existing = json.loads(config_path.read_text())
362
- except json.JSONDecodeError:
363
- print(f"[meshcode] WARNING: {config_path} exists but is not valid JSON.", file=sys.stderr)
364
- return 2
365
-
366
- if not isinstance(existing, dict):
367
- existing = {}
471
+ server_id = f"meshcode-{project}-{agent}"
472
+ server_block = _build_server_block(project, project_id, agent, role, api_key, sb)
368
473
 
369
- servers = existing.setdefault("mcpServers", {})
370
- if not isinstance(servers, dict):
371
- print(f"[meshcode] ERROR: 'mcpServers' in {config_path} is not an object", file=sys.stderr)
372
- return 2
474
+ if client in NON_JSON_CLIENTS:
475
+ # Dispatch to per-format writers (preserves other sections).
476
+ if client == "codex":
477
+ _atomic_write_toml_codex(config_path, server_id, server_block)
478
+ else:
479
+ print(f"[meshcode] ERROR: no writer registered for non-JSON client '{client}'", file=sys.stderr)
480
+ return 2
481
+ else:
482
+ existing: Dict[str, Any] = {}
483
+ if config_path.exists():
484
+ try:
485
+ existing = json.loads(config_path.read_text())
486
+ except json.JSONDecodeError:
487
+ print(f"[meshcode] WARNING: {config_path} exists but is not valid JSON.", file=sys.stderr)
488
+ return 2
489
+
490
+ if not isinstance(existing, dict):
491
+ existing = {}
492
+
493
+ servers = existing.setdefault("mcpServers", {})
494
+ if not isinstance(servers, dict):
495
+ print(f"[meshcode] ERROR: 'mcpServers' in {config_path} is not an object", file=sys.stderr)
496
+ return 2
373
497
 
374
- server_id = f"meshcode-{project}-{agent}"
375
- servers[server_id] = _build_server_block(project, project_id, agent, role, api_key, sb)
376
- _atomic_write_json(config_path, existing)
498
+ servers[server_id] = server_block
499
+ _atomic_write_json(config_path, existing)
377
500
 
378
501
  display = CLIENT_DISPLAY_NAMES[client]
379
502
  print(f"[meshcode] MCP server '{server_id}' added to {display} GLOBAL config")
@@ -404,7 +527,7 @@ def setup(*args) -> int:
404
527
  print("Usage:", file=sys.stderr)
405
528
  print(" meshcode setup <project> <agent> [role] # workspace flow (recommended)", file=sys.stderr)
406
529
  print(" meshcode setup <client> <project> <agent> [role] # legacy global flow", file=sys.stderr)
407
- print(" Clients: claude-code, cursor, cline, claude-desktop", file=sys.stderr)
530
+ print(" Clients: claude-code, cursor, cline, claude-desktop, windsurf, codex", file=sys.stderr)
408
531
  return 1
409
532
 
410
533
  if args[0] in CLIENT_CONFIG_PATHS:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 1.8.4
3
+ Version: 1.8.8
4
4
  Summary: Real-time communication between AI agents — Supabase-backed CLI
5
5
  Author-email: MeshCode <hello@meshcode.io>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "meshcode"
7
- version = "1.8.4"
7
+ version = "1.8.8"
8
8
  description = "Real-time communication between AI agents — Supabase-backed CLI"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -1,17 +0,0 @@
1
- """Run via: python -m meshcode_mcp serve"""
2
- import sys
3
- from .server import run_server
4
-
5
-
6
- def main():
7
- args = sys.argv[1:]
8
- if args and args[0] == "serve":
9
- run_server()
10
- else:
11
- print("Usage: python -m meshcode_mcp serve", file=sys.stderr)
12
- print(" (env vars: MESHCODE_PROJECT, MESHCODE_AGENT, SUPABASE_URL, SUPABASE_KEY)", file=sys.stderr)
13
- sys.exit(1)
14
-
15
-
16
- if __name__ == "__main__":
17
- main()
File without changes
File without changes
File without changes
File without changes