meshcode 2.10.77__tar.gz → 2.10.84__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 (49) hide show
  1. {meshcode-2.10.77 → meshcode-2.10.84}/PKG-INFO +1 -1
  2. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/__init__.py +1 -1
  3. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/comms_v4.py +40 -2
  4. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/meshcode_mcp/server.py +135 -39
  5. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/setup_clients.py +230 -0
  6. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode.egg-info/PKG-INFO +1 -1
  7. {meshcode-2.10.77 → meshcode-2.10.84}/pyproject.toml +1 -1
  8. {meshcode-2.10.77 → meshcode-2.10.84}/README.md +0 -0
  9. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/ascii_art.py +0 -0
  10. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/cli.py +0 -0
  11. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/compat.py +0 -0
  12. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/error_hints.py +0 -0
  13. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/exceptions.py +0 -0
  14. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/invites.py +0 -0
  15. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/launcher.py +0 -0
  16. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/launcher_install.py +0 -0
  17. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/meshcode_mcp/__init__.py +0 -0
  18. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/meshcode_mcp/__main__.py +0 -0
  19. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/meshcode_mcp/backend.py +0 -0
  20. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/meshcode_mcp/realtime.py +0 -0
  21. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/meshcode_mcp/test_backend.py +0 -0
  22. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/meshcode_mcp/test_realtime.py +0 -0
  23. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
  24. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/preferences.py +0 -0
  25. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/protocol_v2.py +0 -0
  26. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/quickstart.py +0 -0
  27. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/run_agent.py +0 -0
  28. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/secrets.py +0 -0
  29. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/self_update.py +0 -0
  30. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/supervisor.py +0 -0
  31. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode/upload.py +0 -0
  32. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode.egg-info/SOURCES.txt +0 -0
  33. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode.egg-info/dependency_links.txt +0 -0
  34. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode.egg-info/entry_points.txt +0 -0
  35. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode.egg-info/requires.txt +0 -0
  36. {meshcode-2.10.77 → meshcode-2.10.84}/meshcode.egg-info/top_level.txt +0 -0
  37. {meshcode-2.10.77 → meshcode-2.10.84}/setup.cfg +0 -0
  38. {meshcode-2.10.77 → meshcode-2.10.84}/tests/test_core.py +0 -0
  39. {meshcode-2.10.77 → meshcode-2.10.84}/tests/test_cross_agent_messaging.py +0 -0
  40. {meshcode-2.10.77 → meshcode-2.10.84}/tests/test_esc_deaf_state.py +0 -0
  41. {meshcode-2.10.77 → meshcode-2.10.84}/tests/test_exceptions.py +0 -0
  42. {meshcode-2.10.77 → meshcode-2.10.84}/tests/test_mark_read_batch.py +0 -0
  43. {meshcode-2.10.77 → meshcode-2.10.84}/tests/test_migration_integrity.py +0 -0
  44. {meshcode-2.10.77 → meshcode-2.10.84}/tests/test_realtime_event_freshness.py +0 -0
  45. {meshcode-2.10.77 → meshcode-2.10.84}/tests/test_rls_cross_tenant.py +0 -0
  46. {meshcode-2.10.77 → meshcode-2.10.84}/tests/test_rpc_migrations.py +0 -0
  47. {meshcode-2.10.77 → meshcode-2.10.84}/tests/test_security_regressions.py +0 -0
  48. {meshcode-2.10.77 → meshcode-2.10.84}/tests/test_sentinel.py +0 -0
  49. {meshcode-2.10.77 → meshcode-2.10.84}/tests/test_status_enum_coverage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 2.10.77
3
+ Version: 2.10.84
4
4
  Summary: Real-time communication between AI agents — Supabase-backed CLI
5
5
  Author-email: MeshCode <hello@meshcode.io>
6
6
  License: MIT
@@ -1,5 +1,5 @@
1
1
  """MeshCode — Real-time communication between AI agents."""
2
- __version__ = "2.10.77"
2
+ __version__ = "2.10.84"
3
3
 
4
4
  # Exception hierarchy — eagerly imported (lightweight, no deps)
