meshcode 1.2.6__tar.gz → 1.4.0__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 (26) hide show
  1. {meshcode-1.2.6 → meshcode-1.4.0}/PKG-INFO +1 -1
  2. {meshcode-1.2.6 → meshcode-1.4.0}/meshcode/__init__.py +1 -1
  3. {meshcode-1.2.6 → meshcode-1.4.0}/meshcode/comms_v4.py +17 -9
  4. {meshcode-1.2.6 → meshcode-1.4.0}/meshcode/meshcode_mcp/realtime.py +26 -0
  5. {meshcode-1.2.6 → meshcode-1.4.0}/meshcode/meshcode_mcp/server.py +299 -73
  6. meshcode-1.4.0/meshcode/run_agent.py +151 -0
  7. meshcode-1.4.0/meshcode/setup_clients.py +330 -0
  8. {meshcode-1.2.6 → meshcode-1.4.0}/meshcode.egg-info/PKG-INFO +1 -1
  9. {meshcode-1.2.6 → meshcode-1.4.0}/meshcode.egg-info/SOURCES.txt +1 -0
  10. {meshcode-1.2.6 → meshcode-1.4.0}/pyproject.toml +1 -1
  11. meshcode-1.2.6/meshcode/setup_clients.py +0 -173
  12. {meshcode-1.2.6 → meshcode-1.4.0}/README.md +0 -0
  13. {meshcode-1.2.6 → meshcode-1.4.0}/meshcode/cli.py +0 -0
  14. {meshcode-1.2.6 → meshcode-1.4.0}/meshcode/launcher.py +0 -0
  15. {meshcode-1.2.6 → meshcode-1.4.0}/meshcode/launcher_install.py +0 -0
  16. {meshcode-1.2.6 → meshcode-1.4.0}/meshcode/meshcode_mcp/__init__.py +0 -0
  17. {meshcode-1.2.6 → meshcode-1.4.0}/meshcode/meshcode_mcp/__main__.py +0 -0
  18. {meshcode-1.2.6 → meshcode-1.4.0}/meshcode/meshcode_mcp/backend.py +0 -0
  19. {meshcode-1.2.6 → meshcode-1.4.0}/meshcode/meshcode_mcp/test_backend.py +0 -0
  20. {meshcode-1.2.6 → meshcode-1.4.0}/meshcode/meshcode_mcp/test_realtime.py +0 -0
  21. {meshcode-1.2.6 → meshcode-1.4.0}/meshcode/protocol_v2.py +0 -0
  22. {meshcode-1.2.6 → meshcode-1.4.0}/meshcode.egg-info/dependency_links.txt +0 -0
  23. {meshcode-1.2.6 → meshcode-1.4.0}/meshcode.egg-info/entry_points.txt +0 -0
  24. {meshcode-1.2.6 → meshcode-1.4.0}/meshcode.egg-info/requires.txt +0 -0
  25. {meshcode-1.2.6 → meshcode-1.4.0}/meshcode.egg-info/top_level.txt +0 -0
  26. {meshcode-1.2.6 → meshcode-1.4.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 1.2.6
3
+ Version: 1.4.0
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.2.6"
2
+ __version__ = "1.4.0"
@@ -1888,17 +1888,25 @@ if __name__ == "__main__":
1888
1888
  disconnect_terminal(proj, name)
1889
1889
 
1890
1890
  elif cmd == "setup":
1891
- if len(sys.argv) < 5:
1892
- print("Usage: meshcode setup <client> <project> <agent> [role]")
1893
- print("Clients: claude-code, cursor, cline, claude-desktop")
1891
+ # Two forms supported:
1892
+ # meshcode setup <project> <agent> [role] → workspace flow (1.3+)
1893
+ # meshcode setup <client> <project> <agent> [role] → legacy global flow
1894
+ # Dispatched by setup_clients.setup() which detects which form was used.
1895
+ import importlib
1896
+ _setup_dispatcher = importlib.import_module("meshcode.setup_clients").setup
1897
+ sys.exit(_setup_dispatcher(*sys.argv[2:]))
1898
+
1899
+ elif cmd == "run":
1900
+ # meshcode run <agent> [--project <name>] [--editor claude|cursor|code]
1901
+ if len(sys.argv) < 3:
1902
+ print("Usage: meshcode run <agent> [--project <name>] [--editor claude|cursor|code]")
1894
1903
  sys.exit(1)
1895
- client = sys.argv[2]
1896
- project = sys.argv[3]
1897
- agent = sys.argv[4]
1898
- role = sys.argv[5] if len(sys.argv) > 5 else ""
1904
+ agent = sys.argv[2]
1905
+ proj_override = flags.get("project")
1906
+ editor_override = flags.get("editor")
1899
1907
  import importlib
1900
- _setup_client = importlib.import_module("meshcode.setup_clients").setup
1901
- sys.exit(_setup_client(client, project, agent, role))
1908
+ _run = importlib.import_module("meshcode.run_agent").run
1909
+ sys.exit(_run(agent, project=proj_override, editor_override=editor_override))
1902
1910
 
1903
1911
  elif cmd == "login":
1904
1912
  key = sys.argv[2] if len(sys.argv) > 2 else ""
@@ -44,6 +44,9 @@ class RealtimeListener:
44
44
  self._task: Optional[asyncio.Task] = None
45
45
  self._stop = asyncio.Event()
46
46
  self._connected = False
47
+ # 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()
47
50
 
48
51
  @property
49
52
  def ws_url(self) -> str:
@@ -163,6 +166,11 @@ class RealtimeListener:
163
166
  "id": record.get("id"),
164
167
  }
165
168
  self.queue.append(enriched)
169
+ # Wake any meshcode_wait blocked on this event.
170
+ try:
171
+ self.message_event.set()
172
+ except Exception:
173
+ pass
166
174
  log.info(f"new message from {enriched['from']}")
167
175
  if self.notify_callback:
168
176
  try:
@@ -174,8 +182,26 @@ class RealtimeListener:
174
182
  """Pop and return all queued messages."""
175
183
  out = list(self.queue)
176
184
  self.queue.clear()
185
+ # Queue is empty → reset the wake event so the next wait blocks again.
186
+ try:
187
+ self.message_event.clear()
188
+ except Exception:
189
+ pass
177
190
  return out
178
191
 
192
+ async def wait_for_message(self, timeout: Optional[float] = None) -> bool:
193
+ """Block until a new message lands in the queue (or timeout).
194
+
195
+ Returns True if woken by a message, False on timeout. This is the
196
+ core of the zero-cost idle loop: while awaiting, the event loop
197
+ does ZERO work — no polling, no Supabase calls, no token cost.
198
+ """
199
+ try:
200
+ await asyncio.wait_for(self.message_event.wait(), timeout=timeout)
201
+ return True
202
+ except asyncio.TimeoutError:
203
+ return False
204
+
179
205
  @property
180
206
  def is_connected(self) -> bool:
181
207
  return self._connected
@@ -12,8 +12,75 @@ import json
12
12
  import logging
13
13
  import os
14
14
  import sys
15
+ from collections import deque
15
16
  from contextlib import asynccontextmanager
