meshcode 1.3.0__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 (25) hide show
  1. {meshcode-1.3.0 → meshcode-1.4.0}/PKG-INFO +1 -1
  2. {meshcode-1.3.0 → meshcode-1.4.0}/meshcode/__init__.py +1 -1
  3. {meshcode-1.3.0 → meshcode-1.4.0}/meshcode/meshcode_mcp/realtime.py +26 -0
  4. {meshcode-1.3.0 → meshcode-1.4.0}/meshcode/meshcode_mcp/server.py +268 -67
  5. {meshcode-1.3.0 → meshcode-1.4.0}/meshcode/setup_clients.py +33 -0
  6. {meshcode-1.3.0 → meshcode-1.4.0}/meshcode.egg-info/PKG-INFO +1 -1
  7. {meshcode-1.3.0 → meshcode-1.4.0}/pyproject.toml +1 -1
  8. {meshcode-1.3.0 → meshcode-1.4.0}/README.md +0 -0
  9. {meshcode-1.3.0 → meshcode-1.4.0}/meshcode/cli.py +0 -0
  10. {meshcode-1.3.0 → meshcode-1.4.0}/meshcode/comms_v4.py +0 -0
  11. {meshcode-1.3.0 → meshcode-1.4.0}/meshcode/launcher.py +0 -0
  12. {meshcode-1.3.0 → meshcode-1.4.0}/meshcode/launcher_install.py +0 -0
  13. {meshcode-1.3.0 → meshcode-1.4.0}/meshcode/meshcode_mcp/__init__.py +0 -0
  14. {meshcode-1.3.0 → meshcode-1.4.0}/meshcode/meshcode_mcp/__main__.py +0 -0
  15. {meshcode-1.3.0 → meshcode-1.4.0}/meshcode/meshcode_mcp/backend.py +0 -0
  16. {meshcode-1.3.0 → meshcode-1.4.0}/meshcode/meshcode_mcp/test_backend.py +0 -0
  17. {meshcode-1.3.0 → meshcode-1.4.0}/meshcode/meshcode_mcp/test_realtime.py +0 -0
  18. {meshcode-1.3.0 → meshcode-1.4.0}/meshcode/protocol_v2.py +0 -0
  19. {meshcode-1.3.0 → meshcode-1.4.0}/meshcode/run_agent.py +0 -0
  20. {meshcode-1.3.0 → meshcode-1.4.0}/meshcode.egg-info/SOURCES.txt +0 -0
  21. {meshcode-1.3.0 → meshcode-1.4.0}/meshcode.egg-info/dependency_links.txt +0 -0
  22. {meshcode-1.3.0 → meshcode-1.4.0}/meshcode.egg-info/entry_points.txt +0 -0
  23. {meshcode-1.3.0 → meshcode-1.4.0}/meshcode.egg-info/requires.txt +0 -0
  24. {meshcode-1.3.0 → meshcode-1.4.0}/meshcode.egg-info/top_level.txt +0 -0
  25. {meshcode-1.3.0 → 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.3.0
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.3.0"
2
+ __version__ = "1.4.0"
@@ -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
@@ -111,6 +178,54 @@ if not _flip_status("online", "MCP session active"):
111
178
  print(f"[meshcode-mcp] WARNING: could not flip status to online", file=sys.stderr)
112
179
 
113
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
227
+
228
+
114
229
  # ============================================================
115
230
  # Agent identity from Supabase profile (for system instructions)
116
231
  # ============================================================
@@ -180,10 +295,19 @@ YOUR DEFAULT BEHAVIOR LOOP (do this without being asked):
180
295
  user prompt. Read it, decide if it requires action or a reply, take
181
296
  action, send a reply via meshcode_send if appropriate, then call
182
297
  meshcode_wait AGAIN to keep listening.
183
- 5. When meshcode_wait returns timed_out (no message in 4 min), call it
184
- AGAIN immediately. Do not return to the user. The loop is your
185
- default state you are a long-running mesh agent, not a one-shot
186
- 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.
187
311
 
188
312
  YOU SHOULD ONLY BREAK OUT OF THE meshcode_wait LOOP IF:
189
313
  - The human user explicitly says "stop" or pressed Ctrl+C.
@@ -213,6 +337,7 @@ YOUR FIRST ACTIONS WHEN THIS SESSION STARTS:
213
337
 
214
338
  AVAILABLE MESH TOOLS (all from this MCP server):
215
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
216
341
  meshcode_send — send a message to another mesh agent
217
342
  meshcode_check — non-blocking peek at the inbox count
218
343
  meshcode_read — drain inbox, mark messages read
@@ -282,13 +407,19 @@ async def lifespan(_app):
282
407
  try:
283
408
  yield {"realtime": _REALTIME}
284
409
  finally:
285
- log.info("lifespan shutdown — stopping heartbeat + realtime")
410
+ log.info("lifespan shutdown — stopping heartbeat + realtime + releasing lease")
286
411
  hb_task.cancel()
287
412
  try:
288
413
  await hb_task
289
414
  except asyncio.CancelledError:
290
415
  pass
291
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}")
292
423
 
293
424
 
294
425
  # ============================================================
@@ -311,13 +442,24 @@ except Exception:
311
442
  # ----------------- TOOLS -----------------
312
443
 
