meshcode 2.11.109__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.109 → meshcode-2.11.110rc1}/PKG-INFO +1 -1
  2. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/__init__.py +1 -1
  3. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/_stop_hook_template.py +8 -0
  4. {meshcode-2.11.109 → 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.109 → meshcode-2.11.110rc1}/meshcode/hostd.py +92 -12
  8. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/server.py +43 -23
  9. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/protocol_handler.py +36 -6
  10. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/run_agent.py +115 -5
  11. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/setup_clients.py +26 -1
  12. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode.egg-info/PKG-INFO +1 -1
  13. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode.egg-info/SOURCES.txt +2 -0
  14. {meshcode-2.11.109 → meshcode-2.11.110rc1}/pyproject.toml +2 -1
  15. {meshcode-2.11.109 → meshcode-2.11.110rc1}/README.md +0 -0
  16. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/__main__.py +0 -0
  17. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/_session_handoff_template 2.py +0 -0
  18. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/_session_handoff_template 3.py +0 -0
  19. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/_session_handoff_template.py +0 -0
  20. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/ascii_art.py +0 -0
  21. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/atomic_push.py +0 -0
  22. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/claude_update 2.py +0 -0
  23. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/claude_update 3.py +0 -0
  24. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/claude_update.py +0 -0
  25. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/cli.py +0 -0
  26. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/compat.py +0 -0
  27. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/daemon.py +0 -0
  28. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/date_parse.py +0 -0
  29. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/doctor.py +0 -0
  30. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/error_hints.py +0 -0
  31. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/exceptions.py +0 -0
  32. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/hostd 2.py +0 -0
  33. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/invites.py +0 -0
  34. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/launcher.py +0 -0
  35. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/launcher_install.py +0 -0
  36. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/__init__.py +0 -0
  37. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/__main__.py +0 -0
  38. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/backend.py +0 -0
  39. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/realtime.py +0 -0
  40. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/sleep_signals.py +0 -0
  41. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/test_backend.py +0 -0
  42. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/test_boot_timing.py +0 -0
  43. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/test_install_guard.py +0 -0
  44. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/test_prefs_claude_version.py +0 -0
  45. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/test_realtime.py +0 -0
  46. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
  47. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/preferences.py +0 -0
  48. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/protocol_v2.py +0 -0
  49. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/quickstart.py +0 -0
  50. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/rpc_allowlist.py +0 -0
  51. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/scripts/check_secrets.py +0 -0
  52. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/scripts/race_rate_harness.py +0 -0
  53. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/secrets.py +0 -0
  54. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/self_update.py +0 -0
  55. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/supervisor.py +0 -0
  56. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/up 2.py +0 -0
  57. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/up.py +0 -0
  58. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode/upload.py +0 -0
  59. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode.egg-info/dependency_links.txt +0 -0
  60. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode.egg-info/entry_points.txt +0 -0
  61. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode.egg-info/requires.txt +0 -0
  62. {meshcode-2.11.109 → meshcode-2.11.110rc1}/meshcode.egg-info/top_level.txt +0 -0
  63. {meshcode-2.11.109 → meshcode-2.11.110rc1}/setup.cfg +0 -0
  64. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_auto_update_hardening.py +0 -0
  65. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_autonomous_closegap_1.py +0 -0
  66. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_autonomous_closegap_2.py +0 -0
  67. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_autonomous_closegap_3.py +0 -0
  68. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_autonomous_prompt_inject 2.py +0 -0
  69. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_autonomous_prompt_inject 3.py +0 -0
  70. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_autonomous_prompt_inject.py +0 -0
  71. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_boot_bug_regression.py +0 -0
  72. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_color_truecolor.py +0 -0
  73. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_core.py +0 -0
  74. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_cross_agent_messaging.py +0 -0
  75. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_date_parse.py +0 -0
  76. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_doctor.py +0 -0
  77. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_epistemic_v1_python_sdk.py +0 -0
  78. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_epistemic_v1_stop_conditions.py +0 -0
  79. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_esc_deaf_state.py +0 -0
  80. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_exceptions.py +0 -0
  81. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_file_upload.py +0 -0
  82. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_init_device_code.py +0 -0
  83. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_install_guard.py +0 -0
  84. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_lease_sigterm_release.py +0 -0
  85. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_mark_read_batch.py +0 -0
  86. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_marketplace_ratings.py +0 -0
  87. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_migration_integrity.py +0 -0
  88. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_realtime_event_freshness.py +0 -0
  89. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_rls_cross_tenant.py +0 -0
  90. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_rpc_grants.py +0 -0
  91. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_rpc_migrations.py +0 -0
  92. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_run_agent_dry_run.py +0 -0
  93. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_run_agent_no_server_import.py +0 -0
  94. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_security_regressions.py +0 -0
  95. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_self_update_user_site.py +0 -0
  96. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_sentinel.py +0 -0
  97. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_setup_path.py +0 -0
  98. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_sleep_signals.py +0 -0
  99. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_status_enum_coverage.py +0 -0
  100. {meshcode-2.11.109 → meshcode-2.11.110rc1}/tests/test_stay_on_loop_hook.py +0 -0
  101. {meshcode-2.11.109 → 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.109
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.109"
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)
@@ -567,7 +596,7 @@ def _do_respawns(api_key: str, host_id: str) -> int:
567
596
  _old_vis_pids = _discover_agent_pids(_target) if (_is_recycle and _visible) else []