5
5
  from meshcode.exceptions import ( # noqa: F401
@@ -27,7 +27,7 @@ import os
27
27
  import sys
28
28
  import time
29
29
  import subprocess
30
- from datetime import datetime
30
+ from datetime import datetime, timedelta
31
31
 
32
32
  # Force UTF-8 stdio so unicode chars (✓, →, etc.) in CLI output don't crash
33
33
  # on Windows cp1252. Safe no-op on POSIX.
@@ -344,7 +344,14 @@ def _sanitize_notification_text(text: str) -> str:
344
344
 
345
345
 
346
346
  def send_notification(project, name, from_agent, pending=1):
347
- """Cross-platform notification: macOS (osascript), Windows (PowerShell toast), Linux (notify-send)."""
347
+ """Cross-platform notification: macOS (osascript), Windows (PowerShell toast), Linux (notify-send).
348
+
349
+ Off by default. Opt in by setting MESHCODE_DESKTOP_NOTIFY=1 in the env.
350
+ Samuel asked us to stop popping the macOS toast on every pending-message
351
+ nudge — it interrupts him while he's working in another app.
352
+ """
353
+ if os.environ.get("MESHCODE_DESKTOP_NOTIFY", "0") not in ("1", "true", "yes", "on"):
354
+ return
348
355
  title = _sanitize_notification_text(f"MeshCode [{project}] → {name}")
349
356
  body = _sanitize_notification_text(f"{pending} mensaje(s) pendiente(s) de {from_agent}")
350
357
  try:
@@ -1141,6 +1148,37 @@ def send_msg(project, from_agent, to_agent, content, msg_type="msg", compact=Fal
1141
1148
  "read": False
1142
1149
  }
1143
1150
 
1151
+ # Dedup: skip if an identical unread message from the same sender to the
1152
+ # same recipient was already inserted in the last 30s. This catches the
1153
+ # common failure mode of a caller re-sending a backgrounded broadcast
1154
+ # because the subprocess looked stuck — the original delivery still lands
1155
+ # and the recipient gets the same payload N times. Samuel hit this with
1156
+ # 4× duplicate "LAST ROUND" broadcasts on 2026-04-30.
1157
+ try:
1158
+ from urllib.parse import quote as _q
1159
+ _payload_json = json.dumps(payload, sort_keys=True, ensure_ascii=False)
1160
+ _cutoff = (datetime.utcnow() - timedelta(seconds=30)).strftime("%Y-%m-%dT%H:%M:%S.000+00:00")
1161
+ _existing = sb_select(
1162
+ "mc_messages",
1163
+ f"project_id=eq.{project_id}"
1164
+ f"&from_agent=eq.{_q(from_agent)}"
1165
+ f"&to_agent=eq.{_q(to_agent)}"
1166
+ f"&type=eq.{_q(msg_type)}"
1167
+ f"&read=eq.false"
1168
+ f"&created_at=gte.{_q(_cutoff)}",
1169
+ order="created_at.desc",
1170
+ )
1171
+ for _row in (_existing or [])[:5]:
1172
+ _row_payload = _row.get("payload")
1173
+ if isinstance(_row_payload, dict):
1174
+ if json.dumps(_row_payload, sort_keys=True, ensure_ascii=False) == _payload_json:
1175
+ print(f"[{project}] {from_agent}->{to_agent}: skipped (duplicate within 30s, msg_id={_row['id']})")
1176
+ return
1177
+ except Exception:
1178
+ # Dedup is best-effort. Never block a legitimate send because the
1179
+ # check failed.
1180
+ pass
1181
+
1144
1182
  result = sb_insert("mc_messages", msg)
1145
1183
  if result:
1146
1184
  preview = json.dumps(payload, ensure_ascii=False)[:60]
@@ -803,8 +803,21 @@ def _acquire_lease() -> bool:
803
803
  "p_instance_id": _INSTANCE_ID,
804
804
  })
805
805
  if isinstance(r, dict) and r.get("ok"):
806
+ global _CONSECUTIVE_IDLE_SECONDS
807
+ _CONSECUTIVE_IDLE_SECONDS = 0 # P6: reset idle counter on lease success
806
808
  return True
807
809
  if isinstance(r, dict) and r.get("error"):
810
+ # Tombstone gate (mig 211): user disconnected this agent.
811
+ # Refuse to re-acquire — exit cleanly. User must hit
812
+ # Reconnect button on dashboard to clear disconnected_at.
813
+ if r.get("error_code") == "kicked":
814
+ _mc_log(
815
+ f"Agent '{AGENT_NAME}' was disconnected by the user "
816
+ f"(at {r.get('disconnected_at', 'unknown')}). "
817
+ f"Click Reconnect on the dashboard to allow this agent to run again.",
818
+ "error",
819
+ )
820
+ sys.exit(0)
808
821
  err = str(r.get("error", ""))
809
822
  if "already running" in err:
810
823
  if attempt < 2:
@@ -1409,7 +1422,25 @@ def _heartbeat_loop_inner():
1409
1422
  lease_counter = 0
