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.
Files changed (101) hide show
  1. {meshcode-2.11.108 → meshcode-2.11.110rc1}/PKG-INFO +1 -1
  2. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/__init__.py +1 -1
  3. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/_stop_hook_template.py +8 -0
  4. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/comms_v4.py +33 -4
  5. meshcode-2.11.110rc1/meshcode/hooks/__init__.py +7 -0
  6. meshcode-2.11.110rc1/meshcode/hooks/repo_path_lock.py +156 -0
  7. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/hostd.py +177 -14
  8. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/server.py +54 -41
  9. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/protocol_handler.py +36 -6
  10. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/run_agent.py +131 -5
  11. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/self_update.py +50 -7
  12. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/setup_clients.py +26 -1
  13. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode.egg-info/PKG-INFO +1 -1
  14. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode.egg-info/SOURCES.txt +2 -0
  15. {meshcode-2.11.108 → meshcode-2.11.110rc1}/pyproject.toml +2 -1
  16. {meshcode-2.11.108 → meshcode-2.11.110rc1}/README.md +0 -0
  17. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/__main__.py +0 -0
  18. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/_session_handoff_template 2.py +0 -0
  19. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/_session_handoff_template 3.py +0 -0
  20. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/_session_handoff_template.py +0 -0
  21. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/ascii_art.py +0 -0
  22. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/atomic_push.py +0 -0
  23. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/claude_update 2.py +0 -0
  24. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/claude_update 3.py +0 -0
  25. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/claude_update.py +0 -0
  26. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/cli.py +0 -0
  27. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/compat.py +0 -0
  28. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/daemon.py +0 -0
  29. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/date_parse.py +0 -0
  30. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/doctor.py +0 -0
  31. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/error_hints.py +0 -0
  32. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/exceptions.py +0 -0
  33. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/hostd 2.py +0 -0
  34. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/invites.py +0 -0
  35. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/launcher.py +0 -0
  36. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/launcher_install.py +0 -0
  37. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/__init__.py +0 -0
  38. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/__main__.py +0 -0
  39. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/backend.py +0 -0
  40. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/realtime.py +0 -0
  41. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/sleep_signals.py +0 -0
  42. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/test_backend.py +0 -0
  43. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/test_boot_timing.py +0 -0
  44. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/test_install_guard.py +0 -0
  45. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/test_prefs_claude_version.py +0 -0
  46. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/test_realtime.py +0 -0
  47. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
  48. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/preferences.py +0 -0
  49. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/protocol_v2.py +0 -0
  50. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/quickstart.py +0 -0
  51. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/rpc_allowlist.py +0 -0
  52. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/scripts/check_secrets.py +0 -0
  53. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/scripts/race_rate_harness.py +0 -0
  54. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/secrets.py +0 -0
  55. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/supervisor.py +0 -0
  56. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/up 2.py +0 -0
  57. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/up.py +0 -0
  58. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode/upload.py +0 -0
  59. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode.egg-info/dependency_links.txt +0 -0
  60. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode.egg-info/entry_points.txt +0 -0
  61. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode.egg-info/requires.txt +0 -0
  62. {meshcode-2.11.108 → meshcode-2.11.110rc1}/meshcode.egg-info/top_level.txt +0 -0
  63. {meshcode-2.11.108 → meshcode-2.11.110rc1}/setup.cfg +0 -0
  64. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_auto_update_hardening.py +0 -0
  65. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_autonomous_closegap_1.py +0 -0
  66. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_autonomous_closegap_2.py +0 -0
  67. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_autonomous_closegap_3.py +0 -0
  68. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_autonomous_prompt_inject 2.py +0 -0
  69. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_autonomous_prompt_inject 3.py +0 -0
  70. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_autonomous_prompt_inject.py +0 -0
  71. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_boot_bug_regression.py +0 -0
  72. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_color_truecolor.py +0 -0
  73. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_core.py +0 -0
  74. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_cross_agent_messaging.py +0 -0
  75. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_date_parse.py +0 -0
  76. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_doctor.py +0 -0
  77. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_epistemic_v1_python_sdk.py +0 -0
  78. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_epistemic_v1_stop_conditions.py +0 -0
  79. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_esc_deaf_state.py +0 -0
  80. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_exceptions.py +0 -0
  81. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_file_upload.py +0 -0
  82. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_init_device_code.py +0 -0
  83. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_install_guard.py +0 -0
  84. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_lease_sigterm_release.py +0 -0
  85. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_mark_read_batch.py +0 -0
  86. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_marketplace_ratings.py +0 -0
  87. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_migration_integrity.py +0 -0
  88. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_realtime_event_freshness.py +0 -0
  89. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_rls_cross_tenant.py +0 -0
  90. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_rpc_grants.py +0 -0
  91. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_rpc_migrations.py +0 -0
  92. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_run_agent_dry_run.py +0 -0
  93. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_run_agent_no_server_import.py +0 -0
  94. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_security_regressions.py +0 -0
  95. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_self_update_user_site.py +0 -0
  96. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_sentinel.py +0 -0
  97. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_setup_path.py +0 -0
  98. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_sleep_signals.py +0 -0
  99. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_status_enum_coverage.py +0 -0
  100. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_stay_on_loop_hook.py +0 -0
  101. {meshcode-2.11.108 → meshcode-2.11.110rc1}/tests/test_wait_open_tasks_contradiction.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 2.11.108
3
+ Version: 2.11.110rc1
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.11.108"
2
+ __version__ = "2.11.110rc1"
3
3
 
4
4
  # Exception hierarchy — eagerly imported (lightweight, no deps)
5
5
  from meshcode.exceptions import ( # noqa: F401
@@ -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
- hb_pid = _start_heartbeat_daemon(project, name)
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 _spawn_agent(project: str, agent: str, headless: bool = False) -> bool:
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
- proc = subprocess.Popen([sys.executable, "-m", "meshcode", "run", target], **kwargs)
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 f"run {target}" in line)
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
- pids.append(int(ln))
901
+ p = int(ln)
798
902
  except Exception:
799
- pass
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) — log-only. Flip _FORCE_KILL_DRYRUN=False to enforce.")
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
- _log(f"REAP-DRYRUN {target}: WOULD kill pid {pid} ({reason}) log-only, no kill. "
972
- f"Flip _REAP_DRYRUN=False after confirming zero false positives.")
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.get("status") or "") in BUSY_STATUSES:
1517
- continue # safe-point ONLY never recycle a working agent mid-task
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