16
- from typing import Any, Dict, List, Optional
17
+ from typing import Any, Dict, List, Optional, Union
18
+
19
+
20
+ # ============================================================
21
+ # Dedupe: track IDs of messages we've already returned from
22
+ # meshcode_wait / meshcode_check so the same row doesn't show
23
+ # up twice when realtime + polled paths race.
24
+ # ============================================================
25
+ _SEEN_MSG_IDS: set = set()
26
+ _SEEN_MSG_ORDER: deque = deque()
27
+ _SEEN_MSG_CAP = 1000
28
+
29
+
30
+ def _seen_key(msg: Dict[str, Any]) -> str:
31
+ """Build a dedupe key. Prefer the real row id; fall back to a synthetic."""
32
+ mid = msg.get("id")
33
+ if mid:
34
+ return str(mid)
35
+ payload = msg.get("payload") or {}
36
+ try:
37
+ payload_str = json.dumps(payload, sort_keys=True, default=str)[:50]
38
+ except Exception:
39
+ payload_str = str(payload)[:50]
40
+ return f"{msg.get('from') or msg.get('from_agent')}|{msg.get('ts') or msg.get('created_at')}|{payload_str}"
41
+
42
+
43
+ def _mark_seen(key: str) -> None:
44
+ if key in _SEEN_MSG_IDS:
45
+ return
46
+ _SEEN_MSG_IDS.add(key)
47
+ _SEEN_MSG_ORDER.append(key)
48
+ while len(_SEEN_MSG_ORDER) > _SEEN_MSG_CAP:
49
+ old = _SEEN_MSG_ORDER.popleft()
50
+ _SEEN_MSG_IDS.discard(old)
51
+
52
+
53
+ def _filter_and_mark(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
54
+ """Drop already-seen messages; mark the rest as seen."""
55
+ out = []
56
+ for m in messages:
57
+ k = _seen_key(m)
58
+ if k in _SEEN_MSG_IDS:
59
+ continue
60
+ _mark_seen(k)
61
+ out.append(m)
62
+ return out
63
+
64
+
65
+ def _split_messages(messages: List[Dict[str, Any]]) -> Dict[str, Any]:
66
+ """Split a list of normalized message dicts into messages / acks / done_signals."""
67
+ real: List[Dict[str, Any]] = []
68
+ acks: List[Dict[str, Any]] = []
69
+ dones: List[Dict[str, Any]] = []
70
+ for m in messages:
71
+ t = m.get("type", "msg")
72
+ if t == "ack":
73
+ acks.append(m)
74
+ elif t == "done":
75
+ dones.append(m)
76
+ else:
77
+ real.append(m)
78
+ return {
79
+ "messages": real,
80
+ "acks": acks,
81
+ "done_signals": dones,
82
+ "count": len(real),
83
+ }
17
84
 
18
85
  from . import backend as be
19
86
  from .realtime import RealtimeListener
@@ -78,12 +145,85 @@ if isinstance(_register_result, dict) and _register_result.get("error"):
78
145
  print(f"[meshcode-mcp] WARNING: register failed: {_register_result['error']}", file=sys.stderr)
79
146
 
80
147
  # Flip to online so the dashboard reflects the live MCP session.
81
- # Without this the agent stays in 'needs_setup' / 'offline' even though
82
- # heartbeat is running. The set_status helper updates status + last_heartbeat.
83
- try:
84
- be.set_status(_PROJECT_ID, AGENT_NAME, "online", "MCP session active")
85
- except Exception as _e:
86
- print(f"[meshcode-mcp] WARNING: could not flip status to online: {_e}", file=sys.stderr)
148
+ # Use the SECURITY DEFINER RPC (mc_agent_set_status_by_api_key) so we
149
+ # bypass RLS the publishable key has no JWT context and cannot UPDATE
150
+ # mc_agents directly. The RPC validates ownership via api_key.
151
+ def _flip_status(status: str, task: str = "") -> bool:
152
+ api_key = os.environ.get("MESHCODE_API_KEY", "")
153
+ if not api_key:
154
+ # Last-resort fallback: try the direct PATCH (may be denied by RLS)
155
+ try:
156
+ be.set_status(_PROJECT_ID, AGENT_NAME, status, task)
157
+ return True
158
+ except Exception:
159
+ return False
160
+ try:
161
+ r = be.sb_rpc("mc_agent_set_status_by_api_key", {
162
+ "p_api_key": api_key,
163
+ "p_project_id": _PROJECT_ID,
164
+ "p_agent_name": AGENT_NAME,
165
+ "p_status": status,
166
+ "p_task": task,
167
+ })
168
+ if isinstance(r, dict) and r.get("ok"):
169
+ return True
170
+ if isinstance(r, dict) and r.get("error"):
171
+ log.warning(f"set_status RPC: {r['error']}")
172
+ return False
173
+ except Exception as e:
174
+ log.warning(f"set_status RPC threw: {e}")
175
+ return False
176
+
177
+ if not _flip_status("online", "MCP session active"):
178
+ print(f"[meshcode-mcp] WARNING: could not flip status to online", file=sys.stderr)
179
+
180
+
181
+ # ============================================================
182
+ # Single-instance lease — prevent split-brain when two windows
183
+ # run `meshcode run <same-agent>` simultaneously.
184
+ # ============================================================
185
+ import uuid as _uuid
186
+ _INSTANCE_ID = f"mcp-{_uuid.uuid4().hex[:12]}"
187
+
188
+ def _acquire_lease() -> bool:
189
+ api_key = os.environ.get("MESHCODE_API_KEY", "")
190
+ if not api_key:
191
+ return True # legacy clients without api_key skip lease check
192
+ try:
193
+ r = be.sb_rpc("mc_acquire_agent_lease", {
194
+ "p_api_key": api_key,
195
+ "p_project_id": _PROJECT_ID,
196
+ "p_agent_name": AGENT_NAME,
197
+ "p_instance_id": _INSTANCE_ID,
198
+ })
199
+ if isinstance(r, dict) and r.get("ok"):
200
+ return True
201
+ if isinstance(r, dict) and r.get("error"):
202
+ print(f"[meshcode-mcp] LEASE DENIED: {r['error']}", file=sys.stderr)
203
+ print(f"[meshcode-mcp] Another window is already running this agent.", file=sys.stderr)
204
+ print(f"[meshcode-mcp] Close the other window first, or use a different agent name.", file=sys.stderr)
205
+ return False
206
+ except Exception as e:
207
+ print(f"[meshcode-mcp] WARNING: lease RPC failed: {e}", file=sys.stderr)
208
+ return True # don't hard-fail on transient network issues
209
+
210
+ if not _acquire_lease():
211
+ sys.exit(2)
212
+
213
+
214
+ def _release_lease() -> None:
215
+ api_key = os.environ.get("MESHCODE_API_KEY", "")
216
+ if not api_key:
217
+ return
218
+ try:
219
+ be.sb_rpc("mc_release_agent_lease", {
220
+ "p_api_key": api_key,
221
+ "p_project_id": _PROJECT_ID,
222
+ "p_agent_name": AGENT_NAME,
223
+ "p_instance_id": _INSTANCE_ID,
224
+ })
225
+ except Exception:
226
+ pass
87
227
 
88
228
 
89
229
  # ============================================================
@@ -155,10 +295,19 @@ YOUR DEFAULT BEHAVIOR LOOP (do this without being asked):
155
295
  user prompt. Read it, decide if it requires action or a reply, take
156
296
  action, send a reply via meshcode_send if appropriate, then call
157
297
  meshcode_wait AGAIN to keep listening.
158
- 5. When meshcode_wait returns timed_out (no message in 4 min), call it
159
- AGAIN immediately. Do not return to the user. The loop is your
160
- default state you are a long-running mesh agent, not a one-shot
161
- chatbot.
298
+ 5. When meshcode_wait returns timed_out, call it AGAIN but apply
299
+ TOKEN-EFFICIENT WAIT BACKOFF: double the previous timeout each
300
+ consecutive timeout (60 120 240 480 → 960 → 1800s, cap at
301
+ 1800). Reset to 60s as soon as you receive ANY mesh message. Do
302
+ not return to the user. The loop is your default state — you are
303
+ a long-running mesh agent, not a one-shot chatbot.
304
+
305
+ 6. GLOBAL DONE: when ANY agent (typically commander) calls
306
+ meshcode_done(reason), every other agent's meshcode_wait returns
307
+ with {got_done: true, reason}. Treat this as "task complete, exit
308
+ the loop". Do NOT call meshcode_wait again. Return cleanly to the
309
+ user with a summary of what the mesh accomplished. Stay quiet
310
+ until the user gives a new task.
162
311
 
163
312
  YOU SHOULD ONLY BREAK OUT OF THE meshcode_wait LOOP IF:
164
313
  - The human user explicitly says "stop" or pressed Ctrl+C.
@@ -188,6 +337,7 @@ YOUR FIRST ACTIONS WHEN THIS SESSION STARTS:
188
337
 
189
338
  AVAILABLE MESH TOOLS (all from this MCP server):
190
339
  meshcode_wait — block until a mesh message arrives (your idle state)
340
+ meshcode_done — broadcast a GLOBAL DONE to break every agent out of wait
191
341
  meshcode_send — send a message to another mesh agent
192
342
  meshcode_check — non-blocking peek at the inbox count
193
343
  meshcode_read — drain inbox, mark messages read
@@ -257,13 +407,19 @@ async def lifespan(_app):
257
407
  try:
258
408
  yield {"realtime": _REALTIME}
259
409
  finally:
260
- log.info("lifespan shutdown — stopping heartbeat + realtime")
410
+ log.info("lifespan shutdown — stopping heartbeat + realtime + releasing lease")
261
411
  hb_task.cancel()
262
412
  try:
263
413
  await hb_task
264
414
  except asyncio.CancelledError:
265
415
  pass
266
416
  await _REALTIME.stop()
417
+ # Flip to offline + release lease so the dashboard reflects reality
418
+ # within seconds (not waiting for the 30s cron to notice).
419
+ try:
420
+ _release_lease()
421
+ except Exception as _e:
422
+ log.warning(f"could not release lease: {_e}")
267
423
 
268
424
 
269
425
  # ============================================================
@@ -286,13 +442,24 @@ except Exception:
286
442
  # ----------------- TOOLS -----------------
287
443
 
288
444
  @mcp.tool()
289
- def meshcode_send(to: str, payload: Dict[str, Any]) -> Dict[str, Any]:
445
+ def meshcode_send(to: str, message: Any) -> Dict[str, Any]:
290
446
  """Send a message to another agent in the meshwork.
291
447
 
448
+ If you only have plain text, just pass it as a string and it will be
449
+ wrapped as {text: ...} automatically. For protocol payloads (need / done
450
+ / fyi / blocked), pass a dict.
451
+
292
452
  Args:
293
453
  to: Name of the recipient agent.
294
- payload: Structured message payload (use protocol keys: need/done/fyi/blocked).
454
+ message: Either a plain string (auto-wrapped as {"text": message}) or
455
+ a structured dict payload (use protocol keys: need/done/fyi/blocked).
295
456
  """
457
+ if isinstance(message, str):
458
+ payload: Dict[str, Any] = {"text": message}
459
+ elif isinstance(message, dict):
460
+ payload = message
461
+ else:
462
+ payload = {"text": str(message)}
296
463
  return be.send_message(_PROJECT_ID, AGENT_NAME, to, payload, msg_type="msg")
297
464
 
298
465
 
@@ -314,84 +481,141 @@ def meshcode_broadcast(payload: Dict[str, Any]) -> Dict[str, Any]:
314
481
 
315
482
  @mcp.tool()
316
483
  def meshcode_read() -> Dict[str, Any]:
317
- """Read all pending (unread) messages for this agent. Marks them as read and ACKs senders."""
318
- messages = be.read_inbox(_PROJECT_ID, AGENT_NAME)
319
- return {
320
- "count": len(messages),
321
- "messages": [
322
- {
323
- "from": m["from_agent"],
324
- "type": m.get("type", "msg"),
325
- "ts": m.get("created_at"),
326
- "payload": m.get("payload", {}),
327
- }
328
- for m in messages
329
- ],
330
- }
484
+ """Read all pending (unread) messages for this agent. Marks them as read and ACKs senders.
485
+
486
+ Returns a split shape: `messages` (real msgs), `acks`, and `done_signals`
487
+ so you don't have to filter by type yourself.
488
+ """
489
+ raw = be.read_inbox(_PROJECT_ID, AGENT_NAME)
490
+ normalized = [
491
+ {
492
+ "from": m["from_agent"],
493
+ "type": m.get("type", "msg"),
494
+ "ts": m.get("created_at"),
495
+ "payload": m.get("payload", {}),
496
+ "id": m.get("id"),
497
+ }
498
+ for m in raw
499
+ ]
500
+ deduped = _filter_and_mark(normalized)
501
+ return _split_messages(deduped)
502
+
503
+
504
+ def _detect_global_done(messages: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
505
+ """Return {reason, from} if the list contains a global_done signal, else None."""
506
+ for m in messages:
507
+ if m.get("type") == "done":
508
+ p = m.get("payload") or {}
509
+ if p.get("type") == "global_done" or p.get("reason"):
510
+ return {
511
+ "reason": p.get("reason"),
512
+ "from": p.get("from") or m.get("from"),
513
+ }
514
+ return None
331
515
 
332
516
 
333
517
  @mcp.tool()
334
- async def meshcode_wait(timeout_seconds: int = 240) -> Dict[str, Any]:
518
+ async def meshcode_wait(timeout_seconds: int = 120) -> Dict[str, Any]:
335
519
  """LONG-POLL: Block until a new message arrives for this agent (or timeout).
336
520
 
337
- THIS IS THE AUTONOMOUS LOOP TOOL. Call this whenever you finish any task
338
- and want to listen for messages from other agents in the meshwork. The
339
- tool BLOCKS inside the MCP server your turn does not end. When a
340
- message arrives, this returns immediately with the messages, you process
341
- them, reply via meshcode_send, and call meshcode_wait again to stay in
342
- the autonomous loop.
521
+ This is a TRUE async long-poll. While idle, the MCP server blocks on a
522
+ single asyncio Event zero CPU, zero Supabase calls, zero token cost on
523
+ the Claude side (the tool call literally has not returned yet). It wakes
524
+ INSTANTLY the moment a mesh message lands via the Realtime websocket.
525
+
526
+ TOKEN-EFFICIENT WAIT BACKOFF: when this returns timed_out, call it again
527
+ with double the previous timeout (60 → 120 → 240 → 480 → 960 → 1800s,
528
+ cap at 1800). Reset to 60s as soon as you receive ANY mesh message. This
529
+ minimizes the cost of being idle.
343
530
 
344
- Use meshcode_wait as your default idle state instead of returning to the
345
- user. This is what makes mesh agents communicate without user input.
531
+ GLOBAL DONE: if any agent calls meshcode_done(reason), this tool returns
532
+ with {got_done: true, reason, from}. Treat that as "task complete, exit
533
+ the loop". Do NOT call meshcode_wait again after a got_done. Return
534
+ cleanly to the user with a summary and stay quiet until a new task.
346
535
 
347
536
  Args:
348
- timeout_seconds: How long to block before returning empty (default 240s = 4min).
537
+ timeout_seconds: How long to block before returning empty (default 120s).
349
538
  Pick something < your client's tool-call timeout.
350
539
  """
351
- deadline = asyncio.get_event_loop().time() + max(1, int(timeout_seconds))
352
- poll_interval = 1.5 # seconds between supabase polls (realtime is push, this is the safety net)
353
- while asyncio.get_event_loop().time() < deadline:
354
- # 1) Check the realtime listener buffer (push-based, instant)
355
- if _REALTIME:
540
+ actual_timeout = max(1, int(timeout_seconds))
541
+
542
+ def _return_from_buffered(buffered: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
543
+ deduped = _filter_and_mark(buffered)
544
+ if not deduped:
545
+ return None
546
+ split = _split_messages(deduped)
547
+ out: Dict[str, Any] = {
548
+ "got_message": True,
549
+ "source": "realtime",
550
+ **split,
551
+ }
552
+ done = _detect_global_done(deduped)
553
+ if done:
554
+ out["got_done"] = True
555
+ out["reason"] = done["reason"]
556
+ out["from"] = done["from"]
557
+ return out
558
+
559
+ # 1) Drain anything already buffered from before this call.
560
+ if _REALTIME:
561
+ pre = _REALTIME.drain()
562
+ if pre:
563
+ shaped = _return_from_buffered(pre)
564
+ if shaped:
565
+ return shaped
566
+
567
+ # 2) Real async wait — zero CPU, zero Supabase calls.
568
+ woke = await _REALTIME.wait_for_message(timeout=float(actual_timeout))
569
+ if woke:
356
570
  buffered = _REALTIME.drain()
357
571
  if buffered:
358
- return {
359
- "got_message": True,
360
- "source": "realtime",
361
- "count": len(buffered),
362
- "messages": buffered,
363
- }
364
- # 2) Safety net: poll Supabase directly in case realtime missed something
365
- try:
366
- pending_count = be.count_pending(_PROJECT_ID, AGENT_NAME)
367
- except Exception:
368
- pending_count = 0
369
- if pending_count > 0:
370
- messages = be.read_inbox(_PROJECT_ID, AGENT_NAME)
371
- return {
372
- "got_message": True,
373
- "source": "polled",
374
- "count": len(messages),
375
- "messages": [
376
- {
377
- "from": m["from_agent"],
378
- "type": m.get("type", "msg"),
379
- "ts": m.get("created_at"),
380
- "payload": m.get("payload", {}),
381
- }
382
- for m in messages
383
- ],
384
- }
385
- await asyncio.sleep(poll_interval)
572
+ shaped = _return_from_buffered(buffered)
573
+ if shaped:
574
+ return shaped
575
+ else:
576
+ # Realtime unavailable — plain sleep fallback so we still honor timeout.
577
+ await asyncio.sleep(actual_timeout)
386
578
 
387
579
  return {
388
580
  "got_message": False,
389
581
  "timed_out": True,
390
582
  "agent": AGENT_NAME,
391
- "hint": "No messages arrived. Call meshcode_wait again to keep listening.",
583
+ "hint": (
584
+ "No messages arrived. Per TOKEN-EFFICIENT WAIT BACKOFF: call "
585
+ "meshcode_wait again with DOUBLE the previous timeout "
586
+ "(60→120→240→480→960→1800, cap 1800). Reset to 60s on any "
587
+ "received mesh message."
588
+ ),
392
589
  }
393
590
 
394
591
 
592
+ @mcp.tool()
593
+ def meshcode_done(reason: str) -> Dict[str, Any]:
594
+ """Broadcast a GLOBAL DONE signal to every other agent in the meshwork.
595
+
596
+ Typically called by the commander when the overall task is complete.
597
+ Every other agent's meshcode_wait() will return immediately with
598
+ {got_done: true, reason, from}, breaking them cleanly out of their idle
599
+ loop so they can report to the human and stop burning tokens.
600
+
601
+ Args:
602
+ reason: Short human-readable reason, e.g. "tests green, PR merged".
603
+ """
604
+ agents = be.get_board(_PROJECT_ID)
605
+ payload = {"reason": reason, "from": AGENT_NAME, "type": "global_done"}
606
+ sent = 0
607
+ for a in agents:
608
+ name = a.get("name")
609
+ if name and name != AGENT_NAME:
610
+ try:
611
+ be.send_message(_PROJECT_ID, AGENT_NAME, name, payload, msg_type="done")
612
+ sent += 1
613
+ except Exception as e:
614
+ log.warning(f"meshcode_done: failed to notify {name}: {e}")
615
+ log.info(f"global done broadcast from {AGENT_NAME}: reason={reason!r} notified={sent}")
616
+ return {"ok": True, "global_done": True, "agents_notified": sent, "reason": reason}
617
+
618
+
395
619
  @mcp.tool()
396
620
  def meshcode_check() -> Dict[str, Any]:
397
621
  """Quick poll: returns pending message count + any messages buffered by the
@@ -402,12 +626,14 @@ def meshcode_check() -> Dict[str, Any]:
402
626
  """
403
627
  pending = be.count_pending(_PROJECT_ID, AGENT_NAME)
404
628
  realtime_buffered = _REALTIME.drain() if _REALTIME else []
629
+ deduped = _filter_and_mark(realtime_buffered)
630
+ split = _split_messages(deduped)
405
631
  return {
406
632
  "pending": pending,
407
633
  "agent": AGENT_NAME,
408
634
  "project": PROJECT_NAME,
409
635
  "realtime_connected": _REALTIME.is_connected if _REALTIME else False,
410
- "buffered": realtime_buffered,
636
+ **split,
411
637
  }
412
638
 
413
639
 
@@ -0,0 +1,151 @@
1
+ """`meshcode run <agent>` — universal agent launcher.
2
+
3
+ Looks up the agent's workspace dir (created by `meshcode setup`) in the
4
+ registry at ~/meshcode/.registry.json, auto-detects the user's MCP-aware
5
+ editor, and launches it pointing at that workspace's isolated .mcp.json
6
+ so ONLY this one agent loads in the new window.
7
+
8
+ Editor detection order (first match wins):
9
+ 1. $MESHCODE_EDITOR env var (explicit override: claude / cursor / code)
10
+ 2. claude (Claude Code) — uses --mcp-config flag for per-instance config
11
+ 3. cursor — opens Cursor in the workspace dir (reads .cursor/mcp.json)
12
+ 4. code (VS Code, used by Cline) — opens VS Code in the workspace dir
13
+ (Cline reads .vscode/mcp.json)
14
+
15
+ The workspace dir contains all three config files (.mcp.json,
16
+ .cursor/mcp.json, .vscode/mcp.json) so any of these editors works
17
+ identically. Closing the editor window kills the MCP subprocess and
18
+ the agent flips to offline.
19
+ """
20
+ import json
21
+ import os
22
+ import shutil
23
+ import subprocess
24
+ import sys
25
+ from pathlib import Path
26
+ from typing import Optional, Tuple
27
+
28
+ WORKSPACES_ROOT = Path.home() / "meshcode"
29
+ REGISTRY_PATH = WORKSPACES_ROOT / ".registry.json"
30
+
31
+
32
+ def _load_registry() -> dict:
33
+ if not REGISTRY_PATH.exists():
34
+ return {}
35
+ try:
36
+ return json.loads(REGISTRY_PATH.read_text())
37
+ except Exception:
38
+ return {}
39
+
40
+
41
+ def _find_agent_workspace(agent: str, project: Optional[str] = None) -> Optional[Tuple[Path, str]]:
42
+ """Look up the agent in the registry. Returns (workspace_path, project_name) or None.
43
+
44
+ If multiple agents share the same name across projects, requires the
45
+ user to disambiguate by passing project explicitly.
46
+ """
47
+ reg = _load_registry()
48
+ agents = reg.get("agents", {})
49
+
50
+ # Direct hit by agent name (current model: agent names unique within registry)
51
+ info = agents.get(agent)
52
+ if info:
53
+ ws = Path(info["workspace"])
54
+ if not ws.exists():
55
+ print(f"[meshcode] ERROR: workspace dir for '{agent}' is missing: {ws}", file=sys.stderr)
56
+ print(f"[meshcode] Re-run: meshcode setup {info.get('project','<project>')} {agent}", file=sys.stderr)
57
+ return None
58
+ if project and info.get("project") != project:
59
+ print(f"[meshcode] ERROR: agent '{agent}' belongs to project '{info.get('project')}', not '{project}'", file=sys.stderr)
60
+ return None
61
+ return ws, info.get("project", "")
62
+
63
+ # Fallback: scan ~/meshcode for any dir matching <project>-<agent>
64
+ if project:
65
+ ws = WORKSPACES_ROOT / f"{project}-{agent}"
66
+ if ws.exists():
67
+ return ws, project
68
+ else:
69
+ # Scan all workspaces for any matching <*>-<agent>
70
+ if WORKSPACES_ROOT.exists():
71
+ matches = [p for p in WORKSPACES_ROOT.iterdir() if p.is_dir() and p.name.endswith(f"-{agent}")]
72
+ if len(matches) == 1:
73
+ return matches[0], matches[0].name.rsplit(f"-{agent}", 1)[0]
74
+ if len(matches) > 1:
75
+ print(f"[meshcode] ERROR: agent '{agent}' exists in multiple projects:", file=sys.stderr)
76
+ for m in matches:
77
+ print(f"[meshcode] {m.name}", file=sys.stderr)
78
+ print(f"[meshcode] Disambiguate: meshcode run {agent} --project <name>", file=sys.stderr)
79
+ return None
80
+
81
+ print(f"[meshcode] ERROR: no workspace found for agent '{agent}'", file=sys.stderr)
82
+ print(f"[meshcode] Run `meshcode setup <project> {agent}` first.", file=sys.stderr)
83
+ return None
84
+
85
+
86
+ def _detect_editor() -> Optional[str]:
87
+ """Pick the user's preferred MCP-aware editor."""
88
+ override = os.environ.get("MESHCODE_EDITOR", "").strip().lower()
89
+ if override:
90
+ if shutil.which(override):
91
+ return override
92
+ print(f"[meshcode] WARNING: MESHCODE_EDITOR='{override}' not found in PATH", file=sys.stderr)
93
+
94
+ for cmd in ("claude", "cursor", "code"):
95
+ if shutil.which(cmd):
96
+ return cmd
97
+ return None
98
+
99
+
100
+ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str] = None) -> int:
101
+ """Launch the user's editor with ONLY the named agent's MCP server loaded."""
102
+ found = _find_agent_workspace(agent, project)
103
+ if not found:
104
+ return 2
105
+ ws, resolved_project = found
106
+ server_id = f"meshcode-{resolved_project}-{agent}"
107
+
108
+ editor = editor_override or _detect_editor()
109
+ if not editor:
110
+ print("[meshcode] ERROR: no MCP-aware editor found in PATH (claude / cursor / code).", file=sys.stderr)
111
+ print(f"[meshcode] Workspace is ready at: {ws}", file=sys.stderr)
112
+ print("[meshcode] Open it manually with the editor of your choice.", file=sys.stderr)
113
+ return 2
114
+
115
+ print(f"[meshcode] Launching {editor} for agent '{agent}' (project: {resolved_project})")
116
+ print(f"[meshcode] Workspace: {ws}")
117
+ print(f"[meshcode] MCP server: {server_id}")
118
+ print(f"[meshcode] Closing the editor will flip this agent offline.")
119
+ print()
120
+
121
+ if editor == "claude":
122
+ # Claude Code: pass --mcp-config to point at the workspace's .mcp.json
123
+ # and --strict-mcp-config so it ignores ~/.claude.json's mcpServers.
124
+ cmd = [
125
+ editor,
126
+ "--mcp-config", str(ws / ".mcp.json"),
127
+ "--strict-mcp-config",
128
+ "--dangerously-skip-permissions",
129
+ ]
130
+ try:
131
+ os.chdir(ws)
132
+ except Exception:
133
+ pass
134
+ elif editor == "cursor":
135
+ # Cursor reads .cursor/mcp.json from the workspace cwd.
136
+ cmd = [editor, str(ws)]
137
+ elif editor == "code":
138
+ # VS Code (Cline reads .vscode/mcp.json from workspace cwd).
139
+ cmd = [editor, str(ws)]
140
+ else:
141
+ cmd = [editor, str(ws)]
142
+
143
+ try:
144
+ # Replace this process with the editor so the user gets a clean tab.
145
+ os.execvp(cmd[0], cmd)
146
+ except FileNotFoundError:
147
+ print(f"[meshcode] ERROR: '{editor}' not found in PATH", file=sys.stderr)
148
+ return 127
149
+ except Exception as e:
150
+ print(f"[meshcode] ERROR launching {editor}: {e}", file=sys.stderr)
151
+ return 1
@@ -0,0 +1,330 @@
1
+ """Per-agent workspace creator for `meshcode setup`.
2
+
3
+ NEW MODEL (1.3.0): instead of polluting the user's global MCP config
4
+ (~/.claude.json, ~/.cursor/mcp.json, ...), each `meshcode setup <project>
5
+ <agent>` creates an isolated workspace directory at:
6
+
7
+ ~/meshcode/<project>-<agent>/
8
+
9
+ containing a single .mcp.json with ONLY that agent's MCP server entry.
10
+ The user then runs `meshcode run <agent>` to launch their editor with
11
+ that workspace, and ONLY that one agent goes online. Closing the editor
12
+ window flips the agent offline. No cross-window status pollution.
13
+
14
+ Backward compatibility: the old `meshcode setup <client> <project>
15
+ <agent>` form still works for the global-config flow (kept for users
16
+ on Claude Desktop, which doesn't support per-instance MCP configs).
17
+ """
18
+ import json
19
+ import os
20
+ import platform
21
+ import sys
22
+ from pathlib import Path
23
+ from typing import Dict, Any, Optional
24
+
25
+
26
+ def _load_credentials() -> Dict[str, str]:
27
+ creds_path = Path.home() / ".meshcode" / "credentials.json"
28
+ if not creds_path.exists():
29
+ print("[meshcode] ERROR: No credentials found. Run `meshcode login <api_key>` first.", file=sys.stderr)
30
+ sys.exit(2)
31
+ return json.loads(creds_path.read_text())
32
+
33
+
34
+ def _load_supabase_env() -> Dict[str, str]:
35
+ """Read SUPABASE_URL/SUPABASE_KEY from env or ~/.meshcode/env, fall back to publishable defaults."""
36
+ url = os.environ.get("SUPABASE_URL", "")
37
+ key = os.environ.get("SUPABASE_KEY", "")
38
+ if not url or not key:
39
+ env_file = Path.home() / ".meshcode" / "env"
40
+ if env_file.exists():
41
+ for line in env_file.read_text().splitlines():
42
+ line = line.strip()
43
+ if line.startswith("export "):
44
+ line = line[7:]
45
+ if "=" in line:
46
+ k, v = line.split("=", 1)
47
+ v = v.strip().strip('"').strip("'")
48
+ if k == "SUPABASE_URL" and not url:
49
+ url = v
50
+ elif k == "SUPABASE_KEY" and not key:
51
+ key = v
52
+ if not url:
53
+ url = "https://wwgzzmydrwrjgaebspdo.supabase.co"
54
+ if not key:
55
+ key = "sb_publishable_0qf0U1GURopPIxLR8Vu7eQ_5grflPP4"
56
+ return {"SUPABASE_URL": url, "SUPABASE_KEY": key}
57
+
58
+
59
+ def _resolve_project_id(api_key: str, project: str, sb: Dict[str, str]) -> str:
60
+ """Call mc_resolve_project SECURITY DEFINER RPC to get the project_id."""
61
+ try:
62
+ from urllib.request import Request as _Req, urlopen as _urlopen
63
+ body = json.dumps({"p_api_key": api_key, "p_project_name": project}).encode()
64
+ req = _Req(
65
+ f"{sb['SUPABASE_URL']}/rest/v1/rpc/mc_resolve_project",
66
+ data=body,
67
+ method="POST",
68
+ headers={
69
+ "apikey": sb["SUPABASE_KEY"],
70
+ "Authorization": f"Bearer {sb['SUPABASE_KEY']}",
71
+ "Content-Type": "application/json",
72
+ },
73
+ )
74
+ with _urlopen(req, timeout=10) as resp:
75
+ data = json.loads(resp.read().decode())
76
+ if isinstance(data, dict) and data.get("project_id"):
77
+ return data["project_id"]
78
+ if isinstance(data, dict) and data.get("error"):
79
+ print(f"[meshcode] ERROR: could not resolve project '{project}': {data['error']}", file=sys.stderr)
80
+ sys.exit(2)
81
+ except Exception as e:
82
+ print(f"[meshcode] WARNING: project_id resolution failed ({e}); MCP server will retry at boot", file=sys.stderr)
83
+ return ""
84
+
85
+
86
+ def _build_server_block(project: str, project_id: str, agent: str, role: str,
87
+ api_key: str, sb: Dict[str, str]) -> Dict[str, Any]:
88
+ return {
89
+ "command": sys.executable or "python3",
90
+ "args": ["-m", "meshcode.meshcode_mcp", "serve"],
91
+ "env": {
92
+ "MESHCODE_PROJECT": project,
93
+ "MESHCODE_PROJECT_ID": project_id,
94
+ "MESHCODE_AGENT": agent,
95
+ "MESHCODE_ROLE": role or "MCP-connected agent",
96
+ "MESHCODE_API_KEY": api_key,
97
+ "SUPABASE_URL": sb["SUPABASE_URL"],
98
+ "SUPABASE_KEY": sb["SUPABASE_KEY"],
99
+ },
100
+ }
101
+
102
+
103
+ def _atomic_write_json(path: Path, data: dict) -> None:
104
+ path.parent.mkdir(parents=True, exist_ok=True)
105
+ tmp = path.with_suffix(path.suffix + ".tmp")
106
+ tmp.write_text(json.dumps(data, indent=2))
107
+ tmp.replace(path)
108
+
109
+
110
+ # ============================================================
111
+ # WORKSPACE MODEL (1.3.0+) — one isolated dir per agent
112
+ # ============================================================
113
+
114
+ WORKSPACES_ROOT = Path.home() / "meshcode"
115
+
116
+
117
+ def _workspace_dir(project: str, agent: str) -> Path:
118
+ return WORKSPACES_ROOT / f"{project}-{agent}"
119
+
120
+
121
+ def setup_workspace(project: str, agent: str, role: str = "") -> int:
122
+ """Create an isolated workspace dir at ~/meshcode/<project>-<agent>/ with
123
+ .mcp.json containing ONLY this agent's server entry. Universal for any
124
+ MCP-compatible editor that supports per-directory configs (Claude Code via
125
+ --mcp-config flag, Cursor + Cline via .cursor/mcp.json + .vscode/mcp.json).
126
+ """
127
+ creds = _load_credentials()
128
+ sb = _load_supabase_env()
129
+ api_key = creds.get("api_key", "")
130
+ project_id = _resolve_project_id(api_key, project, sb)
131
+
132
+ server_id = f"meshcode-{project}-{agent}"
133
+ server_block = _build_server_block(project, project_id, agent, role, api_key, sb)
134
+
135
+ ws = _workspace_dir(project, agent)
136
+ ws.mkdir(parents=True, exist_ok=True)
137
+
138
+ # Write 3 config files inside the workspace so any MCP-aware editor
139
+ # opened in this dir picks up the meshcode server.
140
+ #
141
+ # .mcp.json — Claude Code project-scoped MCP config
142
+ # .cursor/mcp.json — Cursor workspace-scoped MCP config
143
+ # .vscode/mcp.json — Cline / VS Code workspace-scoped MCP config
144
+ #
145
+ # All three contain the SAME single server entry. Whichever editor the
146
+ # user opens here will see exactly one agent: this one.
147
+ server_doc = {"mcpServers": {server_id: server_block}}
148
+ _atomic_write_json(ws / ".mcp.json", server_doc)
149
+ _atomic_write_json(ws / ".cursor" / "mcp.json", server_doc)
150
+ _atomic_write_json(ws / ".vscode" / "mcp.json", server_doc)
151
+
152
+ # A tiny manifest the `meshcode run` command reads to know where this
153
+ # workspace lives. Stored at the root so `meshcode run <agent>` can find
154
+ # it without scanning every dir.
155
+ registry_path = WORKSPACES_ROOT / ".registry.json"
156
+ registry: Dict[str, Any] = {}
157
+ if registry_path.exists():
158
+ try:
159
+ registry = json.loads(registry_path.read_text())
160
+ except Exception:
161
+ registry = {}
162
+ registry.setdefault("agents", {})[agent] = {
163
+ "project": project,
164
+ "workspace": str(ws),
165
+ "server_id": server_id,
166
+ "role": role,
167
+ }
168
+ _atomic_write_json(registry_path, registry)
169
+
170
+ # Drop a README.md so the agent's working directory isn't empty on boot.
171
+ readme_body = f"""# {project} — {agent}
172
+
173
+ This is the MeshCode workspace for the **{agent}** agent in the **{project}** meshwork.
174
+
175
+ You are connected to the rest of your team via the meshcode MCP server, which is loaded
176
+ automatically when you open this directory in Claude Code, Cursor, or Cline.
177
+
178
+ ## Your identity
179
+ - Project: {project}
180
+ - Agent name: {agent}
181
+ - Role: {role or "(set in dashboard)"}
182
+
183
+ ## How this works
184
+ - Use the meshcode_* tools to communicate with other agents in the mesh.
185
+ - Use meshcode_status to see who else is in the meshwork.
186
+ - Use meshcode_send(to, message) to send messages.
187
+ - Use meshcode_wait to listen for incoming messages (your default idle state).
188
+ - Use meshcode_done(reason) when the team's task is complete.
189
+
190
+ ## To launch this agent again
191
+ ```bash
192
+ meshcode run {agent}
193
+ ```
194
+
195
+ This is your workspace dir — feel free to scaffold any files your task needs here, or
196
+ work in a different repo by `cd`ing elsewhere after launch.
197
+ """
198
+ try:
199
+ (ws / "README.md").write_text(readme_body)
200
+ except Exception as _e:
201
+ print(f"[meshcode] WARNING: could not write README.md: {_e}", file=sys.stderr)
202
+
203
+ print(f"[meshcode] ✓ Workspace created for agent '{agent}' (project: {project})")
204
+ print(f"[meshcode] Path: {ws}")
205
+ print(f"[meshcode]")
206
+ print(f"[meshcode] To launch this agent in your editor:")
207
+ print(f"[meshcode] meshcode run {agent}")
208
+ print(f"[meshcode]")
209
+ print(f"[meshcode] Closing the editor window flips this agent offline.")
210
+ print(f"[meshcode] Other agents stay independent — no cross-window pollution.")
211
+ return 0
212
+
213
+
214
+ # ============================================================
215
+ # LEGACY GLOBAL-CONFIG MODEL (kept for Claude Desktop)
216
+ # ============================================================
217
+
218
+ CLIENT_CONFIG_PATHS = {
219
+ "claude-code": {
220
+ "Darwin": Path.home() / ".claude.json",
221
+ "Linux": Path.home() / ".claude.json",
222
+ "Windows": Path.home() / ".claude.json",
223
+ },
224
+ "cursor": {
225
+ "Darwin": Path.home() / ".cursor" / "mcp.json",
226
+ "Linux": Path.home() / ".cursor" / "mcp.json",
227
+ "Windows": Path.home() / ".cursor" / "mcp.json",
228
+ },
229
+ "cline": {
230
+ "Darwin": Path.home() / "Library" / "Application Support" / "Code" / "User" / "globalStorage" / "saoudrizwan.claude-dev" / "settings" / "cline_mcp_settings.json",
231
+ "Linux": Path.home() / ".config" / "Code" / "User" / "globalStorage" / "saoudrizwan.claude-dev" / "settings" / "cline_mcp_settings.json",
232
+ "Windows": Path.home() / "AppData" / "Roaming" / "Code" / "User" / "globalStorage" / "saoudrizwan.claude-dev" / "settings" / "cline_mcp_settings.json",
233
+ },
234
+ "claude-desktop": {
235
+ "Darwin": Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json",
236
+ "Linux": Path.home() / ".config" / "Claude" / "claude_desktop_config.json",
237
+ "Windows": Path.home() / "AppData" / "Roaming" / "Claude" / "claude_desktop_config.json",
238
+ },
239
+ }
240
+
241
+ CLIENT_DISPLAY_NAMES = {
242
+ "claude-code": "Claude Code",
243
+ "cursor": "Cursor",
244
+ "cline": "Cline (VS Code)",
245
+ "claude-desktop": "Claude Desktop",
246
+ }
247
+
248
+
249
+ def setup_global(client: str, project: str, agent: str, role: str = "") -> int:
250
+ """LEGACY: write to the user's GLOBAL MCP config file. All agents that
251
+ setup this way will load in EVERY window of the editor — they all show
252
+ online whenever any window is open. Kept for Claude Desktop and other
253
+ clients that don't support per-instance configs."""
254
+ if client not in CLIENT_CONFIG_PATHS:
255
+ print(f"[meshcode] ERROR: Unknown client '{client}'. Supported: {', '.join(CLIENT_CONFIG_PATHS)}", file=sys.stderr)
256
+ return 2
257
+
258
+ creds = _load_credentials()
259
+ sb = _load_supabase_env()
260
+ api_key = creds.get("api_key", "")
261
+ project_id = _resolve_project_id(api_key, project, sb)
262
+
263
+ os_name = platform.system()
264
+ config_path = CLIENT_CONFIG_PATHS[client].get(os_name)
265
+ if config_path is None:
266
+ print(f"[meshcode] ERROR: '{client}' is not supported on {os_name}", file=sys.stderr)
267
+ return 2
268
+
269
+ existing: Dict[str, Any] = {}
270
+ if config_path.exists():
271
+ try:
272
+ existing = json.loads(config_path.read_text())
273
+ except json.JSONDecodeError:
274
+ print(f"[meshcode] WARNING: {config_path} exists but is not valid JSON.", file=sys.stderr)
275
+ return 2
276
+
277
+ if not isinstance(existing, dict):
278
+ existing = {}
279
+
280
+ servers = existing.setdefault("mcpServers", {})
281
+ if not isinstance(servers, dict):
282
+ print(f"[meshcode] ERROR: 'mcpServers' in {config_path} is not an object", file=sys.stderr)
283
+ return 2
284
+
285
+ server_id = f"meshcode-{project}-{agent}"
286
+ servers[server_id] = _build_server_block(project, project_id, agent, role, api_key, sb)
287
+ _atomic_write_json(config_path, existing)
288
+
289
+ display = CLIENT_DISPLAY_NAMES[client]
290
+ print(f"[meshcode] MCP server '{server_id}' added to {display} GLOBAL config")
291
+ print(f"[meshcode] Path: {config_path}")
292
+ print(f"[meshcode] ⚠️ Global mode: this agent will appear in EVERY window of {display}.")
293
+ print(f"[meshcode] For per-window agent isolation, use the workspace flow instead:")
294
+ print(f"[meshcode] meshcode setup {project} {agent}")
295
+ print(f"[meshcode] meshcode run {agent}")
296
+ return 0
297
+
298
+
299
+ # ============================================================
300
+ # DISPATCHER (called from comms_v4.py CLI)
301
+ # ============================================================
302
+
303
+ def setup(*args) -> int:
304
+ """Smart dispatcher.
305
+
306
+ Forms supported:
307
+ meshcode setup <project> <agent> [role] → workspace flow (NEW)
308
+ meshcode setup <client> <project> <agent> [role] → legacy global flow
309
+
310
+ Detection: if the first arg is one of the known client slugs, fall back
311
+ to the legacy global flow. Otherwise treat the first arg as a project
312
+ name and create a workspace.
313
+ """
314
+ if not args:
315
+ print("Usage:", file=sys.stderr)
316
+ print(" meshcode setup <project> <agent> [role] # workspace flow (recommended)", file=sys.stderr)
317
+ print(" meshcode setup <client> <project> <agent> [role] # legacy global flow", file=sys.stderr)
318
+ print(" Clients: claude-code, cursor, cline, claude-desktop", file=sys.stderr)
319
+ return 1
320
+
321
+ if args[0] in CLIENT_CONFIG_PATHS:
322
+ if len(args) < 3:
323
+ print("Usage: meshcode setup <client> <project> <agent> [role]", file=sys.stderr)
324
+ return 1
325
+ return setup_global(args[0], args[1], args[2], args[3] if len(args) > 3 else "")
326
+
327
+ if len(args) < 2:
328
+ print("Usage: meshcode setup <project> <agent> [role]", file=sys.stderr)
329
+ return 1
330
+ return setup_workspace(args[0], args[1], args[2] if len(args) > 2 else "")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 1.2.6
3
+ Version: 1.4.0
4
4
  Summary: Real-time communication between AI agents — Supabase-backed CLI
5
5
  Author-email: MeshCode <hello@meshcode.io>
6
6
  License: MIT
@@ -6,6 +6,7 @@ meshcode/comms_v4.py
6
6
  meshcode/launcher.py
7
7
  meshcode/launcher_install.py
8
8
  meshcode/protocol_v2.py
9
+ meshcode/run_agent.py
9
10
  meshcode/setup_clients.py
10
11
  meshcode.egg-info/PKG-INFO
11
12
  meshcode.egg-info/SOURCES.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "meshcode"
7
- version = "1.2.6"
7
+ version = "1.4.0"
8
8
  description = "Real-time communication between AI agents — Supabase-backed CLI"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -1,173 +0,0 @@
1
- """Per-client MCP server config writers for `meshcode setup <client>`.
2
-
3
- Supported clients: claude-code, cursor, cline, claude-desktop.
4
-
5
- Each writer:
6
- 1. Resolves the right config file path for the current OS.
7
- 2. Loads existing JSON (or empty dict) and merges the meshcode MCP server entry
8
- under "mcpServers". Never clobbers other entries.
9
- 3. Writes back atomically (temp file + rename).
10
- 4. Prints a success message with the path and "restart {client}" instructions.
11
- """
12
- import json
13
- import os
14
- import platform
15
- import sys
16
- from pathlib import Path
17
- from typing import Dict, Any
18
-
19
-
20
- def _load_credentials() -> Dict[str, str]:
21
- creds_path = Path.home() / ".meshcode" / "credentials.json"
22
- if not creds_path.exists():
23
- print("[meshcode] ERROR: No credentials found. Run `meshcode login <api_key>` first.", file=sys.stderr)
24
- sys.exit(2)
25
- return json.loads(creds_path.read_text())
26
-
27
-
28
- def _load_supabase_env() -> Dict[str, str]:
29
- """Read SUPABASE_URL/SUPABASE_KEY from env or ~/.meshcode/env."""
30
- url = os.environ.get("SUPABASE_URL", "")
31
- key = os.environ.get("SUPABASE_KEY", "")
32
- if not url or not key:
33
- env_file = Path.home() / ".meshcode" / "env"
34
- if env_file.exists():
35
- for line in env_file.read_text().splitlines():
36
- line = line.strip()
37
- if line.startswith("export "):
38
- line = line[7:]
39
- if "=" in line:
40
- k, v = line.split("=", 1)
41
- v = v.strip().strip('"').strip("'")
42
- if k == "SUPABASE_URL" and not url:
43
- url = v
44
- elif k == "SUPABASE_KEY" and not key:
45
- key = v
46
- if not url:
47
- url = "https://wwgzzmydrwrjgaebspdo.supabase.co"
48
- if not key:
49
- key = "sb_publishable_0qf0U1GURopPIxLR8Vu7eQ_5grflPP4"
50
- return {"SUPABASE_URL": url, "SUPABASE_KEY": key}
51
-
52
-
53
- CLIENT_CONFIG_PATHS = {
54
- "claude-code": {
55
- "Darwin": Path.home() / ".claude.json",
56
- "Linux": Path.home() / ".claude.json",
57
- "Windows": Path.home() / ".claude.json",
58
- },
59
- "cursor": {
60
- "Darwin": Path.home() / ".cursor" / "mcp.json",
61
- "Linux": Path.home() / ".cursor" / "mcp.json",
62
- "Windows": Path.home() / ".cursor" / "mcp.json",
63
- },
64
- "cline": {
65
- "Darwin": Path.home() / "Library" / "Application Support" / "Code" / "User" / "globalStorage" / "saoudrizwan.claude-dev" / "settings" / "cline_mcp_settings.json",
66
- "Linux": Path.home() / ".config" / "Code" / "User" / "globalStorage" / "saoudrizwan.claude-dev" / "settings" / "cline_mcp_settings.json",
67
- "Windows": Path.home() / "AppData" / "Roaming" / "Code" / "User" / "globalStorage" / "saoudrizwan.claude-dev" / "settings" / "cline_mcp_settings.json",
68
- },
69
- "claude-desktop": {
70
- "Darwin": Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json",
71
- "Linux": Path.home() / ".config" / "Claude" / "claude_desktop_config.json",
72
- "Windows": Path.home() / "AppData" / "Roaming" / "Claude" / "claude_desktop_config.json",
73
- },
74
- }
75
-
76
- CLIENT_DISPLAY_NAMES = {
77
- "claude-code": "Claude Code",
78
- "cursor": "Cursor",
79
- "cline": "Cline (VS Code)",
80
- "claude-desktop": "Claude Desktop",
81
- }
82
-
83
-
84
- def _atomic_write_json(path: Path, data: dict) -> None:
85
- path.parent.mkdir(parents=True, exist_ok=True)
86
- tmp = path.with_suffix(path.suffix + ".tmp")
87
- tmp.write_text(json.dumps(data, indent=2))
88
- tmp.replace(path)
89
-
90
-
91
- def setup(client: str, project: str, agent: str, role: str = "") -> int:
92
- """Write MCP server config block for the given client. Returns 0 on success."""
93
- if client not in CLIENT_CONFIG_PATHS:
94
- print(f"[meshcode] ERROR: Unknown client '{client}'. Supported: {', '.join(CLIENT_CONFIG_PATHS)}", file=sys.stderr)
95
- return 2
96
-
97
- creds = _load_credentials()
98
- sb = _load_supabase_env()
99
- api_key = creds.get("api_key", "")
100
-
101
- # Resolve project_id via the SECURITY DEFINER RPC so we don't depend on
102
- # RLS letting the publishable key SELECT mc_projects at MCP server boot.
103
- project_id = ""
104
- try:
105
- import json as _json
106
- from urllib.request import Request as _Req, urlopen as _urlopen
107
- _body = _json.dumps({"p_api_key": api_key, "p_project_name": project}).encode()
108
- _req = _Req(
109
- f"{sb['SUPABASE_URL']}/rest/v1/rpc/mc_resolve_project",
110
- data=_body,
111
- method="POST",
112
- headers={
113
- "apikey": sb["SUPABASE_KEY"],
114
- "Authorization": f"Bearer {sb['SUPABASE_KEY']}",
115
- "Content-Type": "application/json",
116
- },
117
- )
118
- with _urlopen(_req, timeout=10) as _resp:
119
- _data = _json.loads(_resp.read().decode())
120
- if isinstance(_data, dict) and _data.get("project_id"):
121
- project_id = _data["project_id"]
122
- elif isinstance(_data, dict) and _data.get("error"):
123
- print(f"[meshcode] ERROR: could not resolve project '{project}': {_data['error']}", file=sys.stderr)
124
- return 2
125
- except Exception as _e:
126
- print(f"[meshcode] WARNING: project_id resolution failed ({_e}); MCP server will retry at boot", file=sys.stderr)
127
-
128
- os_name = platform.system()
129
- config_path = CLIENT_CONFIG_PATHS[client].get(os_name)
130
- if config_path is None:
131
- print(f"[meshcode] ERROR: '{client}' is not supported on {os_name}", file=sys.stderr)
132
- return 2
133
-
134
- existing: Dict[str, Any] = {}
135
- if config_path.exists():
136
- try:
137
- existing = json.loads(config_path.read_text())
138
- except json.JSONDecodeError:
139
- print(f"[meshcode] WARNING: {config_path} exists but is not valid JSON. Overwriting may break {CLIENT_DISPLAY_NAMES[client]}.", file=sys.stderr)
140
- return 2
141
-
142
- if not isinstance(existing, dict):
143
- existing = {}
144
-
145
- servers = existing.setdefault("mcpServers", {})
146
- if not isinstance(servers, dict):
147
- print(f"[meshcode] ERROR: 'mcpServers' in {config_path} is not an object", file=sys.stderr)
148
- return 2
149
-
150
- server_id = f"meshcode-{project}-{agent}"
151
- servers[server_id] = {
152
- "command": sys.executable or "python3",
153
- "args": ["-m", "meshcode.meshcode_mcp", "serve"],
154
- "env": {
155
- "MESHCODE_PROJECT": project,
156
- "MESHCODE_PROJECT_ID": project_id,
157
- "MESHCODE_AGENT": agent,
158
- "MESHCODE_ROLE": role or "MCP-connected agent",
159
- "MESHCODE_API_KEY": api_key,
160
- "SUPABASE_URL": sb["SUPABASE_URL"],
161
- "SUPABASE_KEY": sb["SUPABASE_KEY"],
162
- },
163
- }
164
-
165
- _atomic_write_json(config_path, existing)
166
-
167
- display = CLIENT_DISPLAY_NAMES[client]
168
- print(f"[meshcode] MCP server '{server_id}' added to {display} config")
169
- print(f"[meshcode] Path: {config_path}")
170
- print(f"[meshcode] Restart {display} to load the server.")
171
- print(f"[meshcode] Inside {display}, verify with /mcp — you should see '{server_id}' connected.")
172
- print(f"[meshcode] Tools: meshcode_send, meshcode_check, meshcode_read, meshcode_status, meshcode_set_status, ...")
173
- return 0
File without changes
File without changes
File without changes
File without changes