568
597
  _log(f"{'RECYCLE-RESPAWN' if _is_recycle else 'RESPAWN'} {proj}/{agent} "
569
598
  f"({'VISIBLE ' if _visible else ''}stale {c.get('heartbeat_age_s')}s, count={c.get('respawn_count')})")
570
- if _spawn_agent(proj, agent, headless=_hl):
599
+ if _spawn_agent(proj, agent, headless=_hl, repo_path=c.get("repo_path")):
571
600
  _record_spawn(_target) # count the terminal we just opened, against the breaker
572
601
  if _is_recycle:
573
602
  if _visible and _old_vis_pids:
@@ -839,8 +868,14 @@ def _pid_cmdline(pid: int) -> str:
839
868
  def _discover_agent_pids(target: str) -> list:
840
869
  """Fallback PID discovery by command line, for agents spawned before this hostd
841
870
  (no recorded PID) or after a state-file loss. Matches `meshcode run <target>`.
842
- 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."""
843
877
  pids = []
878
+ tok = re.compile(r"run\s+" + re.escape(target) + r"(?=\s|$|[\"'])")
844
879
  try:
845
880
  if sys.platform == "win32":
846
881
  out = subprocess.run(
@@ -850,7 +885,7 @@ def _discover_agent_pids(target: str) -> list:
850
885
  for line in out.splitlines():
851
886
  line = line.strip()
852
887
  if line.startswith("CommandLine="):
853
- block_pid = ("meshcode" in line and f"run {target}" in line)
888
+ block_pid = ("meshcode" in line and bool(tok.search(line)))
854
889
  elif line.startswith("ProcessId=") and block_pid:
855
890
  try:
856
891
  pids.append(int(line.split("=", 1)[1]))
@@ -863,14 +898,52 @@ def _discover_agent_pids(target: str) -> list:
863
898
  capture_output=True, text=True, timeout=8).stdout
864
899
  for ln in out.split():
865
900
  try:
866
- pids.append(int(ln))
901
+ p = int(ln)
867
902
  except Exception:
868
- 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)
869
907
  except Exception:
870
908
  pass
871
909
  return pids
872
910
 
873
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
+
874
947
  def _kill_headless_pid(target: str, pid: int) -> bool:
875
948
  """Hard-kill a recorded headless PID + its child tree. Guards against PID reuse by
876
949
  confirming the cmdline still looks like this meshcode agent. Returns True if killed."""
@@ -905,6 +978,7 @@ def _kill_headless_pid(target: str, pid: int) -> bool:
905
978
  except Exception:
906
979
  pass
907
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
908
982
  return True
909
983
  except Exception as e:
910
984
  _log(f"WARN: STOP {target} kill pid {pid} failed: {e}")
@@ -980,8 +1054,11 @@ def _do_force_kills(api_key: str, host_id: str) -> int:
980
1054
  if not pid:
981
1055
  continue