313
444
  @mcp.tool()
314
- def meshcode_send(to: str, payload: Dict[str, Any]) -> Dict[str, Any]:
445
+ def meshcode_send(to: str, message: Any) -> Dict[str, Any]:
315
446
  """Send a message to another agent in the meshwork.
316
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
+
317
452
  Args:
318
453
  to: Name of the recipient agent.
319
- 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).
320
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)}
321
463
  return be.send_message(_PROJECT_ID, AGENT_NAME, to, payload, msg_type="msg")
322
464
 
323
465
 
@@ -339,84 +481,141 @@ def meshcode_broadcast(payload: Dict[str, Any]) -> Dict[str, Any]:
339
481
 
340
482
  @mcp.tool()
341
483
  def meshcode_read() -> Dict[str, Any]:
342
- """Read all pending (unread) messages for this agent. Marks them as read and ACKs senders."""
343
- messages = be.read_inbox(_PROJECT_ID, AGENT_NAME)
344
- return {
345
- "count": len(messages),
346
- "messages": [
347
- {
348
- "from": m["from_agent"],
349
- "type": m.get("type", "msg"),
350
- "ts": m.get("created_at"),
351
- "payload": m.get("payload", {}),
352
- }
353
- for m in messages
354
- ],
355
- }
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
356
515
 
357
516
 
358
517
  @mcp.tool()
359
- async def meshcode_wait(timeout_seconds: int = 240) -> Dict[str, Any]:
518
+ async def meshcode_wait(timeout_seconds: int = 120) -> Dict[str, Any]:
360
519
  """LONG-POLL: Block until a new message arrives for this agent (or timeout).
361
520
 
362
- THIS IS THE AUTONOMOUS LOOP TOOL. Call this whenever you finish any task
363
- and want to listen for messages from other agents in the meshwork. The
364
- tool BLOCKS inside the MCP server your turn does not end. When a
365
- message arrives, this returns immediately with the messages, you process
366
- them, reply via meshcode_send, and call meshcode_wait again to stay in
367
- 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.
368
530
 
369
- Use meshcode_wait as your default idle state instead of returning to the
370
- 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.
371
535
 
372
536
  Args:
373
- timeout_seconds: How long to block before returning empty (default 240s = 4min).
537
+ timeout_seconds: How long to block before returning empty (default 120s).
374
538
  Pick something < your client's tool-call timeout.
375
539
  """
376
- deadline = asyncio.get_event_loop().time() + max(1, int(timeout_seconds))
377
- poll_interval = 1.5 # seconds between supabase polls (realtime is push, this is the safety net)
378
- while asyncio.get_event_loop().time() < deadline:
379
- # 1) Check the realtime listener buffer (push-based, instant)
380
- 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:
381
570
  buffered = _REALTIME.drain()
382
571
  if buffered:
383
- return {
384
- "got_message": True,
385
- "source": "realtime",
386
- "count": len(buffered),
387
- "messages": buffered,
388
- }
389
- # 2) Safety net: poll Supabase directly in case realtime missed something
390
- try:
391
- pending_count = be.count_pending(_PROJECT_ID, AGENT_NAME)
392
- except Exception:
393
- pending_count = 0
394
- if pending_count > 0:
395
- messages = be.read_inbox(_PROJECT_ID, AGENT_NAME)
396
- return {
397
- "got_message": True,
398
- "source": "polled",
399
- "count": len(messages),
400
- "messages": [
401
- {
402
- "from": m["from_agent"],
403
- "type": m.get("type", "msg"),
404
- "ts": m.get("created_at"),
405
- "payload": m.get("payload", {}),
406
- }
407
- for m in messages
408
- ],
409
- }
410
- 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)
411
578
 
412
579
  return {
413
580
  "got_message": False,
414
581
  "timed_out": True,
415
582
  "agent": AGENT_NAME,
416
- "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
+ ),
417
589
  }
418
590
 
419
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
+
420
619
  @mcp.tool()
421
620
  def meshcode_check() -> Dict[str, Any]:
422
621
  """Quick poll: returns pending message count + any messages buffered by the
@@ -427,12 +626,14 @@ def meshcode_check() -> Dict[str, Any]:
427
626
  """
428
627
  pending = be.count_pending(_PROJECT_ID, AGENT_NAME)
429
628
  realtime_buffered = _REALTIME.drain() if _REALTIME else []
629
+ deduped = _filter_and_mark(realtime_buffered)
630
+ split = _split_messages(deduped)
430
631
  return {
431
632
  "pending": pending,
432
633
  "agent": AGENT_NAME,
433
634
  "project": PROJECT_NAME,
434
635
  "realtime_connected": _REALTIME.is_connected if _REALTIME else False,
435
- "buffered": realtime_buffered,
636
+ **split,
436
637
  }
437
638
 
438
639
 
@@ -167,6 +167,39 @@ def setup_workspace(project: str, agent: str, role: str = "") -> int:
167
167
  }
168
168
  _atomic_write_json(registry_path, registry)
169
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
+
170
203
  print(f"[meshcode] ✓ Workspace created for agent '{agent}' (project: {project})")
171
204
  print(f"[meshcode] Path: {ws}")
172
205
  print(f"[meshcode]")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 1.3.0
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "meshcode"
7
- version = "1.3.0"
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"}
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes