meshcode 2.10.77__tar.gz → 2.10.83__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {meshcode-2.10.77 → meshcode-2.10.83}/PKG-INFO +1 -1
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/__init__.py +1 -1
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/comms_v4.py +40 -2
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/meshcode_mcp/server.py +106 -25
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/setup_clients.py +230 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode.egg-info/PKG-INFO +1 -1
- {meshcode-2.10.77 → meshcode-2.10.83}/pyproject.toml +1 -1
- {meshcode-2.10.77 → meshcode-2.10.83}/README.md +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/ascii_art.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/cli.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/compat.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/error_hints.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/exceptions.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/invites.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/launcher.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/launcher_install.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/meshcode_mcp/__init__.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/meshcode_mcp/__main__.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/meshcode_mcp/backend.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/meshcode_mcp/realtime.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/meshcode_mcp/test_backend.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/meshcode_mcp/test_realtime.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/preferences.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/protocol_v2.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/quickstart.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/run_agent.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/secrets.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/self_update.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/supervisor.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode/upload.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode.egg-info/SOURCES.txt +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode.egg-info/dependency_links.txt +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode.egg-info/entry_points.txt +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode.egg-info/requires.txt +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/meshcode.egg-info/top_level.txt +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/setup.cfg +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/tests/test_core.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/tests/test_cross_agent_messaging.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/tests/test_esc_deaf_state.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/tests/test_exceptions.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/tests/test_mark_read_batch.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/tests/test_migration_integrity.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/tests/test_realtime_event_freshness.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/tests/test_rls_cross_tenant.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/tests/test_rpc_migrations.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/tests/test_security_regressions.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/tests/test_sentinel.py +0 -0
- {meshcode-2.10.77 → meshcode-2.10.83}/tests/test_status_enum_coverage.py +0 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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":
|
|
1975
|
-
"Authorization": f"Bearer {
|
|
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
|
-
|
|
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}
|
|
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__}
|
|
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
|
-
"
|
|
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":
|
|
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",
|
|
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
|
-
|
|
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()
|
|
@@ -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]")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|