982
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.
983
1059
  _log(f"FORCE-KILL-DRYRUN {target}: WOULD kill visible pid {pid} (explicit human stop, "
984
- 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.")
985
1062
  elif _kill_headless_pid(target, pid):
986
1063
  pids.pop(target, None)
987
1064
  n += 1
@@ -1037,8 +1114,11 @@ def _do_reap(api_key: str, host_id: str) -> int:
1037
1114
  """LOG-ONLY while _REAP_DRYRUN; else hard-kill (reuse-guard inside). Returns 1 if killed."""
1038
1115
  nonlocal pids
1039
1116
  if _REAP_DRYRUN:
1040
- _log(f"REAP-DRYRUN {target}: WOULD kill pid {pid} ({reason}) log-only, no kill. "
1041
- 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.")
1042
1122
  return 0
1043
1123
  if _kill_headless_pid(target, pid):
1044
1124
  if pids.get(target) == pid:
@@ -4324,31 +4324,51 @@ def _mark_realtime_msgs_read_in_db(messages: List[Dict[str, Any]]) -> None:
4324
4324
  if not api_key:
4325
4325
  return
4326
4326
 
4327
- # Try batch first (1 RPC for N messages = ~50ms total)
4328
- try:
4329
- result = be.sb_rpc("mc_mark_messages_read_batch", {
4330
- "p_api_key": api_key,
4331
- "p_project_id": _PROJECT_ID,
4332
- "p_message_ids": msg_ids,
4333
- })
4334
- if result and not be.is_error(result):
4335
- log.debug(f"batch mark_read: {result.get('marked', 0)}/{len(msg_ids)} marked")
4336
- return
4337
- # Batch RPC returned error — fall through to individual
4338
- log.debug(f"batch mark_read failed: {be.get_error_message(result)}, falling back to individual")
4339
- except Exception as e:
4340
- log.debug(f"batch mark_read unavailable ({e}), falling back to individual")
4327
+ # RE-DELIVERY FIX (2.11.110, task 01465452 / Samuel+commander 2026-06-04):
4328
+ # mark_read IS the read-watermark. If it silently fails under DB load, the
4329
+ # messages stay read=false and meshcode_wait RE-DELIVERS them next cycle
4330
+ # (commander hit this 3-4x this session). Retry transient failures with a
4331
+ # short backoff; SURFACE (WARNING) any IDs still unmarked instead of
4332
+ # swallowing at debug — "exactly-once" delivery depends on this completing.
4333
+ def _retry_rpc(fn, attempts=3, base=0.2):
4334
+ last = None
4335
+ for i in range(attempts):
4336
+ try:
4337
+ r = fn()
4338
+ if not (isinstance(r, dict) and be.is_error(r)):
4339
+ return r, None
4340
+ last = be.get_error_message(r)
4341
+ except Exception as e:
4342
+ last = str(e)
4343
+ if i < attempts - 1:
4344
+ _time.sleep(base * (i + 1)) # 0.2s, 0.4s — transient timeout/contention
4345
+ return None, last
4346
+
4347
+ # Try batch first (1 RPC for N messages), retried on transient failure.
4348
+ result, err = _retry_rpc(lambda: be.sb_rpc("mc_mark_messages_read_batch", {
4349
+ "p_api_key": api_key,
4350
+ "p_project_id": _PROJECT_ID,
4351
+ "p_message_ids": msg_ids,
4352
+ }))
4353
+ if result is not None:
4354
+ log.debug(f"batch mark_read: {result.get('marked', 0)}/{len(msg_ids)} marked")
4355
+ return
4356
+ log.debug(f"batch mark_read failed after retries ({err}); falling back to individual")
4341
4357
 
4342
- # Fallback: individual RPCs (N RPCs = ~N*50ms)
4358
+ # Fallback: individual RPCs, each retried. Track stragglers so a persistent
4359
+ # failure is VISIBLE (re-delivery is the lesser evil vs silent message loss).
4360
+ unmarked = []
4343
4361
  for mid in msg_ids:
4344
- try:
4345
- be.sb_rpc("mc_mark_message_read", {
4346
- "p_api_key": api_key,
4347
- "p_project_id": _PROJECT_ID,
4348
- "p_message_id": mid,
4349
- })
4350
- except Exception as e:
4351
- log.debug(f"mark_read failed for msg {mid}: {e}")
4362
+ _r, _e = _retry_rpc(lambda mid=mid: be.sb_rpc("mc_mark_message_read", {
4363
+ "p_api_key": api_key,
4364
+ "p_project_id": _PROJECT_ID,
4365
+ "p_message_id": mid,
4366
+ }), attempts=2)
4367
+ if _r is None:
4368
+ unmarked.append(mid)
4369
+ if unmarked:
4370
+ log.warning(f"mark_read: {len(unmarked)}/{len(msg_ids)} msgs UNMARKED after retries "
4371
+ f"(will re-deliver) — likely DB-load timeout: {unmarked[:5]}")
4352
4372
 
4353
4373
  # Confirm delivery (best-effort, background) — completes the
4354
4374
  # delivery pipeline: sent → read → confirmed.
@@ -257,8 +257,14 @@ def _spawn_terminal(cmd: str) -> tuple[bool, str]:
257
257
  # ============================================================
258
258
  # launch-batch
259
259
  # ============================================================
260
- def cmd_launch_batch(agent_names: Iterable[str]) -> int:
261
- """meshcode launch-batch <agent_names...> — spawn one terminal each.
260
+ def cmd_launch_batch(agent_names: Iterable[str], repo_path: Optional[str] = None) -> int:
261
+ """meshcode launch-batch <agent_names...> [--repo <path>] — spawn one terminal each.
262
+
263
+ repo_path (optional, core-commander launch-diff 2.11.110): if set, each agent
264
+ launches repo-scoped (`meshcode run <name> --repo <path>` → cwd=repo + repo-lock
265
+ hooks). Validated AT THIS SINK (realpath + isdir + reject '"'/control chars)
266
+ BEFORE shell/cmd.exe interpolation, since repo_path is USER input. Empty/None
267
+ => unscoped (100% legacy behavior).
262
268
 
263
269
  Returns 0 on full success, 1 if any agent failed to launch (still
264
270
  prints JSON summary to stdout).
@@ -275,6 +281,18 @@ def cmd_launch_batch(agent_names: Iterable[str]) -> int:
275
281
  # Resolve `meshcode` binary path (CLI wrapper installed by pip).
276
282
  mc_bin = shutil.which("meshcode") or "meshcode"
277
283
 
284
+ # SECURITY (audit P1-4): repo_path is USER input that gets interpolated into the
285
+ # platform launch string below. Validate AT THE SINK: canonicalize, require a
286
+ # real dir, reject the double-quote + control chars that could break out of the
287
+ # cmd.exe / shell quoting. None/empty => rp=None => unscoped (legacy behavior).
288
+ rp: Optional[str] = None
289
+ if repo_path:
290
+ rp = os.path.realpath(os.path.expanduser(repo_path))
291
+ if (not os.path.isdir(rp)) or ('"' in rp) or any(ord(c) < 32 for c in rp):
292
+ print(json.dumps({"ok": False, "error": f"invalid repo path: {repo_path!r}",
293
+ "error_code": "invalid_repo_path"}))
294
+ return 1
295
+
278
296
  # RATE LIMIT: hard-cap the batch so a crafted `agents=` list can't spawn an
279
297
  # unbounded number of terminals (DoS). Excess is reported, never launched.
280
298
  if len(names) > _MAX_BATCH:
@@ -309,9 +327,9 @@ def cmd_launch_batch(agent_names: Iterable[str]) -> int:
309
327
  # PER-PLATFORM quoting (mesh-core FIX2): cmd.exe wants double-quotes, not POSIX shlex
310
328
  # single-quotes (cmd.exe passes single-quotes through literally -> file-not-found).
311
329
  if sys.platform == "win32":
312
- cmd = f'"{mc_bin}" run "{name}"'
330
+ cmd = f'"{mc_bin}" run "{name}"' + (f' --repo "{rp}"' if rp else "")
313
331
  else:
314
- cmd = f"{shlex.quote(mc_bin)} run {shlex.quote(name)}"
332
+ cmd = f"{shlex.quote(mc_bin)} run {shlex.quote(name)}" + (f" --repo {shlex.quote(rp)}" if rp else "")
315
333
  ok, info = _spawn_terminal(cmd)
316
334
  if ok:
317
335
  launched.append(name)
@@ -404,7 +422,12 @@ def cmd_launch_url(url: str) -> int:
404
422
  "error_code": "invalid_input"}))
