meshcode 2.11.108__tar.gz → 2.11.110rc1__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.11.108 → meshcode-2.11.110rc1}/PKG-INFO +1 -1
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/__init__.py +1 -1
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/_stop_hook_template.py +8 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/comms_v4.py +33 -4
- meshcode-2.11.110rc1/meshcode/hooks/__init__.py +7 -0
- meshcode-2.11.110rc1/meshcode/hooks/repo_path_lock.py +156 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/hostd.py +177 -14
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/server.py +54 -41
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/protocol_handler.py +36 -6
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/run_agent.py +131 -5
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/self_update.py +50 -7
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/setup_clients.py +26 -1
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode.egg-info/PKG-INFO +1 -1
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode.egg-info/SOURCES.txt +2 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/pyproject.toml +2 -1
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/README.md +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/__main__.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/_session_handoff_template 2.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/_session_handoff_template 3.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/_session_handoff_template.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/ascii_art.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/atomic_push.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/claude_update 2.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/claude_update 3.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/claude_update.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/cli.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/compat.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/daemon.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/date_parse.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/doctor.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/error_hints.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/exceptions.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/hostd 2.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/invites.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/launcher.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/launcher_install.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/__init__.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/__main__.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/backend.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/realtime.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/sleep_signals.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/test_backend.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/test_boot_timing.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/test_install_guard.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/test_prefs_claude_version.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/test_realtime.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/preferences.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/protocol_v2.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/quickstart.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/rpc_allowlist.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/scripts/check_secrets.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/scripts/race_rate_harness.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/secrets.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/supervisor.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/up 2.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/up.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/upload.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode.egg-info/dependency_links.txt +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode.egg-info/entry_points.txt +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode.egg-info/requires.txt +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode.egg-info/top_level.txt +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/setup.cfg +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_auto_update_hardening.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_autonomous_closegap_1.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_autonomous_closegap_2.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_autonomous_closegap_3.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_autonomous_prompt_inject 2.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_autonomous_prompt_inject 3.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_autonomous_prompt_inject.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_boot_bug_regression.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_color_truecolor.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_core.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_cross_agent_messaging.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_date_parse.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_doctor.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_epistemic_v1_python_sdk.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_epistemic_v1_stop_conditions.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_esc_deaf_state.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_exceptions.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_file_upload.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_init_device_code.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_install_guard.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_lease_sigterm_release.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_mark_read_batch.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_marketplace_ratings.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_migration_integrity.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_realtime_event_freshness.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_rls_cross_tenant.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_rpc_grants.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_rpc_migrations.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_run_agent_dry_run.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_run_agent_no_server_import.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_security_regressions.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_self_update_user_site.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_sentinel.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_setup_path.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_sleep_signals.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_status_enum_coverage.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_stay_on_loop_hook.py +0 -0
- {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_wait_open_tasks_contradiction.py +0 -0
|
@@ -48,6 +48,7 @@ reported commander trapped in this exact path. Two new release paths:
|
|
|
48
48
|
connected") → human-confirmed unreachable.
|
|
49
49
|
"""
|
|
50
50
|
import json
|
|
51
|
+
import os
|
|
51
52
|
import sys
|
|
52
53
|
from pathlib import Path
|
|
53
54
|
|
|
@@ -469,6 +470,13 @@ def _user_explicitly_reports_mcp_failure(transcript_path, lookback_records=_UNRE
|
|
|
469
470
|
|
|
470
471
|
|
|
471
472
|
def main():
|
|
473
|
+
# MESHCODE_AGENT_SESSION gate (task 24e3dd44): this hook is safe to install at USER
|
|
474
|
+
# scope (~/.claude/settings.json) because it NO-OPS for any Claude session that is not
|
|
475
|
+
# a meshcode agent launch. run_agent exports MESHCODE_AGENT_SESSION=1 for every agent;
|
|
476
|
+
# a human's personal `claude` session has it unset -> the loop hook never traps them.
|
|
477
|
+
# (Workspace-scope installs also inherit the env, so agent loop-survival is unchanged.)
|
|
478
|
+
if not os.environ.get("MESHCODE_AGENT_SESSION"):
|
|
479
|
+
sys.exit(0)
|
|
472
480
|
raw = sys.stdin.read()
|
|
473
481
|
try:
|
|
474
482
|
payload = json.loads(raw) if raw else {}
|
|
@@ -1797,8 +1797,15 @@ def _heartbeat_loop_pidfile(project, name):
|
|
|
1797
1797
|
return Path.home() / ".meshcode" / f"heartbeat_{project}_{name}.pid"
|
|
1798
1798
|
|
|
1799
1799
|
|
|
1800
|
-
def _start_heartbeat_daemon(project, name):
|
|
1801
|
-
"""Fork a tiny background process that POSTs mc_heartbeat every 30s.
|
|
1800
|
+
def _start_heartbeat_daemon(project, name, agent_pid=None):
|
|
1801
|
+
"""Fork a tiny background process that POSTs mc_heartbeat every 30s.
|
|
1802
|
+
|
|
1803
|
+
Fix A (task 24e3dd44, mesh-dev): when agent_pid is given (the long-lived claude
|
|
1804
|
+
PID = connect_terminal ppid), the fork verifies that process is ALIVE before each
|
|
1805
|
+
POST and self-exits when gone — so a dead agent stops showing 'online'. Windows-
|
|
1806
|
+
SAFE: never os.kill(pid,0) on win32 (that TERMINATES); uses OpenProcess +
|
|
1807
|
+
GetExitCodeProcess (259 == STILL_ACTIVE).
|
|
1808
|
+
"""
|
|
1802
1809
|
pid_file = _heartbeat_loop_pidfile(project, name)
|
|
1803
1810
|
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
|
1804
1811
|
# Stop any existing heartbeat
|
|
@@ -1818,7 +1825,24 @@ def _start_heartbeat_daemon(project, name):
|
|
|
1818
1825
|
f"project={project!r}; name={name!r}\n"
|
|
1819
1826
|
f"url={SUPABASE_URL!r}; key={SUPABASE_KEY!r}\n"
|
|
1820
1827
|
f"api_key={_ak!r}\n"
|
|
1828
|
+
f"agent_pid={agent_pid!r}\n"
|
|
1821
1829
|
"import urllib.parse\n"
|
|
1830
|
+
"def agent_alive():\n"
|
|
1831
|
+
" if not agent_pid: return True\n"
|
|
1832
|
+
" if sys.platform=='win32':\n"
|
|
1833
|
+
" try:\n"
|
|
1834
|
+
" import ctypes\n"
|
|
1835
|
+
" h=ctypes.windll.kernel32.OpenProcess(0x1000, False, int(agent_pid))\n"
|
|
1836
|
+
" if not h: return False\n"
|
|
1837
|
+
" code=ctypes.c_ulong()\n"
|
|
1838
|
+
" ok=ctypes.windll.kernel32.GetExitCodeProcess(h, ctypes.byref(code))\n"
|
|
1839
|
+
" ctypes.windll.kernel32.CloseHandle(h)\n"
|
|
1840
|
+
" return bool(ok) and code.value==259\n"
|
|
1841
|
+
" except Exception: return True\n"
|
|
1842
|
+
" try:\n"
|
|
1843
|
+
" os.kill(int(agent_pid),0); return True\n"
|
|
1844
|
+
" except ProcessLookupError: return False\n"
|
|
1845
|
+
" except Exception: return True\n"
|
|
1822
1846
|
"def post(path, body):\n"
|
|
1823
1847
|
" req=urllib.request.Request(url+path, data=json.dumps(body).encode(),\n"
|
|
1824
1848
|
" headers={'apikey':key,'Authorization':'Bearer '+key,'Content-Type':'application/json','Accept-Profile':'meshcode','Content-Profile':'meshcode'}, method='POST')\n"
|
|
@@ -1856,6 +1880,8 @@ def _start_heartbeat_daemon(project, name):
|
|
|
1856
1880
|
" except Exception: return True\n"
|
|
1857
1881
|
"pid=get_pid_for_project()\n"
|
|
1858
1882
|
"while True:\n"
|
|
1883
|
+
" if not agent_alive():\n"
|
|
1884
|
+
" sys.exit(0)\n"
|
|
1859
1885
|
" if pid:\n"
|
|
1860
1886
|
" if not check_still_leased(pid):\n"
|
|
1861
1887
|
" sys.exit(0)\n"
|
|
@@ -1961,7 +1987,9 @@ def connect_terminal(project, name, role=""):
|
|
|
1961
1987
|
except Exception:
|
|
1962
1988
|
pass # Non-critical — don't block agent boot
|
|
1963
1989
|
|
|
1964
|
-
|
|
1990
|
+
# Fix A: hand the heartbeat fork the owning claude/agent PID so it self-exits when
|
|
1991
|
+
# the agent dies (instead of keeping a dead agent 'online'). ppid is that long-lived process.
|
|
1992
|
+
hb_pid = _start_heartbeat_daemon(project, name, agent_pid=ppid)
|
|
1965
1993
|
|
|
1966
1994
|
pending = (rpc_result or {}).get("pending_messages", 0)
|
|
1967
1995
|
print(f"[meshcode] Connected: {name} -> {project}")
|
|
@@ -3382,6 +3410,7 @@ if __name__ == "__main__":
|
|
|
3382
3410
|
proj_override = flags.get("project")
|
|
3383
3411
|
editor_override = flags.get("editor")
|
|
3384
3412
|
perm_override = flags.get("permission") # bypass | safe | ask
|
|
3413
|
+
repo_override = flags.get("repo") # repo-scoped launch (cwd=repo + repo-lock hooks); task 24e3dd44
|
|
3385
3414
|
if "--bypass" in sys.argv:
|
|
3386
3415
|
perm_override = "bypass"
|
|
3387
3416
|
elif "--safe" in sys.argv:
|
|
@@ -3413,7 +3442,7 @@ if __name__ == "__main__":
|
|
|
3413
3442
|
autonomous = bool(flags.get("autonomous")) or autonomous_env
|
|
3414
3443
|
import importlib
|
|
3415
3444
|
_run = importlib.import_module("meshcode.run_agent").run
|
|
3416
|
-
sys.exit(_run(agent, project=proj_override, editor_override=editor_override, permission_override=perm_override, dry_run=dry_run, autonomous=autonomous))
|
|
3445
|
+
sys.exit(_run(agent, project=proj_override, editor_override=editor_override, permission_override=perm_override, dry_run=dry_run, autonomous=autonomous, repo_path=repo_override))
|
|
3417
3446
|
|
|
3418
3447
|
elif cmd == "scan":
|
|
3419
3448
|
# meshcode scan — detect identicon from stdin/clipboard and launch agent
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""MeshCode packaged Claude Code hook scripts (shipped as package data).
|
|
2
|
+
|
|
3
|
+
Currently: repo_path_lock.py — the canonical PreToolUse repo-path lock that
|
|
4
|
+
hard-locks a repo-scoped agent session to its registered repo even under
|
|
5
|
+
--dangerously-skip-permissions. Installed to ~/.claude/hooks by
|
|
6
|
+
run_agent.ensure_repo_lock_hook_installed() (fallback user-scope path).
|
|
7
|
+
"""
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""MeshCode canonical PreToolUse repo-path lock hook.
|
|
3
|
+
|
|
4
|
+
Hard-locks an agent's filesystem tool calls to its registered repo path —
|
|
5
|
+
EVEN under `--dangerously-skip-permissions` (bypassPermissions). This works
|
|
6
|
+
because Claude Code fires PreToolUse hooks BEFORE any permission-mode check,
|
|
7
|
+
and a hook returning `permissionDecision: "deny"` blocks the tool in every
|
|
8
|
+
mode. settings.json `permissions.deny` does NOT survive bypass — only this hook does.
|
|
9
|
+
|
|
10
|
+
Env contract (exported by the launcher):
|
|
11
|
+
MESHCODE_REPO_LOCK absolute repo root the session is locked to.
|
|
12
|
+
UNSET/empty -> hook is a NO-OP (fail-open).
|
|
13
|
+
MESHCODE_REPO_LOCK_ALLOW optional os.pathsep-separated extra allowed roots.
|
|
14
|
+
MESHCODE_REPO_LOCK_BASH "1" to also scan Bash commands (heuristic).
|
|
15
|
+
Always-allowed: locked root + ALLOW entries + ~/.claude/projects + OS temp.
|
|
16
|
+
"""
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
|
|
23
|
+
_CYGDRIVE_RE = re.compile(r"^/cygdrive/([a-zA-Z])(?=/|$)")
|
|
24
|
+
_MSYS_DRIVE_RE = re.compile(r"^/([a-zA-Z])(?=/|$)")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _demsys(path: str) -> str:
|
|
28
|
+
path = _CYGDRIVE_RE.sub(lambda m: m.group(1) + ":", path)
|
|
29
|
+
return _MSYS_DRIVE_RE.sub(lambda m: m.group(1) + ":", path)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _norm(p: str) -> str:
|
|
33
|
+
return os.path.normcase(os.path.realpath(p))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolve(path: str, cwd: str) -> str:
|
|
37
|
+
path = os.path.expanduser(_demsys(path))
|
|
38
|
+
cwd = os.path.expanduser(_demsys(cwd))
|
|
39
|
+
if not os.path.isabs(path):
|
|
40
|
+
path = os.path.join(cwd, path)
|
|
41
|
+
return _norm(path)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _within(target: str, root: str) -> bool:
|
|
45
|
+
if target == root:
|
|
46
|
+
return True
|
|
47
|
+
return target.startswith(root + os.sep)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _allowed_roots(cwd: str):
|
|
51
|
+
roots = []
|
|
52
|
+
lock = os.environ.get("MESHCODE_REPO_LOCK", "").strip()
|
|
53
|
+
if lock:
|
|
54
|
+
roots.append(_norm(lock))
|
|
55
|
+
extra = os.environ.get("MESHCODE_REPO_LOCK_ALLOW", "").strip()
|
|
56
|
+
if extra:
|
|
57
|
+
for r in extra.split(os.pathsep):
|
|
58
|
+
r = r.strip()
|
|
59
|
+
if r:
|
|
60
|
+
roots.append(_norm(r))
|
|
61
|
+
home = os.path.expanduser("~")
|
|
62
|
+
roots.append(_norm(os.path.join(home, ".claude", "projects")))
|
|
63
|
+
roots.append(_norm(tempfile.gettempdir()))
|
|
64
|
+
return roots
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_PATH_KEYS = {
|
|
68
|
+
"Read": ["file_path"],
|
|
69
|
+
"Edit": ["file_path"],
|
|
70
|
+
"Write": ["file_path"],
|
|
71
|
+
"MultiEdit": ["file_path"],
|
|
72
|
+
"NotebookEdit": ["notebook_path"],
|
|
73
|
+
"Glob": ["path"],
|
|
74
|
+
"Grep": ["path"],
|
|
75
|
+
"LS": ["path"],
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _deny(reason: str) -> None:
|
|
80
|
+
print(json.dumps({
|
|
81
|
+
"hookSpecificOutput": {
|
|
82
|
+
"hookEventName": "PreToolUse",
|
|
83
|
+
"permissionDecision": "deny",
|
|
84
|
+
"permissionDecisionReason": reason,
|
|
85
|
+
}
|
|
86
|
+
}))
|
|
87
|
+
sys.exit(0)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _candidate_bash_paths(command: str):
|
|
91
|
+
toks = re.findall(r'"[^"]*"|\'[^\']*\'|\S+', command)
|
|
92
|
+
for t in toks:
|
|
93
|
+
t = t.strip('"\'')
|
|
94
|
+
if not t:
|
|
95
|
+
continue
|
|
96
|
+
looks_pathy = (
|
|
97
|
+
bool(_MSYS_DRIVE_RE.match(t))
|
|
98
|
+
or bool(_CYGDRIVE_RE.match(t))
|
|
99
|
+
or t.startswith("~/")
|
|
100
|
+
or t.startswith("..")
|
|
101
|
+
or "/.." in t
|
|
102
|
+
or "\\.." in t
|
|
103
|
+
or (len(t) >= 3 and t[1] == ":" and t[2] in "\\/")
|
|
104
|
+
)
|
|
105
|
+
if looks_pathy:
|
|
106
|
+
yield t
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main() -> int:
|
|
110
|
+
try:
|
|
111
|
+
raw = sys.stdin.read()
|
|
112
|
+
data = json.loads(raw) if raw.strip() else {}
|
|
113
|
+
except Exception:
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
if not os.environ.get("MESHCODE_REPO_LOCK", "").strip():
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
tool = data.get("tool_name") or ""
|
|
120
|
+
tool_input = data.get("tool_input") or {}
|
|
121
|
+
cwd = data.get("cwd") or os.getcwd()
|
|
122
|
+
roots = _allowed_roots(cwd)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
if tool in _PATH_KEYS:
|
|
126
|
+
for key in _PATH_KEYS[tool]:
|
|
127
|
+
raw_path = tool_input.get(key)
|
|
128
|
+
if not raw_path:
|
|
129
|
+
continue
|
|
130
|
+
target = _resolve(str(raw_path), cwd)
|
|
131
|
+
if not any(_within(target, r) for r in roots):
|
|
132
|
+
_deny(
|
|
133
|
+
f"repo-path lock: {tool} target '{raw_path}' is outside "
|
|
134
|
+
f"the locked repo. Allowed root: "
|
|
135
|
+
f"{os.environ.get('MESHCODE_REPO_LOCK')}"
|
|
136
|
+
)
|
|
137
|
+
elif tool == "Bash" and os.environ.get("MESHCODE_REPO_LOCK_BASH", "").strip() == "1":
|
|
138
|
+
command = str(tool_input.get("command") or "")
|
|
139
|
+
for cand in _candidate_bash_paths(command):
|
|
140
|
+
target = _resolve(cand, cwd)
|
|
141
|
+
if not any(_within(target, r) for r in roots):
|
|
142
|
+
_deny(
|
|
143
|
+
f"repo-path lock: Bash command references '{cand}' outside "
|
|
144
|
+
f"the locked repo ({os.environ.get('MESHCODE_REPO_LOCK')}). "
|
|
145
|
+
f"Set MESHCODE_REPO_LOCK_BASH=0 to disable bash scanning."
|
|
146
|
+
)
|
|
147
|
+
except SystemExit:
|
|
148
|
+
raise
|
|
149
|
+
except Exception:
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
if __name__ == "__main__":
|
|
156
|
+
sys.exit(main())
|
|
@@ -34,6 +34,7 @@ from __future__ import annotations
|
|
|
34
34
|
|
|
35
35
|
import json
|
|
36
36
|
import os
|
|
37
|
+
import re
|
|
37
38
|
import shlex
|
|
38
39
|
import shutil
|
|
39
40
|
import signal as _signal
|
|
@@ -327,7 +328,26 @@ def _meshcode_bin() -> str:
|
|
|
327
328
|
return "meshcode"
|
|
328
329
|
|
|
329
330
|
|
|
330
|
-
def
|
|
331
|
+
def _validate_repo_path(rp) -> Optional[str]:
|
|
332
|
+
"""Validate a per-agent repo override (task 24e3dd44 #4, mc_agents.repo_path).
|
|
333
|
+
Returns an absolute realpath dir, or None. REJECTS (None + logs): NULL/empty, a value with a
|
|
334
|
+
double-quote or control char (injection hardening), or a path that is not an existing dir."""
|
|
335
|
+
if not rp or not isinstance(rp, str):
|
|
336
|
+
return None
|
|
337
|
+
if '"' in rp or any(ord(ch) < 0x20 for ch in rp):
|
|
338
|
+
_log(f"WARN: repo_path rejected (quote/control char): {rp!r}")
|
|
339
|
+
return None
|
|
340
|
+
try:
|
|
341
|
+
p = os.path.realpath(os.path.expanduser(rp))
|
|
342
|
+
except Exception:
|
|
343
|
+
return None
|
|
344
|
+
if not os.path.isdir(p):
|
|
345
|
+
_log(f"WARN: repo_path rejected (not a directory): {rp!r} -> {p}")
|
|
346
|
+
return None
|
|
347
|
+
return p
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _spawn_agent(project: str, agent: str, headless: bool = False, repo_path=None) -> bool:
|
|
331
351
|
"""Relaunch `meshcode run <project>/<agent>`.
|
|
332
352
|
|
|
333
353
|
headless=False (default): VISIBLE terminal window. Samuel must SEE it — we
|
|
@@ -338,9 +358,14 @@ def _spawn_agent(project: str, agent: str, headless: bool = False) -> bool:
|
|
|
338
358
|
|
|
339
359
|
headless=True (Fleet Control mig404 per-agent flag): background process, NO
|
|
340
360
|
window — for fleet agents that don't need a terminal (like the qa launch).
|
|
361
|
+
|
|
362
|
+
repo_path (task 24e3dd44 #4): optional per-agent repo. When set + valid, `--repo <path>` is
|
|
363
|
+
appended so `meshcode run` opens there. NULL/invalid = unchanged. Inert until mc_agents.repo_path
|
|
364
|
+
+ the respawn RPC carry it.
|
|
341
365
|
"""
|
|
342
366
|
target = f"{project}/{agent}"
|
|
343
367
|
bin_ = _meshcode_bin()
|
|
368
|
+
repo = _validate_repo_path(repo_path)
|
|
344
369
|
if headless:
|
|
345
370
|
# background, NO terminal — UNIVERSAL macOS/Linux/Windows (task c1a6c6a8, mesh-dev specs).
|
|
346
371
|
# Clean env (a stale CLAUDECODE aborts `meshcode run`); keep crash logs in a per-agent logfile
|
|
@@ -403,7 +428,8 @@ def _spawn_agent(project: str, agent: str, headless: bool = False) -> bool:
|
|
|
403
428
|
try:
|
|
404
429
|
# `python -m meshcode` (NOT the meshcode.exe shim) so the .exe isn't held open by the
|
|
405
430
|
# agent -> a background `pip install -U` can replace it on Windows (task 14782bb4 #4).
|
|
406
|
-
|
|
431
|
+
argv = [sys.executable, "-m", "meshcode", "run", target] + (["--repo", repo] if repo else [])
|
|
432
|
+
proc = subprocess.Popen(argv, **kwargs)
|
|
407
433
|
# LAUNCH-NO-WINDOW (task 35bee961): record the headless PID so the Stop sweep
|
|
408
434
|
# (_do_stops) can hard-kill it — a headless agent has NO window for Samuel to
|
|
409
435
|
# close, so desired_state='stopped' must be enforced by killing the process.
|
|
@@ -435,14 +461,17 @@ def _spawn_agent(project: str, agent: str, headless: bool = False) -> bool:
|
|
|
435
461
|
except Exception:
|
|
436
462
|
_bindir = ""
|
|
437
463
|
if sys.platform == "win32":
|
|
464
|
+
# repo already validated (no '"'/control chars, real dir) -> safe to double-quote.
|
|
465
|
+
_repo_win = f' --repo "{repo}"' if repo else ''
|
|
438
466
|
cmd = (f'set "CLAUDECODE=" & set "CLAUDE_CODE_SESSION=" & '
|
|
439
467
|
f'set "MESHCODE_NO_UPDATE=" & set "MESHCODE_NO_AUTO_UPDATE=" & '
|
|
440
468
|
+ (f'set "PATH={_bindir};%PATH%" & ' if _bindir else '')
|
|
441
|
-
+ f'"{sys.executable}" -m meshcode run "{target}"')
|
|
469
|
+
+ f'"{sys.executable}" -m meshcode run "{target}"{_repo_win}')
|
|
442
470
|
else:
|
|
471
|
+
_repo_posix = f" --repo {shlex.quote(repo)}" if repo else ''
|
|
443
472
|
cmd = (f"unset CLAUDECODE CLAUDE_CODE_SESSION MESHCODE_NO_UPDATE MESHCODE_NO_AUTO_UPDATE; "
|
|
444
473
|
+ (f"export PATH={shlex.quote(_bindir)}:$PATH; " if _bindir else "")
|
|
445
|
-
+ f"exec {shlex.quote(sys.executable)} -m meshcode run {shlex.quote(target)}")
|
|
474
|
+
+ f"exec {shlex.quote(sys.executable)} -m meshcode run {shlex.quote(target)}{_repo_posix}")
|
|
446
475
|
try:
|
|
447
476
|
from meshcode import protocol_handler as _ph
|
|
448
477
|
ok, info = _ph._spawn_terminal(cmd)
|
|
@@ -561,11 +590,17 @@ def _do_respawns(api_key: str, host_id: str) -> int:
|
|
|
561
590
|
except Exception:
|
|
562
591
|
pass
|
|
563
592
|
continue
|
|
593
|
+
# Part 2 (Samuel req #2): for a VISIBLE recycle, snapshot the OLD window
|
|
594
|
+
# pid(s) BEFORE spawning the fresh one — so the new pid is never in the
|
|
595
|
+
# close set (commander q1: never touch the fresh terminal).
|
|
596
|
+
_old_vis_pids = _discover_agent_pids(_target) if (_is_recycle and _visible) else []
|
|
564
597
|
_log(f"{'RECYCLE-RESPAWN' if _is_recycle else 'RESPAWN'} {proj}/{agent} "
|
|
565
598
|
f"({'VISIBLE ' if _visible else ''}stale {c.get('heartbeat_age_s')}s, count={c.get('respawn_count')})")
|
|
566
|
-
if _spawn_agent(proj, agent, headless=_hl):
|
|
599
|
+
if _spawn_agent(proj, agent, headless=_hl, repo_path=c.get("repo_path")):
|
|
567
600
|
_record_spawn(_target) # count the terminal we just opened, against the breaker
|
|
568
601
|
if _is_recycle:
|
|
602
|
+
if _visible and _old_vis_pids:
|
|
603
|
+
_close_old_visible_recycle(_target, _old_vis_pids) # close old window (DRY-RUN first)
|
|
569
604
|
_rpc("mc_record_recycle",
|
|
570
605
|
{"p_api_key": api_key, "p_project_id": c.get("project_id"), "p_agent_name": agent})
|
|
571
606
|
n += 1
|
|
@@ -667,6 +702,69 @@ def _recycle_blocked(st, target, now=None):
|
|
|
667
702
|
return None
|
|
668
703
|
|
|
669
704
|
|
|
705
|
+
# Samuel rule 2026-06-04: never recycle a CONNECTED agent (live MCP session) except
|
|
706
|
+
# the >3h uptime lifecycle. BUSY_STATUSES (working/online/busy) MISSES a connected-
|
|
707
|
+
# but-idle agent (status idle/standby, window open, heartbeat fresh) — a fresh
|
|
708
|
+
# heartbeat is the stronger 'live session' signal, so an idle-but-connected agent
|
|
709
|
+
# was being version-recycled out from under the user (the storm he kept seeing).
|
|
710
|
+
_CONNECTED_HEARTBEAT_S = _env_int("MESHCODE_CONNECTED_HEARTBEAT_SEC", 60, 10)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _agent_connected(a) -> bool:
|
|
714
|
+
"""True if the agent has a live MCP session: an explicitly-busy status OR a
|
|
715
|
+
very-recent heartbeat (window open even when idle/standby)."""
|
|
716
|
+
if (a.get("status") or "") in BUSY_STATUSES:
|
|
717
|
+
return True
|
|
718
|
+
hb = a.get("heartbeat_age_s")
|
|
719
|
+
try:
|
|
720
|
+
return hb is not None and float(hb) < _CONNECTED_HEARTBEAT_S
|
|
721
|
+
except (TypeError, ValueError):
|
|
722
|
+
return False
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
# Part 2 (Samuel req #2 2026-06-04): on a VISIBLE recycle, close the OLD window so
|
|
726
|
+
# old+new don't both stay open (audit gap 6a203baa). DRY-RUN first (commander q2 +
|
|
727
|
+
# reaper safe-arm pattern): log-only until the logs confirm it's ONLY the old pid.
|
|
728
|
+
_CLOSE_OLD_VISIBLE_DRYRUN = True
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _pid_alive(pid) -> bool:
|
|
732
|
+
if not pid:
|
|
733
|
+
return False
|
|
734
|
+
try:
|
|
735
|
+
os.kill(int(pid), 0)
|
|
736
|
+
return True
|
|
737
|
+
except ProcessLookupError:
|
|
738
|
+
return False
|
|
739
|
+
except PermissionError:
|
|
740
|
+
return True # exists, owned by another uid — treat as alive (don't guess)
|
|
741
|
+
except Exception:
|
|
742
|
+
return False
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _close_old_visible_recycle(target: str, old_pids) -> int:
|
|
746
|
+
"""Close the OLD window's still-alive process on a VISIBLE recycle. `old_pids`
|
|
747
|
+
is the PRE-SPAWN snapshot, so the freshly-opened window's pid is excluded by
|
|
748
|
+
construction — we NEVER touch the fresh terminal (commander q1). Graceful
|
|
749
|
+
self-exit (the stop-hook ends the session on must_exit=recycle) is PRIMARY
|
|
750
|
+
(q3): an already-exited old pid is skipped. DRY-RUN first (q2): log the
|
|
751
|
+
would-close pid; flip _CLOSE_OLD_VISIBLE_DRYRUN=False to arm once logs show
|
|
752
|
+
it's only the old pid. Real kill reuses _kill_headless_pid's cmdline guard."""
|
|
753
|
+
n = 0
|
|
754
|
+
for pid in old_pids:
|
|
755
|
+
if not _pid_alive(pid):
|
|
756
|
+
continue # already self-closed gracefully (q3 primary) — nothing to do
|
|
757
|
+
if _CLOSE_OLD_VISIBLE_DRYRUN:
|
|
758
|
+
_log(f"CLOSE-OLD-VISIBLE-DRYRUN {target}: WOULD close old window pid {pid} "
|
|
759
|
+
f"(visible recycle; fresh window already spawned + excluded) — log-only. "
|
|
760
|
+
f"Flip _CLOSE_OLD_VISIBLE_DRYRUN=False after confirming it's ONLY the old pid.")
|
|
761
|
+
continue
|
|
762
|
+
if _kill_headless_pid(target, pid):
|
|
763
|
+
_log(f"CLOSE-OLD-VISIBLE {target}: closed old window pid {pid} (visible recycle; kept fresh window)")
|
|
764
|
+
n += 1
|
|
765
|
+
return n
|
|
766
|
+
|
|
767
|
+
|
|
670
768
|
def _spawn_rate_ok(target: str):
|
|
671
769
|
"""Anti-spam circuit breaker. Returns (ok, tripped_burst, reason).
|
|
672
770
|
|
|
@@ -770,8 +868,14 @@ def _pid_cmdline(pid: int) -> str:
|
|
|
770
868
|
def _discover_agent_pids(target: str) -> list:
|
|
771
869
|
"""Fallback PID discovery by command line, for agents spawned before this hostd
|
|
772
870
|
(no recorded PID) or after a state-file loss. Matches `meshcode run <target>`.
|
|
773
|
-
Best-effort; returns [] on any failure.
|
|
871
|
+
Best-effort; returns [] on any failure.
|
|
872
|
+
|
|
873
|
+
SAFETY (prefix-match fix, task 24e3dd44): target must match as a WHOLE token — `run <target>`
|
|
874
|
+
followed by whitespace/end/quote — NEVER a bare substring, else 'back' matches 'back-2' and a
|
|
875
|
+
downstream reaper/force-kill could KILL a healthy sibling. re.escape keeps target literal. On
|
|
876
|
+
POSIX pgrep -f is substring, so re-verify each candidate's full cmdline with the exact regex."""
|
|
774
877
|
pids = []
|
|
878
|
+
tok = re.compile(r"run\s+" + re.escape(target) + r"(?=\s|$|[\"'])")
|
|
775
879
|
try:
|
|
776
880
|
if sys.platform == "win32":
|
|
777
881
|
out = subprocess.run(
|
|
@@ -781,7 +885,7 @@ def _discover_agent_pids(target: str) -> list:
|
|
|
781
885
|
for line in out.splitlines():
|
|
782
886
|
line = line.strip()
|
|
783
887
|
if line.startswith("CommandLine="):
|
|
784
|
-
block_pid = ("meshcode" in line and
|
|
888
|
+
block_pid = ("meshcode" in line and bool(tok.search(line)))
|
|
785
889
|
elif line.startswith("ProcessId=") and block_pid:
|
|
786
890
|
try:
|
|
787
891
|
pids.append(int(line.split("=", 1)[1]))
|
|
@@ -794,14 +898,52 @@ def _discover_agent_pids(target: str) -> list:
|
|
|
794
898
|
capture_output=True, text=True, timeout=8).stdout
|
|
795
899
|
for ln in out.split():
|
|
796
900
|
try:
|
|
797
|
-
|
|
901
|
+
p = int(ln)
|
|
798
902
|
except Exception:
|
|
799
|
-
|
|
903
|
+
continue
|
|
904
|
+
# pgrep matched a substring; keep ONLY if the full cmdline has the exact token.
|
|
905
|
+
if tok.search(_pid_cmdline(p)):
|
|
906
|
+
pids.append(p)
|
|
800
907
|
except Exception:
|
|
801
908
|
pass
|
|
802
909
|
return pids
|
|
803
910
|
|
|
804
911
|
|
|
912
|
+
def _kill_heartbeat_fork(target: str) -> None:
|
|
913
|
+
"""Kill the agent's heartbeat daemon fork (Fix B, task 24e3dd44). The fork is detached into its
|
|
914
|
+
OWN session, so os.killpg(getpgid(agent_pid)) in _kill_headless_pid does NOT take it down — it
|
|
915
|
+
would keep POSTing and show a stopped agent 'online'. Stop it by its pidfile
|
|
916
|
+
heartbeat_<proj>_<name>.pid. Best-effort."""
|
|
917
|
+
try:
|
|
918
|
+
proj, _, agent = target.partition("/")
|
|
919
|
+
if not agent:
|
|
920
|
+
return
|
|
921
|
+
pidf = STATE_DIR / f"heartbeat_{proj}_{agent}.pid"
|
|
922
|
+
if not pidf.exists():
|
|
923
|
+
return
|
|
924
|
+
try:
|
|
925
|
+
hb = int(pidf.read_text(encoding="utf-8").strip())
|
|
926
|
+
except Exception:
|
|
927
|
+
hb = 0
|
|
928
|
+
if hb:
|
|
929
|
+
try:
|
|
930
|
+
if sys.platform == "win32":
|
|
931
|
+
subprocess.run(["taskkill", "/PID", str(hb), "/F"], capture_output=True, timeout=8)
|
|
932
|
+
else:
|
|
933
|
+
os.kill(hb, _signal.SIGTERM)
|
|
934
|
+
except ProcessLookupError:
|
|
935
|
+
pass
|
|
936
|
+
except Exception:
|
|
937
|
+
pass
|
|
938
|
+
try:
|
|
939
|
+
pidf.unlink()
|
|
940
|
+
except Exception:
|
|
941
|
+
pass
|
|
942
|
+
_log(f"STOP {target}: stopped heartbeat fork (pid {hb or '?'})")
|
|
943
|
+
except Exception:
|
|
944
|
+
pass
|
|
945
|
+
|
|
946
|
+
|
|
805
947
|
def _kill_headless_pid(target: str, pid: int) -> bool:
|
|
806
948
|
"""Hard-kill a recorded headless PID + its child tree. Guards against PID reuse by
|
|
807
949
|
confirming the cmdline still looks like this meshcode agent. Returns True if killed."""
|
|
@@ -836,6 +978,7 @@ def _kill_headless_pid(target: str, pid: int) -> bool:
|
|
|
836
978
|
except Exception:
|
|
837
979
|
pass
|
|
838
980
|
_log(f"STOP {target}: hard-killed headless pid {pid}")
|
|
981
|
+
_kill_heartbeat_fork(target) # Fix B: its detached heartbeat fork won't die with the killpg
|
|
839
982
|
return True
|
|
840
983
|
except Exception as e:
|
|
841
984
|
_log(f"WARN: STOP {target} kill pid {pid} failed: {e}")
|
|
@@ -911,8 +1054,11 @@ def _do_force_kills(api_key: str, host_id: str) -> int:
|
|
|
911
1054
|
if not pid:
|
|
912
1055
|
continue
|
|
913
1056
|
if _FORCE_KILL_DRYRUN:
|
|
1057
|
+
# SAFE-ARM logging: log the cmdline so the would-kill can be validated as a real
|
|
1058
|
+
# stuck visible agent before _FORCE_KILL_DRYRUN is flipped.
|
|
914
1059
|
_log(f"FORCE-KILL-DRYRUN {target}: WOULD kill visible pid {pid} (explicit human stop, "
|
|
915
|
-
f"hb {a.get('heartbeat_age_s')}s)
|
|
1060
|
+
f"hb {a.get('heartbeat_age_s')}s) cmdline={_pid_cmdline(pid).strip()[:100]!r} — "
|
|
1061
|
+
f"log-only. Flip _FORCE_KILL_DRYRUN=False to enforce.")
|
|
916
1062
|
elif _kill_headless_pid(target, pid):
|
|
917
1063
|
pids.pop(target, None)
|
|
918
1064
|
n += 1
|
|
@@ -968,8 +1114,11 @@ def _do_reap(api_key: str, host_id: str) -> int:
|
|
|
968
1114
|
"""LOG-ONLY while _REAP_DRYRUN; else hard-kill (reuse-guard inside). Returns 1 if killed."""
|
|
969
1115
|
nonlocal pids
|
|
970
1116
|
if _REAP_DRYRUN:
|
|
971
|
-
|
|
972
|
-
|
|
1117
|
+
# SAFE-ARM logging: include the cmdline of what we WOULD kill so it can be validated as a
|
|
1118
|
+
# true ghost/orphan — NOT a healthy agent — before flip.
|
|
1119
|
+
_log(f"REAP-DRYRUN {target}: WOULD kill pid {pid} ({reason}) "
|
|
1120
|
+
f"cmdline={_pid_cmdline(pid).strip()[:100]!r} — log-only, no kill. "
|
|
1121
|
+
f"Flip _REAP_DRYRUN=False ONLY after confirming zero false positives.")
|
|
973
1122
|
return 0
|
|
974
1123
|
if _kill_headless_pid(target, pid):
|
|
975
1124
|
if pids.get(target) == pid:
|
|
@@ -1054,6 +1203,9 @@ def _do_recycle_enforce(api_key: str, host_id: str) -> int:
|
|
|
1054
1203
|
agent (headless_pids, with _kill_headless_pid's reuse-guard) — NEVER blind cmdline. After the
|
|
1055
1204
|
kill it goes stale and the recycle FAST-PATH in _do_respawns relaunches it within seconds ->
|
|
1056
1205
|
SessionStart restores the handoff. Returns number force-killed."""
|
|
1206
|
+
# DEAD-FEATURE (task 222b1b02, Samuel 2026-06-04): RECYCLE disabled in prod — hard no-op
|
|
1207
|
+
# (no recycles are triggered, so there is nothing to enforce). Crash-RESPAWN unaffected.
|
|
1208
|
+
return 0
|
|
1057
1209
|
res = _rpc("mc_recycle_enforce_candidates", {"p_api_key": api_key, "p_host_id": host_id})
|
|
1058
1210
|
if not res or not res.get("ok"):
|
|
1059
1211
|
return 0
|
|
@@ -1099,6 +1251,11 @@ def _do_recycle_enforce(api_key: str, host_id: str) -> int:
|
|
|
1099
1251
|
|
|
1100
1252
|
def _do_recycles(api_key: str, host_id: str) -> int:
|
|
1101
1253
|
"""Uptime-based recycle at task boundary. Returns number recycled."""
|
|
1254
|
+
# DEAD-FEATURE (task 222b1b02, Samuel 2026-06-04): the RECYCLE feature is disabled in
|
|
1255
|
+
# prod (unreliable — kept causing version/env-mismatch storms). Hard no-op in source so
|
|
1256
|
+
# it stays dead even if a schedule row reappears. Crash-RESPAWN (_do_respawns) is
|
|
1257
|
+
# UNAFFECTED — only RECYCLE triggers are killed.
|
|
1258
|
+
return 0
|
|
1102
1259
|
cfg = _rpc("mc_host_config_get", {"p_api_key": api_key, "p_host_id": host_id})
|
|
1103
1260
|
if not cfg or not cfg.get("ok"):
|
|
1104
1261
|
return 0
|
|
@@ -1489,6 +1646,10 @@ def _do_version_recycles(api_key: str, host_id: str) -> int:
|
|
|
1489
1646
|
mid-task), rate-limited (<=1 version-recycle per agent / 30min), recycle-not-kill (clean handoff via
|
|
1490
1647
|
mc_request_recycle -> agent exits at its boundary -> _do_respawns relaunches on the new version).
|
|
1491
1648
|
Recorded via mc_record_recycle (NEVER counts against the mig406 crash respawn cap). Owner-scoped."""
|
|
1649
|
+
# DEAD-FEATURE (task 222b1b02, Samuel 2026-06-04): RECYCLE disabled in prod — hard no-op.
|
|
1650
|
+
# This was the env-mismatch storm source; fixed-at-source via run_agent env-sync, but the
|
|
1651
|
+
# whole recycle feature is being removed per owner. Crash-RESPAWN is unaffected.
|
|
1652
|
+
return 0
|
|
1492
1653
|
cfg = _rpc("mc_host_config_get", {"p_api_key": api_key, "p_host_id": host_id})
|
|
1493
1654
|
if not cfg or not cfg.get("ok"):
|
|
1494
1655
|
return 0
|
|
@@ -1513,8 +1674,10 @@ def _do_version_recycles(api_key: str, host_id: str) -> int:
|
|
|
1513
1674
|
continue # agent already on the on-disk version (or newer) — nothing to do
|
|
1514
1675
|
except Exception:
|
|
1515
1676
|
continue
|
|
1516
|
-
if (a
|
|
1517
|
-
continue #
|
|
1677
|
+
if _agent_connected(a):
|
|
1678
|
+
continue # Samuel rule: never version-recycle a CONNECTED agent (live MCP session,
|
|
1679
|
+
# even if idle/standby) — the >3h uptime lifecycle (_do_recycles) is the
|
|
1680
|
+
# only recycle that may touch a connected agent.
|
|
1518
1681
|
proj, agent = a.get("project_name"), a.get("name")
|
|
1519
1682
|
if not proj or not agent:
|
|
1520
1683
|
continue
|