1410
1423
  while not _heartbeat_stop.is_set():
1411
1424
  try:
1412
- be.sb_rpc("mc_heartbeat", {"p_project_id": _PROJECT_ID, "p_agent_name": AGENT_NAME, "p_version": _SDK_VERSION})
1425
+ _hb_resp = be.sb_rpc("mc_heartbeat", {"p_project_id": _PROJECT_ID, "p_agent_name": AGENT_NAME, "p_version": _SDK_VERSION})
1426
+
1427
+ # Tombstone gate (mig 211): user disconnected this agent.
1428
+ # Stop the heartbeat thread; lifespan finally will clean up.
1429
+ if isinstance(_hb_resp, dict) and _hb_resp.get("error_code") == "kicked":
1430
+ log.warning(
1431
+ f"heartbeat refused: user_disconnected at "
1432
+ f"{_hb_resp.get('disconnected_at', 'unknown')} — exiting heartbeat loop"
1433
+ )
1434
+ _heartbeat_stop.set()
1435
+ # Trigger process exit on next tick of the main loop. We
1436
+ # cannot call sys.exit() from a daemon thread reliably;
1437
+ # instead, drop the lease state in-memory so the lifespan
1438
+ # teardown path runs cleanly when the parent process closes.
1439
+ try:
1440
+ os.kill(os.getpid(), _signal.SIGTERM)
1441
+ except Exception:
1442
+ pass
1443
+ break
1413
1444
 
1414
1445
  # CPU-based status detection
1415
1446
  parent_cpu = _get_parent_cpu()
@@ -1488,12 +1519,25 @@ def _heartbeat_loop_inner():
1488
1519
  try:
1489
1520
  api_key = _get_api_key()
1490
1521
  if api_key:
1491
- be.sb_rpc("mc_acquire_agent_lease", {
1522
+ _lease_resp = be.sb_rpc("mc_acquire_agent_lease", {
1492
1523
  "p_api_key": api_key,
1493
1524
  "p_project_id": _PROJECT_ID,
1494
1525
  "p_agent_name": AGENT_NAME,
1495
1526
  "p_instance_id": _INSTANCE_ID,
1496
1527
  })
1528
+ # Tombstone gate (mig 211): user disconnected this agent
1529
+ # between heartbeats. Stop loop and signal process exit.
1530
+ if isinstance(_lease_resp, dict) and _lease_resp.get("error_code") == "kicked":
1531
+ log.warning(
1532
+ f"lease renewal refused: user_disconnected at "
1533
+ f"{_lease_resp.get('disconnected_at', 'unknown')} — exiting"
1534
+ )
1535
+ _heartbeat_stop.set()
1536
+ try:
1537
+ os.kill(os.getpid(), _signal.SIGTERM)
1538
+ except Exception:
1539
+ pass
1540
+ break
1497
1541
  except Exception as e:
1498
1542
  log.warning(f"lease renewal failed: {e}")
1499
1543
 
@@ -1953,54 +1997,62 @@ def meshcode_download_file(file_id: str) -> Dict[str, Any]:
1953
1997
  mime_type = file_info.get("mime_type", "application/octet-stream")
1954
1998
  file_name = file_info.get("file_name", "download")
1955
1999
 
1956
- # Step 2: Download from Supabase Storage (using service-level anon key)
2000
+ # Step 2: Download from Supabase Storage.
2001
+ # The bucket is private, so the publishable/anon key returns 404. We try
2002
+ # the keys in order: service_role (if user opted in) → anon → public-bucket
2003
+ # fallback. Most setups need service_role for cross-agent file access.
1957
2004
  sb_url = os.environ.get("SUPABASE_URL", be._sb_url if hasattr(be, '_sb_url') else "")
1958
- sb_key = os.environ.get("SUPABASE_KEY", be._sb_key if hasattr(be, '_sb_key') else "")
2005
+ anon_key = os.environ.get("SUPABASE_KEY", be._sb_key if hasattr(be, '_sb_key') else "")
2006
+ service_key = os.environ.get("MESHCODE_SUPABASE_SERVICE_KEY", "")
1959
2007
 
1960
- if not sb_url or not sb_key:
2008
+ if not sb_url or not anon_key:
1961
2009
  return {"error": "storage not configured", "error_code": "config_error"}
1962
2010
 
1963
- # URL-encode each path segment so filenames with spaces/unicode (the common
1964
- # 400 cause when dashboard composer attaches files like "Screenshot 1.png")
1965
- # don't produce a malformed Storage URL.
1966
2011
  from urllib.parse import quote as _quote
1967
2012
  _enc_path = "/".join(_quote(seg, safe="") for seg in storage_path.split("/") if seg)
1968
2013
  _enc_bucket = _quote(bucket, safe="")
1969
2014
 
1970
- def _try_download(url: str):
2015
+ def _try_download(url: str, key: str):
1971
2016
  req = _req.Request(
1972
2017
  url,
1973
2018
  headers={
1974
- "apikey": sb_key,
1975
- "Authorization": f"Bearer {sb_key}",
2019
+ "apikey": key,
2020
+ "Authorization": f"Bearer {key}",
1976
2021
  },
1977
2022
  )
1978
2023
  with _req.urlopen(req, timeout=30) as resp:
1979
2024
  return resp.read()
1980
2025
 
1981
- # Try authenticated object endpoint first; fall back to public endpoint
1982
- # for buckets that have public read enabled. Many 400/403 cases on private
1983
- # buckets resolve when the bucket is configured as public-readable.
1984
2026
  auth_url = f"{sb_url}/storage/v1/object/{_enc_bucket}/{_enc_path}"
1985
2027
  public_url = f"{sb_url}/storage/v1/object/public/{_enc_bucket}/{_enc_path}"
2028
+
2029
+ attempts: List[tuple] = []
2030
+ if service_key:
2031
+ attempts.append(("service_role auth", auth_url, service_key))
2032
+ attempts.append(("anon auth", auth_url, anon_key))
2033
+ attempts.append(("anon public", public_url, anon_key))
2034
+
1986
2035
  last_err = None
1987
2036
  content = None
1988
- for _url in (auth_url, public_url):
2037
+ tried_labels = []
2038
+ for label, _url, _key in attempts:
2039
+ tried_labels.append(label)
1989
2040
  try:
1990
- content = _try_download(_url)
2041
+ content = _try_download(_url, _key)
1991
2042
  break
1992
2043
  except _uerr.HTTPError as e:
1993
- last_err = f"HTTP {e.code} on {_url}: {e.reason}"
2044
+ last_err = f"HTTP {e.code} via {label}: {e.reason}"
1994
2045
  continue
1995
2046
  except Exception as e:
1996
- last_err = f"{type(e).__name__} on {_url}: {e}"
2047
+ last_err = f"{type(e).__name__} via {label}: {e}"
1997
2048
  continue
1998
2049
  if content is None:
1999
2050
  return {
2000
2051
  "error": f"download failed: {last_err}",
2001
2052
  "error_code": "download_error",
2002
- "tried_urls": [auth_url, public_url],
2053
+ "tried": tried_labels,
2003
2054
  "storage_path": storage_path,
2055
+ "hint": "set MESHCODE_SUPABASE_SERVICE_KEY in the MCP server env to enable service_role downloads (private bucket)" if not service_key else None,
2004
2056
  }
2005
2057
 
2006
2058
  # Step 3: Return content based on type
@@ -2260,23 +2312,37 @@ async def meshcode_wait(timeout_seconds: int = 20, include_acks: bool = False) -
2260
2312
  result["pending_tasks"] = [claimed]
2261
2313
  break # Return so agent works the claimed task
2262
2314
 
2263
- # Update status to sleeping after threshold, but keep looping
2315
+ # Update status to sleeping after threshold, but keep looping.
2316
+ # P_kill_zombie_string (mig 211 audit): do NOT serialize the
2317
+ # idle-seconds counter to the DB. The "idle Ns — still listening"
2318
+ # string survived offline transitions and never decayed (qa
2319
+ # showed "idle 322998880s" — ~10 years of process uptime).
2320
+ # Counter stays in-memory; DB task is empty for sleeping.
2264
2321
  if _AUTO_SLEEP_THRESHOLD > 0 and _CONSECUTIVE_IDLE_SECONDS >= _AUTO_SLEEP_THRESHOLD and not _STAY_AWAKE:
2265
2322
  try:
2266
2323
  api_key = _get_api_key()
2267
2324
  if api_key:
2268
- be.sb_rpc("mc_agent_set_status_by_api_key", {
2325
+ _sleep_resp = be.sb_rpc("mc_agent_set_status_by_api_key", {
2269
2326
  "p_api_key": api_key,
2270
2327
  "p_project_id": _PROJECT_ID,
2271
2328
  "p_agent_name": AGENT_NAME,
2272
2329
  "p_status": "sleeping",
2273
- "p_task": f"idle {_CONSECUTIVE_IDLE_SECONDS}s — still listening",
2330
+ "p_task": "",
2274
2331
  })
2332
+ # Tombstone gate (mig 211): user disconnected during wait.
2333
+ if isinstance(_sleep_resp, dict) and _sleep_resp.get("error_code") == "kicked":
2334
+ log.warning("auto-sleep: user_disconnected — exiting wait loop")
2335
+ _heartbeat_stop.set()
2336
+ try:
2337
+ os.kill(os.getpid(), _signal.SIGTERM)
2338
+ except Exception:
2339
+ pass
2340
+ return {"timed_out": True, "reason": "user_disconnected"}
2275
2341
  except Exception as e:
2276
2342
  log.debug(f"auto-sleep status update failed: {e}")
2277
2343
  # Do NOT return — keep looping. Status says sleeping but
2278
2344
  # we are still listening for messages via realtime.
2279
- _set_state("sleeping", f"idle {_CONSECUTIVE_IDLE_SECONDS}s — still listening")
2345
+ _set_state("sleeping", "")
2280
2346
 
2281
2347
  # No messages, no tasks — loop back and wait again
2282
2348
  continue
@@ -2643,7 +2709,7 @@ def meshcode_set_status(status: str, task: str = "") -> Dict[str, Any]:
2643
2709
  status: One of: working, idle, standby, blocked, done, online, sleeping.
2644
2710
  task: Optional human-readable task description.
2645
2711
  """
2646
- global _STAY_AWAKE
2712
+ global _STAY_AWAKE, _CONSECUTIVE_IDLE_SECONDS
2647
2713
  # PRODUCT RULE: Cannot sleep/idle/standby with open tasks. Work first.
2648
2714
  if status in ("sleeping", "idle", "standby"):
2649
2715
  pending_tasks = _get_pending_tasks_summary()
@@ -2659,7 +2725,22 @@ def meshcode_set_status(status: str, task: str = "") -> Dict[str, Any]:
2659
2725
  _STAY_AWAKE = True
2660
2726
  elif status == "sleeping":
2661
2727
  _STAY_AWAKE = False
2662
- return be.set_status(_PROJECT_ID, AGENT_NAME, status, task, api_key=_get_api_key())
2728
+ resp = be.set_status(_PROJECT_ID, AGENT_NAME, status, task, api_key=_get_api_key())
2729
+ # Tombstone gate (mig 211): cannot self-clear a user-disconnect.
2730
+ # Surface the refusal to the LLM so it stops retrying.
2731
+ if isinstance(resp, dict) and resp.get("error_code") == "kicked":
2732
+ _STAY_AWAKE = False
2733
+ _set_state("sleeping", "user_disconnected")
2734
+ return {
2735
+ "refused": True,
2736
+ "reason": "user_disconnected — agent was kicked by the user. Click Reconnect on the dashboard to allow this agent to run.",
2737
+ "disconnected_at": resp.get("disconnected_at"),
2738
+ }
2739
+ # P6: reset idle counter on explicit online flip — keeps the
2740
+ # "idle Ns" string from accumulating across status churn.
2741
+ if status == "online" and isinstance(resp, dict) and resp.get("ok"):
2742
+ _CONSECUTIVE_IDLE_SECONDS = 0
2743
+ return resp
2663
2744
 
2664
2745
 
2665
2746
  @mcp.tool()
@@ -2680,27 +2761,42 @@ def meshcode_init(project: str, agent: str, role: str = "") -> Dict[str, Any]:
2680
2761
 
2681
2762
  @mcp.tool()
2682
2763
  @with_working_status
2683
- def meshcode_task_create(title: str, description: str = "", assignee: str = "*",
2764
+ def meshcode_task_create(title: str, description: str = "", assignee: Optional[str] = None,
2684
2765
  priority: str = "normal", parent_task_id: Optional[str] = None) -> Dict[str, Any]:
2685
- """Create task. assignee="*" for any, priority: low/normal/high/urgent."""
2766
+ """Create task. assignee = explicit agent name OR 'auto' for round-robin
2767
+ onto the least-loaded live agent in this meshwork. priority: low/normal/high/urgent.
2768
+
2769
+ Per Samuel rule (mig 213): wildcard '*' / NULL / '' assignees are forbidden —
2770
+ every task must have a concrete owner or opt into 'auto' resolution.
2771
+ """
2686
2772
  if not title or not title.strip():
2687
2773
  return {"error": "title cannot be empty"}
2774
+ # Mirror the RPC validation client-side so the LLM gets a clear hint
2775
+ # before round-tripping. Do not silently coerce — the caller must choose.
2776
+ if assignee is None or not str(assignee).strip() or str(assignee).strip() == "*":
2777
+ return {
2778
+ "error": "assignee_required",
2779
+ "hint": "specify an explicit agent name OR pass assignee='auto' for round-robin",
2780
+ }
2688
2781
  api_key = _get_api_key()
2689
2782
  result = be.task_create(api_key, _PROJECT_ID, AGENT_NAME, title.strip(),
2690
2783
  description=description, assignee=assignee,
2691
2784
  priority=priority, parent_task_id=parent_task_id)
2692
- # Auto-notify assignee so they wake from meshcode_wait
2693
- if isinstance(result, dict) and result.get("ok") and assignee and assignee != "*" and assignee != AGENT_NAME:
2694
- try:
2695
- be.send_message(_PROJECT_ID, AGENT_NAME, assignee, {
2696
- "type": "task_assigned",
2697
- "task_id": result.get("task_id", ""),
2698
- "title": title,
2699
- "priority": priority,
2700
- "text": f"New {priority} task assigned to you: {title}",
2701
- }, msg_type="system", api_key=_get_api_key())
2702
- except Exception:
2703
- pass # best-effort notification
2785
+ # Auto-notify the resolved assignee so they wake from meshcode_wait.
2786
+ # Read result["assignee"] (server may have resolved 'auto' to a real name).
2787
+ if isinstance(result, dict) and result.get("ok"):
2788
+ resolved = result.get("assignee") or assignee
2789
+ if resolved and resolved != "*" and resolved != AGENT_NAME:
2790
+ try:
2791
+ be.send_message(_PROJECT_ID, AGENT_NAME, resolved, {
2792
+ "type": "task_assigned",
2793
+ "task_id": result.get("task_id", ""),
2794
+ "title": title,
2795
+ "priority": priority,
2796
+ "text": f"New {priority} task assigned to you: {title}",
2797
+ }, msg_type="system", api_key=_get_api_key())
2798
+ except Exception:
2799
+ pass # best-effort notification
2704
2800
  return result
2705
2801
 
2706
2802
 
@@ -511,6 +511,236 @@ work in a different repo by `cd`ing elsewhere after launch.
511
511
  except Exception as _e:
512
512
  print(f"[meshcode] WARNING: could not write README.md: {_e}", file=sys.stderr)
513
513
 
514
+ # CLAUDE.md — Claude Code reads this on every session start and merges it
515
+ # into the system prompt. We write the boot+loop directive here as a
516
+ # belt-and-suspenders enforcement: even if the MCP `instructions` field is
517
+ # not honored (older clients, schema-deferred sessions, etc.), the agent
518
+ # still auto-enters the loop without the user having to remind it.
519
+ claudemd_body = f"""# {project} — {agent} (MeshCode agent workspace)
520
+
521
+ You are agent **{agent}** in MeshCode meshwork **{project}**.
522
+ Role: {role or "(set in dashboard)"}
523
+
524
+ ## ON SESSION START — DO THIS IMMEDIATELY (no matter what the user types)
525
+
526
+ The moment this session opens — whether the user typed `boot`, `hi`, nothing,
527
+ or anything else — run the full boot protocol. Do NOT reply with only a
528
+ greeting or memory dump and then idle. Do NOT ask "what do you want to work
529
+ on" — the mesh tells you via tasks/messages.
530
+
531
+ If the meshcode_* tool schemas are deferred in your client, your FIRST tool
532
+ call MUST be:
533
+
534
+ ```
535
+ ToolSearch(query="select:meshcode_set_status,meshcode_check,meshcode_tasks,meshcode_auto_wake,meshcode_status,meshcode_wait,meshcode_send,meshcode_task_claim,meshcode_task_complete,meshcode_remember,meshcode_recall")
536
+ ```
537
+
538
+ Then run the boot sequence:
539
+
540
+ 1. `meshcode_set_status(status="online", task="ready")`
541
+ 2. `meshcode_check()` — read NEW messages (last_seen auto-restored from mesh memory)
542
+ 3. `meshcode_tasks()` — see assigned/pending tasks; claim any unclaimed for this agent
543
+ 4. `meshcode_auto_wake()` — scan meshwork health, surface issues
544
+ 5. `meshcode_status()` — see who's online
545
+ 6. `meshcode_wait()` — enter the permanent loop (see next section)
546
+
547
+ ## PERMANENT LOOP (THE #1 RULE)
548
+
549
+ After the boot sequence — and after **EVERY** subsequent action — your next
550
+ tool call MUST be `meshcode_wait()`. The loop:
551
+
552
+ ```
553
+ act → (optional meshcode_send) → meshcode_wait() → repeat
554
+ ```
555
+
556
+ NEVER exit. NEVER stop. NEVER say "standing by" / "let me know" / "I'm here"
557
+ without actually calling `meshcode_wait()`. The ONLY exits:
558
+ - the human user explicitly says "stop" / "sleep" / "exit",
559
+ - the commander broadcasts a got_done / sleep authorization,
560
+ - a fatal error makes continuation impossible.
561
+
562
+ If `meshcode_wait()` times out, call it again with a 2× longer timeout (cap 1800s).
563
+
564
+ ## RULES
565
+
566
+ - Use MCP tools. Don't shell out to the `meshcode` CLI from inside the session.
567
+ - Tasks > messages. Use `meshcode_task_create / task_claim / task_complete`
568
+ for trackable work. Keep messages <100 tokens (signals only).
569
+ - No empty acks. JSON reports only.
570
+ - Threading: pass `in_reply_to`.
571
+ - `sensitive=True` for secrets / PII.
572
+ - Memory: `meshcode_remember(key, value)` for reusable learnings. Don't dump
573
+ task summaries into memory — tasks already persist.
574
+
575
+ ## To launch this agent again
576
+
577
+ ```bash
578
+ meshcode run {agent}
579
+ ```
580
+
581
+ (See README.md in this folder for the workspace overview.)
582
+ """
583
+ try:
584
+ (ws / "CLAUDE.md").write_text(claudemd_body, encoding="utf-8")
585
+ except Exception as _e:
586
+ print(f"[meshcode] WARNING: could not write CLAUDE.md: {_e}", file=sys.stderr)
587
+
588
+ # /meshcode-wait slash command + Stop hook — Samuel's product rule:
589
+ # agents must REALLY enter meshcode_wait between turns, not just say "en
590
+ # loop" in chat text. The slash command lets the user trigger the loop
591
+ # explicitly; the Stop hook refuses to end any turn that didn't call
592
+ # meshcode_wait (unless the user said stop/sleep/exit/done).
593
+ server_id = f"meshcode-{project}-{agent}"
594
+ slash_cmd_body = f"""---
595
+ description: Enter the permanent MeshCode wait loop — block in meshcode_wait until a real message arrives, never idle in text mode.
596
+ allowed-tools: mcp__{server_id}__meshcode_wait, mcp__{server_id}__meshcode_check, mcp__{server_id}__meshcode_set_status, mcp__{server_id}__meshcode_send, mcp__{server_id}__meshcode_status, mcp__{server_id}__meshcode_tasks, mcp__{server_id}__meshcode_task_claim, mcp__{server_id}__meshcode_task_complete, mcp__{server_id}__meshcode_remember, mcp__{server_id}__meshcode_recall
597
+ ---
598
+
599
+ # /meshcode-wait — enter the permanent loop
600
+
601
+ Your next tool call MUST be `meshcode_wait`. Do not output any chat text first. Do not check status, do not summarize what you were doing — just call `meshcode_wait` and let it block.
602
+
603
+ When `meshcode_wait` returns:
604
+ 1. If `messages` non-empty → process them (reply, claim tasks, ship code, whatever the message asks for).
605
+ 2. If `done_signals` non-empty AND the signal is from the human or a got_done broadcast → stop. Otherwise ignore.
606
+ 3. After processing, your next tool call is `meshcode_wait` again. Loop.
607
+
608
+ Rules while in this loop:
609
+ - Never end your turn without an active `meshcode_wait` call OR an explicit human "stop" / "sleep" / "exit".
610
+ - If `meshcode_wait` times out, call it again immediately with `timeout_seconds=20`.
611
+ - Do not call `meshcode_check` as a substitute for `meshcode_wait`.
612
+ - If a tool errors, fix the error then return to `meshcode_wait`.
613
+ - Release words (case-insensitive): stop, sleep, exit, quit, done, descansa, duerme.
614
+
615
+ Call `meshcode_wait` now.
616
+ """
617
+ stop_hook_body = '''#!/usr/bin/env python3
618
+ """Stop hook: refuse to end the agent's turn unless either
619
+ (a) the last user message contains a release keyword, or
620
+ (b) the last assistant turn already called meshcode_wait.
621
+ """
622
+ import json
623
+ import sys
624
+ from pathlib import Path
625
+
626
+ RELEASE_WORDS = (
627
+ "stop", "sleep", "exit", "quit", "done",
628
+ "duerme", "descansa", "quedate dormido", "se acab",
629
+ "got_done",
630
+ )
631
+ WAIT_TOOL_SUFFIX = "meshcode_wait"
632
+
633
+
634
+ def _last_user_message(transcript_path):
635
+ try:
636
+ last = ""
637
+ with transcript_path.open() as f:
638
+ for line in f:
639
+ try:
640
+ rec = json.loads(line)
641
+ except json.JSONDecodeError:
642
+ continue
643
+ if rec.get("role") == "user":
644
+ content = rec.get("content")
645
+ if isinstance(content, str):
646
+ last = content
647
+ elif isinstance(content, list):
648
+ last = " ".join(
649
+ p.get("text", "")
650
+ for p in content
651
+ if isinstance(p, dict) and p.get("type") == "text"
652
+ )
653
+ return last
654
+ except (OSError, FileNotFoundError):
655
+ return ""
656
+
657
+
658
+ def _last_assistant_turn_called_wait(transcript_path):
659
+ try:
660
+ records = []
661
+ with transcript_path.open() as f:
662
+ for line in f:
663
+ try:
664
+ records.append(json.loads(line))
665
+ except json.JSONDecodeError:
666
+ continue
667
+ last_assistant_blocks = []
668
+ for rec in reversed(records):
669
+ if rec.get("role") == "user":
670
+ if last_assistant_blocks:
671
+ break
672
+ continue
673
+ if rec.get("role") == "assistant":
674
+ content = rec.get("content")
675
+ if isinstance(content, list):
676
+ last_assistant_blocks.extend(content)
677
+ elif isinstance(content, dict):
678
+ last_assistant_blocks.append(content)
679
+ for block in last_assistant_blocks:
680
+ if not isinstance(block, dict):
681
+ continue
682
+ if block.get("type") == "tool_use":
683
+ name = str(block.get("name", ""))
684
+ if name.endswith(WAIT_TOOL_SUFFIX):
685
+ return True
686
+ return False
687
+ except (OSError, FileNotFoundError):
688
+ return False
689
+
690
+
691
+ def main():
692
+ raw = sys.stdin.read()
693
+ try:
694
+ payload = json.loads(raw) if raw else {}
695
+ except json.JSONDecodeError:
696
+ payload = {}
697
+ transcript = payload.get("transcript_path") or payload.get("transcriptPath")
698
+ transcript_path = Path(transcript) if transcript else None
699
+ last_user = _last_user_message(transcript_path).lower() if transcript_path else ""
700
+ if any(word in last_user for word in RELEASE_WORDS):
701
+ sys.exit(0)
702
+ if transcript_path and _last_assistant_turn_called_wait(transcript_path):
703
+ sys.exit(0)
704
+ print(json.dumps({
705
+ "decision": "block",
706
+ "reason": (
707
+ "Stay-on-loop: you ended your turn without calling meshcode_wait. "
708
+ "Per Samuel's standing rule, every turn ends with an active "
709
+ "meshcode_wait. Call meshcode_wait now (timeout_seconds=20). "
710
+ "Do not reply with text \\u2014 just make the tool call. The "
711
+ "only way out is the user typing stop / sleep / exit / done."
712
+ ),
713
+ }))
714
+ sys.exit(0)
715
+
716
+
717
+ if __name__ == "__main__":
718
+ main()
719
+ '''
720
+ settings_body = json.dumps({
721
+ "hooks": {
722
+ "Stop": [{
723
+ "hooks": [{
724
+ "type": "command",
725
+ "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/stay_on_loop.py\"",
726
+ }],
727
+ }],
728
+ },
729
+ }, indent=2) + "\n"
730
+ try:
731
+ (ws / ".claude" / "commands").mkdir(parents=True, exist_ok=True)
732
+ (ws / ".claude" / "hooks").mkdir(parents=True, exist_ok=True)
733
+ (ws / ".claude" / "commands" / "meshcode-wait.md").write_text(slash_cmd_body, encoding="utf-8")
734
+ hook_path = ws / ".claude" / "hooks" / "stay_on_loop.py"
735
+ hook_path.write_text(stop_hook_body, encoding="utf-8")
736
+ try:
737
+ hook_path.chmod(0o755)
738
+ except OSError:
739
+ pass
740
+ (ws / ".claude" / "settings.json").write_text(settings_body, encoding="utf-8")
741
+ except Exception as _e:
742
+ print(f"[meshcode] WARNING: could not write /meshcode-wait command + hook: {_e}", file=sys.stderr)
743
+
514
744
  print(f"[meshcode] ✓ Workspace created for agent '{agent}' (project: {project})")
515
745
  print(f"[meshcode] Path: {ws}")
516
746
  print(f"[meshcode]")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 2.10.77
3
+ Version: 2.10.84
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 = "2.10.77"
7
+ version = "2.10.84"
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