405
423
  return 1
406
424
 
407
- return cmd_launch_batch(agents)
425
+ # core-commander launch-diff: optional repo= scopes every launched agent to a
426
+ # repo (cwd=repo + repo-lock hooks). parse_qs ALREADY URL-decodes the value, so
427
+ # no extra unquote here (double-decode would corrupt a path with a literal '%').
428
+ # Path is validated at the sink in cmd_launch_batch.
429
+ repo = qs.get("repo", [""])[0].strip()
430
+ return cmd_launch_batch(agents, repo_path=repo or None)
408
431
 
409
432
 
410
433
  # ============================================================
@@ -543,7 +566,14 @@ def main(argv: list[str]) -> int:
543
566
  sub = argv[0]
544
567
  rest = argv[1:]
545
568
  if sub == "launch-batch":
546
- return cmd_launch_batch(rest)
569
+ # Optional `--repo <path>` (core-commander launch-diff): pull it out of the
570
+ # positional agent-name list; remaining tokens are agent names.
571
+ _repo = None
572
+ if "--repo" in rest:
573
+ _i = rest.index("--repo")
574
+ _repo = rest[_i + 1] if _i + 1 < len(rest) else None
575
+ rest = rest[:_i] + rest[_i + 2:]
576
+ return cmd_launch_batch(rest, repo_path=_repo)
547
577
  if sub == "launch-url":
548
578
  if not rest:
549
579
  print(json.dumps({"ok": False, "error": "url required",
@@ -679,7 +679,73 @@ def _preflight_heartbeat(agent: str, project: str) -> None:
679
679
  print(f"[meshcode] Pre-flight heartbeat skipped: {e}", file=sys.stderr)
680
680
 
681
681
 
682
- def run(agent: str, project: Optional[str] = None, editor_override: Optional[str] = None, permission_override: Optional[str] = None, dry_run: bool = False, autonomous: bool = False) -> int:
682
+ # Repo-scoped launch (task 24e3dd44 / core-commander launch-diff). When `meshcode run
683
+ # <agent> --repo <path>` is used, the agent boots with cwd=repo (not the meshcode
684
+ # workspace), so its repo CLAUDE.md loads — we carry the boot protocol via
685
+ # --append-system-prompt so the mesh loop + rules still apply. .format(agent, project,
686
+ # role, repo) at launch.
687
+ MESHCODE_BOOT_PROTOCOL = """You are agent {agent} in MeshCode meshwork {project}. Role: {role}.
688
+ You are running INSIDE the repo at {repo} (your cwd) — operate here like a normal terminal opened in this repo: read its CLAUDE.md + local memory, work here, and do NOT leave it (a hard repo-path lock denies file ops outside it).
689
+
690
+ ON SESSION START (run NOW, don't idle/greet): if meshcode_* tools are deferred, FIRST call ToolSearch(query="select:meshcode_set_status,meshcode_boot,meshcode_wait,meshcode_send,meshcode_task_complete"). Then 1) meshcode_set_status(status="online",task="ready"); 2) meshcode_boot(); 3) meshcode_wait().
691
+
692
+ PERMANENT LOOP (#1 rule): after boot and after EVERY action, your next tool call MUST be meshcode_wait(). act -> (optional meshcode_send) -> meshcode_wait() -> repeat. NEVER exit/stop without calling meshcode_wait(). Only exits: user says stop/sleep/exit, commander got_done/sleep auth, or fatal error. If wait times out, re-call with 2x timeout (cap 1800s).
693
+
694
+ RULES: MCP tools only (don't shell out to the meshcode CLI). Tasks > messages; messages <100 tokens (signals), no empty acks, JSON, thread via in_reply_to. Close tasks immediately when done (meshcode_task_complete); work assigned tasks immediately. Sync vs async: turn-based/shared-state -> meshcode_call; informing -> meshcode_send. meshcode_remember(key,value) for reusable learnings.
695
+
696
+ ALWAYS USE SKILLS + SUBAGENTS (mandatory): invoke the Skill tool whenever any skill matches (even 1%) BEFORE acting; dispatch subagents for multi-file search/audits. Samuel flagged skipping skills/subagents as underperforming — hard rule."""
697
+
698
+
699
+ def ensure_repo_lock_hook_installed():
700
+ """FALLBACK (user-scope) install of the canonical repo-path lock as a PreToolUse hook in
701
+ ~/.claude/settings.json. SELF-GATED on MESHCODE_REPO_LOCK (the hook no-ops when unset), so
702
+ installing it globally is safe. Idempotent — never duplicates an existing identical command."""
703
+ try:
704
+ import importlib.resources
705
+ import json as _json
706
+ home = Path(os.path.expanduser("~"))
707
+ hd = home / ".claude" / "hooks"
708
+ hd.mkdir(parents=True, exist_ok=True)
709
+ dst = hd / "repo-path-lock.py"
710
+ dst.write_text((importlib.resources.files("meshcode.hooks") / "repo_path_lock.py").read_text("utf-8"), "utf-8")
711
+ py = sys.executable or "python3"
712
+ settings = home / ".claude" / "settings.json"
713
+ doc = _json.loads(settings.read_text("utf-8")) if settings.exists() else {}
714
+ pre = doc.setdefault("hooks", {}).setdefault("PreToolUse", [])
715
+ cmd = f'"{py}" "{dst}"'
716
+ matcher = "Read|Edit|Write|MultiEdit|NotebookEdit|Glob|Grep|LS|Bash"
717
+ if not any(any(h.get("command") == cmd for h in e.get("hooks", [])) for e in pre):
718
+ pre.append({"matcher": matcher, "hooks": [{"type": "command", "command": cmd}]})
719
+ settings.write_text(_json.dumps(doc, indent=2), "utf-8")
720
+ except Exception:
721
+ pass
722
+
723
+
724
+ def ensure_loop_hook_installed():
725
+ """FALLBACK (user-scope) install of stay_on_loop as a Stop hook in ~/.claude/settings.json.
726
+ SAFE globally because STOP_HOOK_BODY now no-ops unless MESHCODE_AGENT_SESSION is set (a human's
727
+ personal claude session never gets trapped). Reuses the canonical STOP_HOOK_BODY. Idempotent."""
728
+ try:
729
+ from meshcode._stop_hook_template import STOP_HOOK_BODY
730
+ import json as _json
731
+ home = Path(os.path.expanduser("~"))
732
+ hd = home / ".claude" / "hooks"
733
+ hd.mkdir(parents=True, exist_ok=True)
734
+ dst = hd / "stay_on_loop.py"
735
+ dst.write_text(STOP_HOOK_BODY, "utf-8")
736
+ py = sys.executable or "python3"
737
+ settings = home / ".claude" / "settings.json"
738
+ doc = _json.loads(settings.read_text("utf-8")) if settings.exists() else {}
739
+ stop = doc.setdefault("hooks", {}).setdefault("Stop", [])
740
+ cmd = f'"{py}" "{dst}"'
741
+ if not any(any(h.get("command") == cmd for h in e.get("hooks", [])) for e in stop):
742
+ stop.append({"hooks": [{"type": "command", "command": cmd}]})
743
+ settings.write_text(_json.dumps(doc, indent=2), "utf-8")
744
+ except Exception:
745
+ pass
746
+
747
+
748
+ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str] = None, permission_override: Optional[str] = None, dry_run: bool = False, autonomous: bool = False, repo_path: Optional[str] = None) -> int:
683
749
  """Launch the user's editor with ONLY the named agent's MCP server loaded.
684
750
 
685
751
  dry_run: when True, exercise the full bootstrap codepath (auth check,
@@ -836,6 +902,33 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
836
902
  except Exception:
837
903
  pass
838
904
 
905
+ # ── Repo-scoped launch + mesh-session hook wiring (task 24e3dd44 / core-commander) ─
906
+ # MESHCODE_AGENT_SESSION marks THIS process as a mesh agent launch — it gates the
907
+ # user-scope stay_on_loop fallback so the loop is enforced for agents but a human's
908
+ # personal `claude` session is NEVER trapped. Exported for EVERY launch (not only
909
+ # repo-scoped) so loop-survival is unconditional.
910
+ os.environ["MESHCODE_AGENT_SESSION"] = "1"
911
+ repo_lock = None
912
+ if repo_path:
913
+ _rp = os.path.realpath(os.path.expanduser(repo_path))
914
+ if (not os.path.isdir(_rp)) or ('"' in _rp) or any(ord(c) < 32 for c in _rp):
915
+ print(json.dumps({"ok": False, "error": f"invalid --repo path: {repo_path!r}",
916
+ "error_code": "invalid_repo_path"}), file=sys.stderr)
917
+ return 2
918
+ repo_lock = _rp
919
+ os.environ["MESHCODE_REPO_LOCK"] = repo_lock
920
+ os.environ.setdefault("MESHCODE_REPO_LOCK_BASH", "1")
921
+ os.environ["MESHCODE_REPO_LOCK_ALLOW"] = os.pathsep.join(
922
+ [str(ws), os.path.expanduser("~/.claude/projects")])
923
+ print(f"[meshcode] Repo-lock: {repo_lock} (file ops outside it are denied)", file=sys.stderr)
924
+ # Pre-stage BOTH user-scope fallback hooks (idempotent). repo-lock self-gates on
925
+ # MESHCODE_REPO_LOCK; loop gates on MESHCODE_AGENT_SESSION — so this is safe even when
926
+ # --settings already carries the workspace hooks (the primary path). One boot test
927
+ # reveals which path fires; both are wired so no recut is needed.
928
+ if not dry_run:
929
+ ensure_repo_lock_hook_installed()
930
+ ensure_loop_hook_installed()
931
+
839
932
  # ── Validate stop hook exists (required for wait-loop integrity) ─
840
933
  # If the workspace was created on an old CLI that didn't install hooks
841
934
  # (or someone wiped .claude/), Claude Code has nothing blocking turn-end
@@ -929,6 +1022,9 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
929
1022
  # color synchronously, so the banner gets the real color without any
930
1023
  # cross-process coordination.
931
1024
 
1025
+ # Default so a banner-build failure below can't leave agent_role undefined for the
1026
+ # repo-scoped --append-system-prompt .format() further down (task 24e3dd44).
1027
+ agent_role = "MCP-connected agent"
932
1028
  # ── Welcome banner with unique ASCII art + personality ──────────
933
1029
  try:
934
1030
  from .ascii_art import generate_art, render_welcome
@@ -1032,13 +1128,27 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
1032
1128
  # instructions so the agent auto-initializes (set_status, check
1033
1129
  # messages, claim tasks, greet peers) without waiting for user input.
1034
1130
  # Uses -- to separate options from the positional prompt argument.
1131
+ # Repo-scoped launch (task 24e3dd44 / core-commander): when --repo is set the
1132
+ # session runs at cwd=repo (NOT the workspace), so explicitly load the workspace
1133
+ # .mcp.json + .claude/settings.json (Stop + repo-lock hooks) via flags, and carry
1134
+ # the mesh boot protocol via --append-system-prompt (the repo's own CLAUDE.md
1135
+ # loads from cwd). --repo unset => none of this added => behavior 100% unchanged.
1136
+ if repo_lock:
1137
+ cmd += [
1138
+ "--mcp-config", str(mcp_json_path),
1139
+ "--settings", str(ws / ".claude" / "settings.json"),
1140
+ "--append-system-prompt", MESHCODE_BOOT_PROTOCOL.format(
1141
+ agent=agent, project=resolved_project,
1142
+ role=(agent_role or "MCP-connected agent"), repo=repo_lock),
1143
+ ]
1035
1144
  cmd.extend(["--", "boot"])
1036
- if not os.path.isdir(ws):
1037
- print(f"[meshcode] WARNING: workspace dir does not exist: {ws}", file=sys.stderr)
1145
+ _launch_cwd = repo_lock or str(ws)
1146
+ if not os.path.isdir(_launch_cwd):
1147
+ print(f"[meshcode] WARNING: launch dir does not exist: {_launch_cwd}", file=sys.stderr)
1038
1148
  try:
1039
- os.chdir(ws)
1149
+ os.chdir(_launch_cwd)
1040
1150
  except Exception as e:
1041
- print(f"[meshcode] WARNING: chdir to workspace failed: {e}", file=sys.stderr)
1151
+ print(f"[meshcode] WARNING: chdir to {_launch_cwd} failed: {e}", file=sys.stderr)
1042
1152
  elif editor_stem == "cursor":
1043
1153
  # Cursor reads .cursor/mcp.json from the workspace cwd.
1044
1154
  cmd = [editor, str(ws)]
@@ -24,6 +24,15 @@ from typing import Dict, Any, Optional
24
24
 
25
25
  from meshcode.exceptions import AuthError, RPCError, MeshCodeConnectionError
26
26
  from meshcode._stop_hook_template import STOP_HOOK_BODY as _STOP_HOOK_BODY
27
+ # Repo-path lock hook body (task 24e3dd44) — read from the packaged canonical hook so the
28
+ # workspace copy + the user-scope fallback (run_agent.ensure_repo_lock_hook_installed) stay
29
+ # byte-identical. Best-effort: empty -> workspace just won't get the primary-path PreToolUse
30
+ # hook (the user-scope fallback still installs it; an empty hook would fail-open anyway).
31
+ try:
32
+ import importlib.resources as _ilr
33
+ _REPO_LOCK_BODY = (_ilr.files("meshcode.hooks") / "repo_path_lock.py").read_text("utf-8")
34
+ except Exception:
35
+ _REPO_LOCK_BODY = ""
27
36
  from meshcode._session_handoff_template import (
28
37
  HANDOFF_WRITE_BODY as _HANDOFF_WRITE_BODY,
29
38
  HANDOFF_READ_BODY as _HANDOFF_READ_BODY,
@@ -345,12 +354,23 @@ def _meshcode_settings_dict() -> dict:
345
354
  """Canonical .claude/settings.json content (Stop + PreCompact +
346
355
  SessionStart hooks, all Windows-safe). Single source used by both the
347
356
  scaffolder and the patch-hooks fresh-create path."""
348
- return {
357
+ d = {
349
358
  "hooks": {
350
359
  event: [{"hooks": [{"type": "command", "command": _hook_command(script)}]}]
351
360
  for event, script in _MESHCODE_HOOKS
352
361
  },
353
362
  }
363
+ # Repo-path lock (task 24e3dd44 / core-commander): PreToolUse needs a MATCHER (the other
364
+ # meshcode hooks are matcher-less), so it's added explicitly rather than via _MESHCODE_HOOKS.
365
+ # The hook self-gates on MESHCODE_REPO_LOCK -> inert for non-repo-scoped sessions, so adding
366
+ # it to every workspace's settings.json is safe. run_agent passes --settings <ws>/.claude/
367
+ # settings.json on a repo-scoped launch so this Stop+PreToolUse pair loads into the cwd=repo
368
+ # session (the PRIMARY path; the user-scope install is the fallback).
369
+ d["hooks"]["PreToolUse"] = [{
370
+ "matcher": "Read|Edit|Write|MultiEdit|NotebookEdit|Glob|Grep|LS|Bash",
371
+ "hooks": [{"type": "command", "command": _hook_command("repo_path_lock.py")}],
372
+ }]
373
+ return d
354
374
 
355
375
 
356
376
  def _heal_settings_hooks(ws: Path, dry_run: bool = False):
@@ -1354,6 +1374,10 @@ If `meshcode_wait()` times out, call it again with a 2× longer timeout (cap 180
1354
1374
  - `sensitive=True` for secrets / PII.
1355
1375
  - Memory: `meshcode_remember(key, value)` for reusable learnings. Don't dump
1356
1376
  task summaries into memory — tasks already persist.
1377
+ - **ALWAYS USE SKILLS + SUBAGENTS (mandatory):** invoke the Skill tool whenever
1378
+ any skill matches (even 1%) BEFORE acting; dispatch subagents for multi-file
1379
+ search/audits. Samuel flagged skipping skills/subagents as underperforming —
1380
+ hard rule.
1357
1381
 
1358
1382
  ## Mobile-first UI verification (before task_complete)
1359
1383
 
@@ -1527,6 +1551,7 @@ Call `meshcode_wait` now.
1527
1551
  ("stay_on_loop.py", stop_hook_body),
1528
1552
  ("session_handoff_write.py", _HANDOFF_WRITE_BODY),
1529
1553
  ("session_handoff_read.py", _HANDOFF_READ_BODY),
1554
+ ("repo_path_lock.py", _REPO_LOCK_BODY), # task 24e3dd44: primary-path repo-lock (PreToolUse)
1530
1555
  ):
1531
1556
  _hp = ws / ".claude" / "hooks" / _name
1532
1557
  _hp.write_text(_body, encoding="utf-8")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 2.11.109
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
@@ -43,6 +43,8 @@ meshcode.egg-info/dependency_links.txt
43
43
  meshcode.egg-info/entry_points.txt
44
44
  meshcode.egg-info/requires.txt
45
45
  meshcode.egg-info/top_level.txt
46
+ meshcode/hooks/__init__.py
47
+ meshcode/hooks/repo_path_lock.py
46
48
  meshcode/meshcode_mcp/__init__.py
47
49
  meshcode/meshcode_mcp/__main__.py
48
50
  meshcode/meshcode_mcp/backend.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "meshcode"
7
- version = "2.11.109"
7
+ version = "2.11.110rc1"
8
8
  description = "Real-time communication between AI agents — Supabase-backed CLI"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -51,3 +51,4 @@ include = ["meshcode", "meshcode.*"]
51
51
 
52
52
  [tool.setuptools.package-data]
53
53
  meshcode = ["comms_v4.py"]
54
+ "meshcode.hooks" = ["*.py"]
File without changes
File without changes