meshcode 1.0.0__py3-none-any.whl
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/__init__.py +2 -0
- meshcode/cli.py +30 -0
- meshcode/comms_v4.py +1208 -0
- meshcode-1.0.0.dist-info/METADATA +277 -0
- meshcode-1.0.0.dist-info/RECORD +8 -0
- meshcode-1.0.0.dist-info/WHEEL +5 -0
- meshcode-1.0.0.dist-info/entry_points.txt +2 -0
- meshcode-1.0.0.dist-info/top_level.txt +1 -0
meshcode/comms_v4.py
ADDED
|
@@ -0,0 +1,1208 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
AICOMMS v4 — Supabase-backed Real-time Inter-Agent Communication
|
|
4
|
+
=================================================================
|
|
5
|
+
Drop-in replacement for comms.py (v3). Same CLI interface, same output format.
|
|
6
|
+
Backend: Supabase REST API (meshcode schema) instead of local files.
|
|
7
|
+
Realtime: Supabase Realtime via publication on mc_messages + mc_agents.
|
|
8
|
+
|
|
9
|
+
COMMANDS (identical to v3):
|
|
10
|
+
register <project> <name> [role] Join a project network
|
|
11
|
+
send <project> <from>:<to> <message> Send message within project
|
|
12
|
+
broadcast <project> <from> <message> Send to all agents in project
|
|
13
|
+
read <project> <name> Read pending messages
|
|
14
|
+
check Hook auto-check (silent if empty)
|
|
15
|
+
watch <project> <name> [interval] [timeout] BLOCKING wait for messages
|
|
16
|
+
board <project> Project status board
|
|
17
|
+
update <project> <name> <status> [task] Update board status
|
|
18
|
+
history <project> [count] [agent1,agent2] Conversation log
|
|
19
|
+
status [project] System overview
|
|
20
|
+
clear <project> <name> Clear inbox
|
|
21
|
+
unregister <project> <name> Leave project
|
|
22
|
+
projects List all active projects
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
import time
|
|
29
|
+
import subprocess
|
|
30
|
+
from datetime import datetime
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from urllib.request import Request, urlopen
|
|
33
|
+
from urllib.error import URLError, HTTPError
|
|
34
|
+
from urllib.parse import quote
|
|
35
|
+
|
|
36
|
+
# ============================================================
|
|
37
|
+
# CONFIG — Supabase connection
|
|
38
|
+
# ============================================================
|
|
39
|
+
SUPABASE_URL = os.environ.get(
|
|
40
|
+
"SUPABASE_URL",
|
|
41
|
+
"https://wwgzzmydrwrjgaebspdo.supabase.co"
|
|
42
|
+
)
|
|
43
|
+
SUPABASE_KEY = os.environ.get(
|
|
44
|
+
"SUPABASE_KEY",
|
|
45
|
+
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Ind3Z3p6bXlkcndyamdhZWJzcGRvIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc2NzY1NDc3MCwiZXhwIjoyMDgzMjMwNzcwfQ.0SBXfb8OtyHmfaKW3dFQ6JYbcLUzCS1d4oXg4V-RAag"
|
|
46
|
+
)
|
|
47
|
+
SCHEMA = "meshcode"
|
|
48
|
+
|
|
49
|
+
# Local paths for session/TTY tracking (still needed for nudge)
|
|
50
|
+
COMMS_DIR = Path(__file__).parent
|
|
51
|
+
SESSIONS_DIR = COMMS_DIR / "sessions"
|
|
52
|
+
LOG_FILE = COMMS_DIR / "comms.log"
|
|
53
|
+
|
|
54
|
+
# ============================================================
|
|
55
|
+
# SUPABASE REST HELPERS (stdlib only — zero dependencies)
|
|
56
|
+
# ============================================================
|
|
57
|
+
|
|
58
|
+
def _headers(*, prefer=None, content_profile=True):
|
|
59
|
+
"""Build headers for Supabase REST API."""
|
|
60
|
+
h = {
|
|
61
|
+
"apikey": SUPABASE_KEY,
|
|
62
|
+
"Authorization": f"Bearer {SUPABASE_KEY}",
|
|
63
|
+
"Content-Type": "application/json",
|
|
64
|
+
}
|
|
65
|
+
if content_profile:
|
|
66
|
+
h["Accept-Profile"] = SCHEMA
|
|
67
|
+
h["Content-Profile"] = SCHEMA
|
|
68
|
+
if prefer:
|
|
69
|
+
h["Prefer"] = prefer
|
|
70
|
+
return h
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _request(method, path, *, data=None, prefer=None):
|
|
74
|
+
"""Make HTTP request to Supabase REST API. Returns parsed JSON or None."""
|
|
75
|
+
url = f"{SUPABASE_URL}/rest/v1/{path}"
|
|
76
|
+
body = json.dumps(data).encode() if data else None
|
|
77
|
+
req = Request(url, data=body, method=method, headers=_headers(prefer=prefer))
|
|
78
|
+
try:
|
|
79
|
+
with urlopen(req, timeout=10) as resp:
|
|
80
|
+
raw = resp.read().decode()
|
|
81
|
+
return json.loads(raw) if raw.strip() else None
|
|
82
|
+
except HTTPError as e:
|
|
83
|
+
err = e.read().decode()
|
|
84
|
+
# Try to extract trigger / RPC error message for clarity
|
|
85
|
+
try:
|
|
86
|
+
err_obj = json.loads(err)
|
|
87
|
+
msg = err_obj.get("message", err[:200])
|
|
88
|
+
# Daily message limit trigger raises a friendly message
|
|
89
|
+
if "Daily message limit reached" in msg:
|
|
90
|
+
print(f"[QUOTA] {msg}", file=sys.stderr)
|
|
91
|
+
else:
|
|
92
|
+
print(f"[ERROR] {method} {path}: {e.code} {msg}", file=sys.stderr)
|
|
93
|
+
except Exception:
|
|
94
|
+
print(f"[ERROR] {method} {path}: {e.code} {err[:200]}", file=sys.stderr)
|
|
95
|
+
return None
|
|
96
|
+
except URLError as e:
|
|
97
|
+
print(f"[ERROR] Network: {e.reason}", file=sys.stderr)
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def sb_select(table, filters="", order=None, limit=None):
|
|
102
|
+
"""SELECT from meshcode.table with PostgREST filters."""
|
|
103
|
+
params = []
|
|
104
|
+
if filters:
|
|
105
|
+
params.append(filters)
|
|
106
|
+
if order:
|
|
107
|
+
params.append(f"order={order}")
|
|
108
|
+
if limit:
|
|
109
|
+
params.append(f"limit={limit}")
|
|
110
|
+
path = f"{table}?{'&'.join(params)}" if params else table
|
|
111
|
+
return _request("GET", path) or []
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def sb_insert(table, row, *, upsert=False, on_conflict=None):
|
|
115
|
+
"""INSERT into meshcode.table. Returns inserted row."""
|
|
116
|
+
prefer = "return=representation"
|
|
117
|
+
if upsert:
|
|
118
|
+
prefer += ",resolution=merge-duplicates"
|
|
119
|
+
path = table
|
|
120
|
+
if on_conflict:
|
|
121
|
+
path += f"?on_conflict={on_conflict}"
|
|
122
|
+
return _request("POST", path, data=row, prefer=prefer)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def sb_update(table, filters, updates):
|
|
126
|
+
"""UPDATE meshcode.table SET updates WHERE filters."""
|
|
127
|
+
path = f"{table}?{filters}"
|
|
128
|
+
return _request("PATCH", path, data=updates, prefer="return=representation")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def sb_delete(table, filters):
|
|
132
|
+
"""DELETE FROM meshcode.table WHERE filters."""
|
|
133
|
+
path = f"{table}?{filters}"
|
|
134
|
+
return _request("DELETE", path, prefer="return=representation")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def sb_rpc(fn_name, params):
|
|
138
|
+
"""Call a Supabase RPC function."""
|
|
139
|
+
url = f"{SUPABASE_URL}/rest/v1/rpc/{fn_name}"
|
|
140
|
+
body = json.dumps(params).encode()
|
|
141
|
+
req = Request(url, data=body, method="POST", headers=_headers(content_profile=False))
|
|
142
|
+
try:
|
|
143
|
+
with urlopen(req, timeout=10) as resp:
|
|
144
|
+
raw = resp.read().decode()
|
|
145
|
+
return json.loads(raw) if raw.strip() else None
|
|
146
|
+
except (HTTPError, URLError) as e:
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ============================================================
|
|
151
|
+
# HELPERS
|
|
152
|
+
# ============================================================
|
|
153
|
+
|
|
154
|
+
def now():
|
|
155
|
+
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def now_iso():
|
|
159
|
+
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S+00:00")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def log_msg(text):
|
|
163
|
+
try:
|
|
164
|
+
with open(LOG_FILE, "a") as f:
|
|
165
|
+
f.write(f"[{now()}] {text}\n")
|
|
166
|
+
except:
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def ensure_sessions():
|
|
171
|
+
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def get_project_id(project_name):
|
|
175
|
+
"""Get or create project, return its UUID."""
|
|
176
|
+
rows = sb_select("mc_projects", f"name=eq.{quote(project_name)}")
|
|
177
|
+
if rows:
|
|
178
|
+
return rows[0]["id"]
|
|
179
|
+
# Create project
|
|
180
|
+
result = sb_insert("mc_projects", {"name": project_name})
|
|
181
|
+
if result and len(result) > 0:
|
|
182
|
+
return result[0]["id"]
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ============================================================
|
|
187
|
+
# NUDGE (same as v3 — local AppleScript)
|
|
188
|
+
# ============================================================
|
|
189
|
+
|
|
190
|
+
NUDGE_COOLDOWN = 10
|
|
191
|
+
|
|
192
|
+
def get_nudge_file(project, name):
|
|
193
|
+
return SESSIONS_DIR / f".nudge_{project}_{name}" if SESSIONS_DIR.exists() else None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def can_nudge(project, name):
|
|
197
|
+
nf = get_nudge_file(project, name)
|
|
198
|
+
if nf and nf.exists():
|
|
199
|
+
try:
|
|
200
|
+
last = float(nf.read_text().strip())
|
|
201
|
+
if (time.time() - last) < NUDGE_COOLDOWN:
|
|
202
|
+
return False
|
|
203
|
+
except:
|
|
204
|
+
pass
|
|
205
|
+
return True
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def mark_nudged(project, name):
|
|
209
|
+
nf = get_nudge_file(project, name)
|
|
210
|
+
if nf:
|
|
211
|
+
nf.write_text(str(time.time()))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def send_notification(project, name, from_agent, pending=1):
|
|
215
|
+
"""Cross-platform notification: macOS (osascript), Windows (PowerShell toast), Linux (notify-send)."""
|
|
216
|
+
title = f"MeshCode [{project}] → {name}"
|
|
217
|
+
body = f"{pending} mensaje(s) pendiente(s) de {from_agent}"
|
|
218
|
+
try:
|
|
219
|
+
if sys.platform == "darwin":
|
|
220
|
+
subprocess.run(['osascript', '-e',
|
|
221
|
+
f'display notification "{body}" with title "{title}" sound name "Ping"'],
|
|
222
|
+
capture_output=True, timeout=3)
|
|
223
|
+
elif sys.platform == "win32":
|
|
224
|
+
ps_script = (
|
|
225
|
+
f'[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null; '
|
|
226
|
+
f'$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent(0); '
|
|
227
|
+
f'$text = $xml.GetElementsByTagName("text"); '
|
|
228
|
+
f'$text[0].AppendChild($xml.CreateTextNode("{title}")); '
|
|
229
|
+
f'$text[1].AppendChild($xml.CreateTextNode("{body}")); '
|
|
230
|
+
f'$toast = [Windows.UI.Notifications.ToastNotification]::new($xml); '
|
|
231
|
+
f'[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("MeshCode").Show($toast)'
|
|
232
|
+
)
|
|
233
|
+
subprocess.run(['powershell', '-Command', ps_script],
|
|
234
|
+
capture_output=True, timeout=5)
|
|
235
|
+
else:
|
|
236
|
+
# Linux
|
|
237
|
+
subprocess.run(['notify-send', title, body], capture_output=True, timeout=3)
|
|
238
|
+
except:
|
|
239
|
+
pass
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def nudge_agent(project, name, from_agent=""):
|
|
243
|
+
if not can_nudge(project, name):
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
ensure_sessions()
|
|
247
|
+
session_file = SESSIONS_DIR / f"{project}_{name}"
|
|
248
|
+
if not session_file.exists():
|
|
249
|
+
send_notification(project, name, from_agent, 1)
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
data = json.loads(session_file.read_text())
|
|
254
|
+
except:
|
|
255
|
+
send_notification(project, name, from_agent, 1)
|
|
256
|
+
return False
|
|
257
|
+
|
|
258
|
+
tty = data.get("tty")
|
|
259
|
+
if not tty:
|
|
260
|
+
send_notification(project, name, from_agent, 1)
|
|
261
|
+
return False
|
|
262
|
+
|
|
263
|
+
# Count pending from Supabase
|
|
264
|
+
project_id = get_project_id(project)
|
|
265
|
+
pending_msgs = sb_select("mc_messages",
|
|
266
|
+
f"project_id=eq.{project_id}&to_agent=eq.{quote(name)}&read=eq.false&type=neq.ack",
|
|
267
|
+
limit=100)
|
|
268
|
+
pending = len(pending_msgs) if pending_msgs else 1
|
|
269
|
+
|
|
270
|
+
message = f"Tienes {pending} mensaje(s). Revisa: meshcode read {project} {name}"
|
|
271
|
+
escaped_message = message.replace('\\', '\\\\').replace('"', '\\"')
|
|
272
|
+
|
|
273
|
+
# Only attempt AppleScript nudge on macOS
|
|
274
|
+
if sys.platform != "darwin":
|
|
275
|
+
send_notification(project, name, from_agent, pending)
|
|
276
|
+
mark_nudged(project, name)
|
|
277
|
+
return True
|
|
278
|
+
|
|
279
|
+
# Use `do script in tab` — writes command DIRECTLY to the target tab
|
|
280
|
+
# without keystroke injection or focus changes. The user's foreground
|
|
281
|
+
# app stays active. Works even if Terminal is in background.
|
|
282
|
+
applescript_terminal = f'''
|
|
283
|
+
tell application "Terminal"
|
|
284
|
+
set targetTab to missing value
|
|
285
|
+
repeat with w in windows
|
|
286
|
+
repeat with t in tabs of w
|
|
287
|
+
try
|
|
288
|
+
if tty of t contains "{tty}" then
|
|
289
|
+
set targetTab to t
|
|
290
|
+
exit repeat
|
|
291
|
+
end if
|
|
292
|
+
end try
|
|
293
|
+
end repeat
|
|
294
|
+
if targetTab is not missing value then exit repeat
|
|
295
|
+
end repeat
|
|
296
|
+
if targetTab is not missing value then
|
|
297
|
+
do script "{escaped_message}" in targetTab
|
|
298
|
+
else
|
|
299
|
+
error "tty not found"
|
|
300
|
+
end if
|
|
301
|
+
end tell
|
|
302
|
+
'''
|
|
303
|
+
success = False
|
|
304
|
+
try:
|
|
305
|
+
result = subprocess.run(['osascript', '-e', applescript_terminal],
|
|
306
|
+
capture_output=True, text=True, timeout=10)
|
|
307
|
+
if result.returncode == 0:
|
|
308
|
+
success = True
|
|
309
|
+
except:
|
|
310
|
+
pass
|
|
311
|
+
|
|
312
|
+
if success:
|
|
313
|
+
mark_nudged(project, name)
|
|
314
|
+
log_msg(f"[{project}] NUDGE: {name} woken ({pending} pending)")
|
|
315
|
+
return True
|
|
316
|
+
|
|
317
|
+
mark_nudged(project, name)
|
|
318
|
+
send_notification(project, name, from_agent, pending)
|
|
319
|
+
log_msg(f"[{project}] NUDGE: {name} via notification (keystroke failed)")
|
|
320
|
+
return False
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def get_session_info():
|
|
324
|
+
"""Detect which agent+project this session is via TTY matching."""
|
|
325
|
+
ensure_sessions()
|
|
326
|
+
my_tty = None
|
|
327
|
+
try:
|
|
328
|
+
check_pid = os.getpid()
|
|
329
|
+
for _ in range(8):
|
|
330
|
+
result = subprocess.check_output(['ps', '-p', str(check_pid), '-o', 'tty=,ppid='],
|
|
331
|
+
text=True, timeout=3).strip()
|
|
332
|
+
parts = result.split()
|
|
333
|
+
if len(parts) >= 2:
|
|
334
|
+
tty_val = parts[0]
|
|
335
|
+
parent = parts[1]
|
|
336
|
+
if tty_val and tty_val != '??' and tty_val != '':
|
|
337
|
+
my_tty = tty_val
|
|
338
|
+
break
|
|
339
|
+
check_pid = parent
|
|
340
|
+
else:
|
|
341
|
+
break
|
|
342
|
+
except:
|
|
343
|
+
pass
|
|
344
|
+
|
|
345
|
+
if not my_tty:
|
|
346
|
+
return None, None
|
|
347
|
+
|
|
348
|
+
for f in SESSIONS_DIR.iterdir():
|
|
349
|
+
if f.is_file() and '_' in f.name and not f.name.startswith('.'):
|
|
350
|
+
try:
|
|
351
|
+
data = json.loads(f.read_text())
|
|
352
|
+
session_tty = data.get("tty", "")
|
|
353
|
+
if session_tty and (session_tty in my_tty or my_tty in session_tty):
|
|
354
|
+
return data.get("project"), data.get("agent")
|
|
355
|
+
except:
|
|
356
|
+
continue
|
|
357
|
+
|
|
358
|
+
return None, None
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# ============================================================
|
|
362
|
+
# COMMANDS
|
|
363
|
+
# ============================================================
|
|
364
|
+
|
|
365
|
+
def register(project, name, role=""):
|
|
366
|
+
ensure_sessions()
|
|
367
|
+
project_id = get_project_id(project)
|
|
368
|
+
if not project_id:
|
|
369
|
+
print(f"[ERROR] No se pudo crear/encontrar proyecto '{project}'")
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
ppid = os.getppid()
|
|
373
|
+
|
|
374
|
+
# Capture TTY
|
|
375
|
+
tty = None
|
|
376
|
+
try:
|
|
377
|
+
check_pid = ppid
|
|
378
|
+
for _ in range(5):
|
|
379
|
+
result = subprocess.check_output(['ps', '-p', str(check_pid), '-o', 'tty=,ppid='],
|
|
380
|
+
text=True, timeout=3).strip()
|
|
381
|
+
parts = result.split()
|
|
382
|
+
if len(parts) >= 2:
|
|
383
|
+
tty_val = parts[0]
|
|
384
|
+
parent = parts[1]
|
|
385
|
+
if tty_val and tty_val != '??' and tty_val != '':
|
|
386
|
+
tty = tty_val
|
|
387
|
+
break
|
|
388
|
+
check_pid = parent
|
|
389
|
+
else:
|
|
390
|
+
break
|
|
391
|
+
except:
|
|
392
|
+
pass
|
|
393
|
+
|
|
394
|
+
# Register agent via tier-aware RPC (enforces plan limits)
|
|
395
|
+
rpc_result = sb_rpc("mc_register_agent", {
|
|
396
|
+
"p_project_id": project_id,
|
|
397
|
+
"p_name": name,
|
|
398
|
+
"p_role": role,
|
|
399
|
+
"p_status": "online"
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
if rpc_result and rpc_result.get("error"):
|
|
403
|
+
print(f"[ERROR] {rpc_result['error']}")
|
|
404
|
+
if rpc_result.get("upgrade_url"):
|
|
405
|
+
print(f"[ERROR] Upgrade: {rpc_result['upgrade_url']}")
|
|
406
|
+
if rpc_result.get("current") is not None:
|
|
407
|
+
print(f"[ERROR] Agentes: {rpc_result['current']} / {rpc_result.get('max', '?')}")
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
# Update tty/pid (RPC doesn't take these — patch separately)
|
|
411
|
+
sb_update("mc_agents",
|
|
412
|
+
f"project_id=eq.{project_id}&name=eq.{quote(name)}",
|
|
413
|
+
{"tty": tty, "pid": ppid, "task": role, "last_heartbeat": now_iso()})
|
|
414
|
+
|
|
415
|
+
# Save local session file (for nudge TTY detection)
|
|
416
|
+
session_data = {"project": project, "agent": name, "pid": ppid, "tty": tty, "registered_at": now()}
|
|
417
|
+
(SESSIONS_DIR / f"{project}_{name}").write_text(json.dumps(session_data))
|
|
418
|
+
|
|
419
|
+
# Get all agents in project
|
|
420
|
+
agents = sb_select("mc_agents", f"project_id=eq.{project_id}", order="registered_at.asc")
|
|
421
|
+
agent_names = [a["name"] for a in agents]
|
|
422
|
+
|
|
423
|
+
# Count pending messages
|
|
424
|
+
pending_msgs = sb_select("mc_messages",
|
|
425
|
+
f"project_id=eq.{project_id}&to_agent=eq.{quote(name)}&read=eq.false&type=neq.ack")
|
|
426
|
+
pending = len(pending_msgs)
|
|
427
|
+
|
|
428
|
+
print(f"[COMMS] Proyecto: {project}")
|
|
429
|
+
print(f"[COMMS] Registrado: {name} ({role})")
|
|
430
|
+
print(f"[COMMS] TTY: {tty or 'no detectado'}")
|
|
431
|
+
print(f"[COMMS] Online en {project}: {', '.join(agent_names)}")
|
|
432
|
+
print(f"[COMMS] Auto-nudge: {'activado' if tty else 'solo notificaciones (sin TTY)'}")
|
|
433
|
+
print(f"[COMMS] Backend: Supabase ({SCHEMA} schema)")
|
|
434
|
+
if pending > 0:
|
|
435
|
+
print(f"[COMMS] Tienes {pending} mensaje(s) pendiente(s) — ejecuta read para verlos")
|
|
436
|
+
|
|
437
|
+
# Show recent history
|
|
438
|
+
recent = sb_select("mc_messages",
|
|
439
|
+
f"project_id=eq.{project_id}&type=neq.ack",
|
|
440
|
+
order="created_at.desc", limit=10)
|
|
441
|
+
if recent:
|
|
442
|
+
recent.reverse()
|
|
443
|
+
print(f"\n[COMMS] Últimos {len(recent)} mensajes del equipo:")
|
|
444
|
+
for msg in recent:
|
|
445
|
+
ts = msg.get("created_at", "")[-8:]
|
|
446
|
+
fr = msg["from_agent"]
|
|
447
|
+
to = msg["to_agent"]
|
|
448
|
+
payload = msg.get("payload", {})
|
|
449
|
+
preview = json.dumps(payload, ensure_ascii=False)[:80]
|
|
450
|
+
print(f" [{ts}] {fr}->{to}: {preview}")
|
|
451
|
+
print()
|
|
452
|
+
|
|
453
|
+
log_msg(f"{name} joined {project} (tty={tty}, backend=supabase)")
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def send_msg(project, from_agent, to_agent, content, msg_type="msg"):
|
|
457
|
+
project_id = get_project_id(project)
|
|
458
|
+
if not project_id:
|
|
459
|
+
print(f"[ERROR] Proyecto '{project}' no encontrado")
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
payload = {}
|
|
463
|
+
try:
|
|
464
|
+
payload = json.loads(content)
|
|
465
|
+
except (json.JSONDecodeError, TypeError):
|
|
466
|
+
payload = {"text": content}
|
|
467
|
+
|
|
468
|
+
msg = {
|
|
469
|
+
"project_id": project_id,
|
|
470
|
+
"from_agent": from_agent,
|
|
471
|
+
"to_agent": to_agent,
|
|
472
|
+
"type": msg_type,
|
|
473
|
+
"payload": payload,
|
|
474
|
+
"read": False
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
result = sb_insert("mc_messages", msg)
|
|
478
|
+
if result:
|
|
479
|
+
preview = json.dumps(payload, ensure_ascii=False)[:60]
|
|
480
|
+
log_msg(f"[{project}] {from_agent}->{to_agent}: {preview}")
|
|
481
|
+
print(f"[{project}] {from_agent}->{to_agent}: sent")
|
|
482
|
+
|
|
483
|
+
# Nudge idle agents (skip acks)
|
|
484
|
+
if msg_type not in ("ack", "warning") and from_agent != "system":
|
|
485
|
+
agents = sb_select("mc_agents",
|
|
486
|
+
f"project_id=eq.{project_id}&name=eq.{quote(to_agent)}")
|
|
487
|
+
if agents and agents[0].get("status") in ("idle", "standby", "online"):
|
|
488
|
+
nudge_agent(project, to_agent, from_agent)
|
|
489
|
+
else:
|
|
490
|
+
print(f"[{project}] ERROR: no se pudo enviar mensaje")
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def broadcast(project, from_agent, content, msg_type="broadcast"):
|
|
494
|
+
project_id = get_project_id(project)
|
|
495
|
+
if not project_id:
|
|
496
|
+
return
|
|
497
|
+
agents = sb_select("mc_agents", f"project_id=eq.{project_id}")
|
|
498
|
+
count = 0
|
|
499
|
+
for agent in agents:
|
|
500
|
+
if agent["name"] != from_agent:
|
|
501
|
+
send_msg(project, from_agent, agent["name"], content, msg_type)
|
|
502
|
+
count += 1
|
|
503
|
+
time.sleep(1.5)
|
|
504
|
+
print(f"[{project}] broadcast from {from_agent} -> {count} agents")
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def read_messages(project, name, silent=False):
|
|
508
|
+
project_id = get_project_id(project)
|
|
509
|
+
if not project_id:
|
|
510
|
+
if not silent:
|
|
511
|
+
print("[EMPTY]")
|
|
512
|
+
return []
|
|
513
|
+
|
|
514
|
+
# Get unread messages
|
|
515
|
+
messages = sb_select("mc_messages",
|
|
516
|
+
f"project_id=eq.{project_id}&to_agent=eq.{quote(name)}&read=eq.false",
|
|
517
|
+
order="created_at.asc")
|
|
518
|
+
|
|
519
|
+
if not messages:
|
|
520
|
+
if not silent:
|
|
521
|
+
print("[EMPTY]")
|
|
522
|
+
return []
|
|
523
|
+
|
|
524
|
+
# Mark all as read
|
|
525
|
+
msg_ids = [m["id"] for m in messages]
|
|
526
|
+
for mid in msg_ids:
|
|
527
|
+
sb_update("mc_messages", f"id=eq.{mid}", {"read": True})
|
|
528
|
+
|
|
529
|
+
print(f"[{project}] {len(messages)} mensaje(s) para {name}:")
|
|
530
|
+
print("---")
|
|
531
|
+
ack_targets = set()
|
|
532
|
+
for msg in messages:
|
|
533
|
+
sender = msg["from_agent"]
|
|
534
|
+
mtype = msg.get("type", "msg")
|
|
535
|
+
ts = msg.get("created_at", "")
|
|
536
|
+
# Format timestamp to match v3
|
|
537
|
+
if "T" in ts:
|
|
538
|
+
ts = ts.replace("T", " ")[:19]
|
|
539
|
+
payload = msg.get("payload", {})
|
|
540
|
+
print(f"[{sender}|{mtype}|{ts}] {json.dumps(payload, ensure_ascii=False)}")
|
|
541
|
+
if mtype not in ("ack", "broadcast") and sender != name:
|
|
542
|
+
ack_targets.add(sender)
|
|
543
|
+
print("---")
|
|
544
|
+
|
|
545
|
+
# Send automatic ACKs
|
|
546
|
+
for sender in ack_targets:
|
|
547
|
+
ack_payload = {"text": f"{name} leyó tu mensaje"}
|
|
548
|
+
sb_insert("mc_messages", {
|
|
549
|
+
"project_id": project_id,
|
|
550
|
+
"from_agent": name,
|
|
551
|
+
"to_agent": sender,
|
|
552
|
+
"type": "ack",
|
|
553
|
+
"payload": ack_payload,
|
|
554
|
+
"read": False
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
return messages
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def hook_check():
|
|
561
|
+
"""Called by PostToolUse hook. Auto-detects project+agent, prints pending.
|
|
562
|
+
Also sends heartbeat so dashboard always shows fresh status."""
|
|
563
|
+
ensure_sessions()
|
|
564
|
+
project, agent = get_session_info()
|
|
565
|
+
if not project or not agent:
|
|
566
|
+
return
|
|
567
|
+
|
|
568
|
+
project_id = get_project_id(project)
|
|
569
|
+
if not project_id:
|
|
570
|
+
return
|
|
571
|
+
|
|
572
|
+
# Always heartbeat — keeps agent alive on dashboard
|
|
573
|
+
sb_update("mc_agents",
|
|
574
|
+
f"project_id=eq.{project_id}&name=eq.{quote(agent)}",
|
|
575
|
+
{"status": "working", "last_heartbeat": now_iso()})
|
|
576
|
+
|
|
577
|
+
# Check for unread non-ack messages
|
|
578
|
+
pending = sb_select("mc_messages",
|
|
579
|
+
f"project_id=eq.{project_id}&to_agent=eq.{quote(agent)}&read=eq.false&type=neq.ack",
|
|
580
|
+
limit=1)
|
|
581
|
+
|
|
582
|
+
if not pending:
|
|
583
|
+
return
|
|
584
|
+
|
|
585
|
+
count = len(sb_select("mc_messages",
|
|
586
|
+
f"project_id=eq.{project_id}&to_agent=eq.{quote(agent)}&read=eq.false&type=neq.ack"))
|
|
587
|
+
|
|
588
|
+
print(f"\n*** [{project.upper()}] {count} MENSAJE(S) para {agent} ***")
|
|
589
|
+
read_messages(project, agent, silent=False)
|
|
590
|
+
print(f"*** Lee y responde via meshcode. ***\n")
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def watch(project, name, interval=10, timeout=0):
|
|
594
|
+
"""BLOCKING wait for messages. Auto-loops: returns on message, auto-restarts after cycle."""
|
|
595
|
+
project_id = get_project_id(project)
|
|
596
|
+
if not project_id:
|
|
597
|
+
return
|
|
598
|
+
|
|
599
|
+
import signal
|
|
600
|
+
_interrupted = [False]
|
|
601
|
+
def handle_interrupt(signum, frame):
|
|
602
|
+
_interrupted[0] = True
|
|
603
|
+
print(f"\n[{project}] {name}: Watch interrumpido por el usuario.")
|
|
604
|
+
sb_update("mc_agents",
|
|
605
|
+
f"project_id=eq.{project_id}&name=eq.{quote(name)}",
|
|
606
|
+
{"status": "online", "task": "Listening to user", "last_heartbeat": now_iso()})
|
|
607
|
+
sys.exit(0)
|
|
608
|
+
signal.signal(signal.SIGINT, handle_interrupt)
|
|
609
|
+
|
|
610
|
+
# Update status to standby
|
|
611
|
+
sb_update("mc_agents",
|
|
612
|
+
f"project_id=eq.{project_id}&name=eq.{quote(name)}",
|
|
613
|
+
{"status": "standby", "task": "Esperando mensajes...", "last_heartbeat": now_iso()})
|
|
614
|
+
|
|
615
|
+
cycle = timeout if timeout > 0 else 600
|
|
616
|
+
print(f"[{project}] {name} en watch — poll cada {interval}s, ciclo {cycle}s (auto-loop)")
|
|
617
|
+
|
|
618
|
+
while not _interrupted[0]:
|
|
619
|
+
start = time.time()
|
|
620
|
+
|
|
621
|
+
while (time.time() - start) < cycle and not _interrupted[0]:
|
|
622
|
+
pending = sb_select("mc_messages",
|
|
623
|
+
f"project_id=eq.{project_id}&to_agent=eq.{quote(name)}&read=eq.false&type=neq.ack",
|
|
624
|
+
limit=1)
|
|
625
|
+
|
|
626
|
+
if pending:
|
|
627
|
+
elapsed = int(time.time() - start)
|
|
628
|
+
print(f"\n*** [{project.upper()}] MENSAJE RECIBIDO ({elapsed}s en standby) ***")
|
|
629
|
+
messages = read_messages(project, name, silent=False)
|
|
630
|
+
|
|
631
|
+
sb_update("mc_agents",
|
|
632
|
+
f"project_id=eq.{project_id}&name=eq.{quote(name)}",
|
|
633
|
+
{"status": "working", "task": "Procesando mensaje recibido", "last_heartbeat": now_iso()})
|
|
634
|
+
|
|
635
|
+
return messages
|
|
636
|
+
|
|
637
|
+
# Heartbeat
|
|
638
|
+
sb_rpc("mc_heartbeat", {"p_project_id": project_id, "p_agent_name": name})
|
|
639
|
+
time.sleep(interval)
|
|
640
|
+
|
|
641
|
+
# Cycle ended without messages — check user context, then AUTO-RESTART watch
|
|
642
|
+
print(f"[{project}] Otro ciclo sin mensajes. Watch activo, sigue corriendo. En standby.")
|
|
643
|
+
sb_update("mc_agents",
|
|
644
|
+
f"project_id=eq.{project_id}&name=eq.{quote(name)}",
|
|
645
|
+
{"status": "standby", "task": "Esperando mensajes...", "last_heartbeat": now_iso()})
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def update_board(project, name, status, task=""):
|
|
649
|
+
project_id = get_project_id(project)
|
|
650
|
+
if not project_id:
|
|
651
|
+
return
|
|
652
|
+
|
|
653
|
+
# Block idle if there are unread messages
|
|
654
|
+
if status == "idle":
|
|
655
|
+
pending = sb_select("mc_messages",
|
|
656
|
+
f"project_id=eq.{project_id}&to_agent=eq.{quote(name)}&read=eq.false&type=neq.ack",
|
|
657
|
+
limit=1)
|
|
658
|
+
if pending:
|
|
659
|
+
count = len(sb_select("mc_messages",
|
|
660
|
+
f"project_id=eq.{project_id}&to_agent=eq.{quote(name)}&read=eq.false&type=neq.ack"))
|
|
661
|
+
print(f"[{project}] {name}: NO puedes estar idle — tienes {count} mensaje(s) pendientes.")
|
|
662
|
+
print(f"[{project}] Ejecuta: python3 ~/Desktop/meshcode/comms_v4.py read {project} {name}")
|
|
663
|
+
status = "working"
|
|
664
|
+
task = f"{count} mensajes pendientes"
|
|
665
|
+
|
|
666
|
+
updates = {"status": status, "last_heartbeat": now_iso()}
|
|
667
|
+
if task:
|
|
668
|
+
updates["task"] = task
|
|
669
|
+
|
|
670
|
+
sb_update("mc_agents",
|
|
671
|
+
f"project_id=eq.{project_id}&name=eq.{quote(name)}", updates)
|
|
672
|
+
print(f"[{project}] {name}: {status} — {task}")
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def show_board(project):
|
|
676
|
+
project_id = get_project_id(project)
|
|
677
|
+
if not project_id:
|
|
678
|
+
print(f"[{project}] Sin proyecto")
|
|
679
|
+
return
|
|
680
|
+
|
|
681
|
+
agents = sb_select("mc_agents", f"project_id=eq.{project_id}", order="registered_at.asc")
|
|
682
|
+
if not agents:
|
|
683
|
+
print(f"[{project}] Sin agentes")
|
|
684
|
+
return
|
|
685
|
+
|
|
686
|
+
print(f"\n{'='*60}")
|
|
687
|
+
print(f" {project.upper()} — Board")
|
|
688
|
+
print(f"{'='*60}")
|
|
689
|
+
for a in agents:
|
|
690
|
+
icon = {"online": "●", "working": "▶", "blocked": "■", "standby": "◆", "idle": "○", "done": "✓"}.get(a.get("status", ""), "?")
|
|
691
|
+
print(f" {icon} {a['name']:<14} {a.get('status','?'):<10} {a.get('task','')}")
|
|
692
|
+
print()
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def show_status(project=None):
|
|
696
|
+
if project:
|
|
697
|
+
projects_data = sb_select("mc_projects", f"name=eq.{quote(project)}")
|
|
698
|
+
else:
|
|
699
|
+
projects_data = sb_select("mc_projects", "", order="created_at.asc")
|
|
700
|
+
|
|
701
|
+
if not projects_data:
|
|
702
|
+
print("[COMMS] Sin proyectos activos")
|
|
703
|
+
return
|
|
704
|
+
|
|
705
|
+
print(f"\n{'='*60}")
|
|
706
|
+
print(f" AICOMMS v4 STATUS (Supabase)")
|
|
707
|
+
print(f"{'='*60}")
|
|
708
|
+
|
|
709
|
+
for proj in projects_data:
|
|
710
|
+
agents = sb_select("mc_agents", f"project_id=eq.{proj['id']}", order="name.asc")
|
|
711
|
+
if not agents:
|
|
712
|
+
continue
|
|
713
|
+
|
|
714
|
+
print(f"\n [{proj['name']}]")
|
|
715
|
+
for a in agents:
|
|
716
|
+
# Count pending
|
|
717
|
+
pending = sb_select("mc_messages",
|
|
718
|
+
f"project_id=eq.{proj['id']}&to_agent=eq.{quote(a['name'])}&read=eq.false&type=neq.ack")
|
|
719
|
+
pcount = len(pending)
|
|
720
|
+
icon = "●" if pcount > 0 else "○"
|
|
721
|
+
print(f" {icon} {a['name']:<14} {a.get('role',''):<25} pending: {pcount}")
|
|
722
|
+
print()
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def show_history(project, last_n=20, between=None):
|
|
726
|
+
project_id = get_project_id(project)
|
|
727
|
+
if not project_id:
|
|
728
|
+
print(f"[{project}] Sin historial")
|
|
729
|
+
return
|
|
730
|
+
|
|
731
|
+
filters = f"project_id=eq.{project_id}&type=neq.ack"
|
|
732
|
+
if between:
|
|
733
|
+
agents = between.split(",")
|
|
734
|
+
if len(agents) == 2:
|
|
735
|
+
a, b = agents
|
|
736
|
+
# PostgREST OR filter
|
|
737
|
+
filters += f"&or=(and(from_agent.eq.{quote(a)},to_agent.eq.{quote(b)}),and(from_agent.eq.{quote(b)},to_agent.eq.{quote(a)}))"
|
|
738
|
+
|
|
739
|
+
messages = sb_select("mc_messages", filters, order="created_at.desc", limit=last_n)
|
|
740
|
+
if not messages:
|
|
741
|
+
print(f"[{project}] Sin historial")
|
|
742
|
+
return
|
|
743
|
+
|
|
744
|
+
messages.reverse()
|
|
745
|
+
|
|
746
|
+
print(f"\n{'='*60}")
|
|
747
|
+
print(f" {project.upper()} — Historial ({len(messages)} mensajes)")
|
|
748
|
+
if between:
|
|
749
|
+
print(f" Filtro: {between}")
|
|
750
|
+
print(f"{'='*60}\n")
|
|
751
|
+
|
|
752
|
+
for msg in messages:
|
|
753
|
+
ts = msg.get("created_at", "")
|
|
754
|
+
if "T" in ts:
|
|
755
|
+
ts = ts.replace("T", " ")[:19]
|
|
756
|
+
sender = msg.get("from_agent", "?")
|
|
757
|
+
to = msg.get("to_agent", "?")
|
|
758
|
+
payload = msg.get("payload", {})
|
|
759
|
+
content = json.dumps(payload, ensure_ascii=False)
|
|
760
|
+
print(f" [{ts}] {sender}->{to}: {content}")
|
|
761
|
+
print()
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def list_projects():
|
|
765
|
+
projects_data = sb_select("mc_projects", "", order="created_at.asc")
|
|
766
|
+
if not projects_data:
|
|
767
|
+
print("[COMMS] Sin proyectos")
|
|
768
|
+
return
|
|
769
|
+
|
|
770
|
+
print(f"\n{'='*60}")
|
|
771
|
+
print(f" PROYECTOS ACTIVOS (Supabase)")
|
|
772
|
+
print(f"{'='*60}")
|
|
773
|
+
|
|
774
|
+
for proj in projects_data:
|
|
775
|
+
agents = sb_select("mc_agents", f"project_id=eq.{proj['id']}", order="name.asc")
|
|
776
|
+
statuses = [f"{a['name']}({a.get('status','?')})" for a in agents]
|
|
777
|
+
print(f" [{proj['name']}] {', '.join(statuses) if statuses else 'sin agentes'}")
|
|
778
|
+
print()
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def clear_inbox(project, name):
|
|
782
|
+
project_id = get_project_id(project)
|
|
783
|
+
if not project_id:
|
|
784
|
+
return
|
|
785
|
+
# Mark all as read
|
|
786
|
+
sb_update("mc_messages",
|
|
787
|
+
f"project_id=eq.{project_id}&to_agent=eq.{quote(name)}&read=eq.false",
|
|
788
|
+
{"read": True})
|
|
789
|
+
print(f"[{project}] {name} inbox cleared")
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def unregister(project, name):
|
|
793
|
+
project_id = get_project_id(project)
|
|
794
|
+
if not project_id:
|
|
795
|
+
return
|
|
796
|
+
sb_delete("mc_agents", f"project_id=eq.{project_id}&name=eq.{quote(name)}")
|
|
797
|
+
|
|
798
|
+
# Clean local session
|
|
799
|
+
session_file = SESSIONS_DIR / f"{project}_{name}"
|
|
800
|
+
if session_file.exists():
|
|
801
|
+
session_file.unlink()
|
|
802
|
+
|
|
803
|
+
log_msg(f"{name} left {project}")
|
|
804
|
+
print(f"[{project}] {name} removed")
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def connect(project, name, hook_target="claude", role=""):
|
|
808
|
+
"""One-command setup: detect OS, check auth, register agent, install hook, start watch."""
|
|
809
|
+
import platform
|
|
810
|
+
os_name = platform.system() # Darwin, Windows, Linux
|
|
811
|
+
|
|
812
|
+
print(f"[MESHCODE] Plataforma detectada: {os_name}")
|
|
813
|
+
print(f"[MESHCODE] Conectando a meshwork '{project}' como '{name}'...")
|
|
814
|
+
|
|
815
|
+
# Check for saved credentials
|
|
816
|
+
creds_path = Path.home() / ".meshcode" / "credentials.json"
|
|
817
|
+
if creds_path.exists():
|
|
818
|
+
try:
|
|
819
|
+
creds = json.loads(creds_path.read_text())
|
|
820
|
+
print(f"[MESHCODE] Autenticado como {creds.get('display_name', creds.get('email', '?'))}")
|
|
821
|
+
except:
|
|
822
|
+
pass
|
|
823
|
+
|
|
824
|
+
# Register agent
|
|
825
|
+
actual_role = role or f"Connected via meshcode connect ({hook_target})"
|
|
826
|
+
register(project, name, actual_role)
|
|
827
|
+
|
|
828
|
+
# Install hook based on target
|
|
829
|
+
comms_path = str(Path(__file__).resolve())
|
|
830
|
+
|
|
831
|
+
if hook_target == "claude":
|
|
832
|
+
settings_path = Path.home() / ".claude" / "settings.json"
|
|
833
|
+
hook_cmd = f"python3 {comms_path} check"
|
|
834
|
+
|
|
835
|
+
settings = {}
|
|
836
|
+
if settings_path.exists():
|
|
837
|
+
try:
|
|
838
|
+
settings = json.loads(settings_path.read_text())
|
|
839
|
+
except:
|
|
840
|
+
pass
|
|
841
|
+
|
|
842
|
+
hooks = settings.setdefault("hooks", {})
|
|
843
|
+
post_hooks = hooks.setdefault("PostToolUse", [])
|
|
844
|
+
|
|
845
|
+
already = any(hook_cmd in h.get("command", "") for h in post_hooks)
|
|
846
|
+
if not already:
|
|
847
|
+
post_hooks.append({"command": hook_cmd, "matcher": ""})
|
|
848
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
849
|
+
settings_path.write_text(json.dumps(settings, indent=2))
|
|
850
|
+
print(f"[MESHCODE] Hook PostToolUse instalado en {settings_path}")
|
|
851
|
+
else:
|
|
852
|
+
print(f"[MESHCODE] Hook PostToolUse ya existe")
|
|
853
|
+
|
|
854
|
+
print(f"[MESHCODE] Claude Code conectado a {project} como {name}")
|
|
855
|
+
|
|
856
|
+
elif hook_target == "codex":
|
|
857
|
+
config_path = Path.cwd() / ".meshcode.json"
|
|
858
|
+
config = {
|
|
859
|
+
"project": project,
|
|
860
|
+
"agent": name,
|
|
861
|
+
"supabase_url": SUPABASE_URL,
|
|
862
|
+
"check_command": f"python3 {comms_path} check",
|
|
863
|
+
"read_command": f"python3 {comms_path} read {project} {name}",
|
|
864
|
+
"send_command": f"python3 {comms_path} send {project} {name}:{{to}} '{{message}}'",
|
|
865
|
+
"platform": os_name
|
|
866
|
+
}
|
|
867
|
+
config_path.write_text(json.dumps(config, indent=2))
|
|
868
|
+
print(f"[MESHCODE] Config guardada en {config_path}")
|
|
869
|
+
print(f"[MESHCODE] Codex: usa read_command y send_command del config")
|
|
870
|
+
|
|
871
|
+
else:
|
|
872
|
+
print(f"[MESHCODE] Hook target desconocido: {hook_target}. Usa 'claude' o 'codex'.")
|
|
873
|
+
return
|
|
874
|
+
|
|
875
|
+
# Print quickstart
|
|
876
|
+
print(f"\n[MESHCODE] ✓ Setup completo. Comandos útiles:")
|
|
877
|
+
print(f" Leer: python3 {comms_path} read {project} {name}")
|
|
878
|
+
print(f" Enviar: python3 {comms_path} send {project} {name}:<destino> '<mensaje>'")
|
|
879
|
+
print(f" Board: python3 {comms_path} board {project}")
|
|
880
|
+
print(f" Watch: python3 {comms_path} watch {project} {name}")
|
|
881
|
+
print()
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def login(api_key):
|
|
885
|
+
"""Authenticate with API key and save credentials locally."""
|
|
886
|
+
result = sb_rpc("mc_validate_api_key", {"p_api_key": api_key})
|
|
887
|
+
if not result or not result.get("valid"):
|
|
888
|
+
print("[MESHCODE] API key inválida o expirada")
|
|
889
|
+
return False
|
|
890
|
+
|
|
891
|
+
# Save credentials
|
|
892
|
+
config_dir = Path.home() / ".meshcode"
|
|
893
|
+
config_dir.mkdir(exist_ok=True)
|
|
894
|
+
creds = {
|
|
895
|
+
"api_key": api_key,
|
|
896
|
+
"user_id": result["user_id"],
|
|
897
|
+
"email": result["email"],
|
|
898
|
+
"display_name": result["display_name"]
|
|
899
|
+
}
|
|
900
|
+
(config_dir / "credentials.json").write_text(json.dumps(creds, indent=2))
|
|
901
|
+
print(f"[MESHCODE] Autenticado como {result['display_name']} ({result['email']})")
|
|
902
|
+
return True
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def show_help():
|
|
906
|
+
print("""
|
|
907
|
+
╔═══════════════════════════════════════════════════════════╗
|
|
908
|
+
║ MeshCode v4 — Supabase-backed Agent Network ║
|
|
909
|
+
╚═══════════════════════════════════════════════════════════╝
|
|
910
|
+
|
|
911
|
+
CORE:
|
|
912
|
+
register <proj> <name> [role] Join project
|
|
913
|
+
send <proj> <from>:<to> <message> Send message
|
|
914
|
+
broadcast <proj> <from> <message> Send to all
|
|
915
|
+
read <proj> <name> Read pending
|
|
916
|
+
check Hook auto-check
|
|
917
|
+
watch <proj> <name> [interval] [timeout] Wait for messages
|
|
918
|
+
|
|
919
|
+
STATUS:
|
|
920
|
+
board <proj> Agent status board
|
|
921
|
+
update <proj> <name> <status> [task] Update status
|
|
922
|
+
status [proj] Overview
|
|
923
|
+
projects List projects
|
|
924
|
+
|
|
925
|
+
HISTORY:
|
|
926
|
+
history <proj> [count] [agent1,agent2] Conversation log
|
|
927
|
+
|
|
928
|
+
SETUP:
|
|
929
|
+
connect <proj> <name> [claude|codex] One-command setup + hook install
|
|
930
|
+
login <api_key> Authenticate with API key
|
|
931
|
+
|
|
932
|
+
CLEANUP:
|
|
933
|
+
clear <proj> <name> Clear inbox
|
|
934
|
+
unregister <proj> <name> Leave project
|
|
935
|
+
|
|
936
|
+
BACKEND: Supabase (meshcode schema)
|
|
937
|
+
PLATFORM: macOS, Windows, Linux
|
|
938
|
+
|
|
939
|
+
Run `meshcode <command> --help` for help on a specific command.
|
|
940
|
+
""")
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
# Per-subcommand help texts
|
|
944
|
+
SUBCOMMAND_HELP = {
|
|
945
|
+
"register": """meshcode register <project> <name> [role]
|
|
946
|
+
|
|
947
|
+
Join a meshwork as a new agent. Tier limits enforced (free=3 agents).
|
|
948
|
+
|
|
949
|
+
ARGUMENTS:
|
|
950
|
+
project Meshwork name (created if doesn't exist)
|
|
951
|
+
name Unique agent name within the meshwork
|
|
952
|
+
role Optional human-readable role description
|
|
953
|
+
|
|
954
|
+
EXAMPLES:
|
|
955
|
+
meshcode register my-app backend "Backend Engineer"
|
|
956
|
+
meshcode register my-app frontend "React UI Developer"
|
|
957
|
+
""",
|
|
958
|
+
"send": """meshcode send <project> <from>:<to> <message>
|
|
959
|
+
|
|
960
|
+
Send a message from one agent to another within a meshwork.
|
|
961
|
+
|
|
962
|
+
ARGUMENTS:
|
|
963
|
+
project Meshwork name
|
|
964
|
+
from:to Sender agent name and recipient agent name, separated by colon
|
|
965
|
+
message JSON payload (preferred) or plain text
|
|
966
|
+
|
|
967
|
+
EXAMPLES:
|
|
968
|
+
meshcode send my-app backend:frontend '{"done":"API ready"}'
|
|
969
|
+
meshcode send my-app backend:commander '{"need":"review","priority":"urgent"}'
|
|
970
|
+
""",
|
|
971
|
+
"broadcast": """meshcode broadcast <project> <from> <message>
|
|
972
|
+
|
|
973
|
+
Send a message to all agents in the meshwork (except sender).
|
|
974
|
+
|
|
975
|
+
EXAMPLES:
|
|
976
|
+
meshcode broadcast my-app commander '{"fyi":"deployment in 10min"}'
|
|
977
|
+
""",
|
|
978
|
+
"read": """meshcode read <project> <name>
|
|
979
|
+
|
|
980
|
+
Read all pending (unread) messages for an agent. Marks them as read
|
|
981
|
+
and sends automatic ACKs to senders.
|
|
982
|
+
|
|
983
|
+
EXAMPLES:
|
|
984
|
+
meshcode read my-app backend
|
|
985
|
+
""",
|
|
986
|
+
"check": """meshcode check
|
|
987
|
+
|
|
988
|
+
Hook auto-check (silent if no pending messages).
|
|
989
|
+
Used internally by the Claude Code PostToolUse hook to detect
|
|
990
|
+
incoming messages between tool calls.
|
|
991
|
+
""",
|
|
992
|
+
"watch": """meshcode watch <project> <name> [interval] [timeout]
|
|
993
|
+
|
|
994
|
+
Block waiting for messages. Polls every <interval> seconds (default 10).
|
|
995
|
+
Exits when a message arrives or after <timeout> seconds (default 600 = 10 min).
|
|
996
|
+
|
|
997
|
+
EXAMPLES:
|
|
998
|
+
meshcode watch my-app backend # default 10s poll, 10min cycle
|
|
999
|
+
meshcode watch my-app backend 5 300 # poll 5s, exit after 5min
|
|
1000
|
+
""",
|
|
1001
|
+
"board": """meshcode board <project>
|
|
1002
|
+
|
|
1003
|
+
Show the status board for all agents in a meshwork.
|
|
1004
|
+
""",
|
|
1005
|
+
"update": """meshcode update <project> <name> <status> [task]
|
|
1006
|
+
|
|
1007
|
+
Update agent status. Statuses: online, working, idle, standby, blocked, done.
|
|
1008
|
+
|
|
1009
|
+
EXAMPLES:
|
|
1010
|
+
meshcode update my-app backend working "implementing auth"
|
|
1011
|
+
meshcode update my-app backend idle "available"
|
|
1012
|
+
""",
|
|
1013
|
+
"status": """meshcode status [project]
|
|
1014
|
+
|
|
1015
|
+
Show overview of all meshworks (or one specific meshwork).
|
|
1016
|
+
""",
|
|
1017
|
+
"history": """meshcode history <project> [count] [agent1,agent2]
|
|
1018
|
+
|
|
1019
|
+
Show conversation history. Optionally filter to messages between two agents.
|
|
1020
|
+
|
|
1021
|
+
EXAMPLES:
|
|
1022
|
+
meshcode history my-app 50
|
|
1023
|
+
meshcode history my-app 20 backend,frontend
|
|
1024
|
+
""",
|
|
1025
|
+
"projects": """meshcode projects
|
|
1026
|
+
|
|
1027
|
+
List all meshworks visible to the current API key.
|
|
1028
|
+
""",
|
|
1029
|
+
"connect": """meshcode connect <project> <name> [claude|codex] [role]
|
|
1030
|
+
|
|
1031
|
+
One-command setup: detect OS, register agent, install hook for the chosen
|
|
1032
|
+
host (Claude Code or Codex), and print quickstart commands.
|
|
1033
|
+
|
|
1034
|
+
EXAMPLES:
|
|
1035
|
+
meshcode connect my-app backend claude "Backend Engineer"
|
|
1036
|
+
meshcode connect my-app worker codex "Data Worker"
|
|
1037
|
+
""",
|
|
1038
|
+
"login": """meshcode login <api_key>
|
|
1039
|
+
|
|
1040
|
+
Authenticate with a MeshCode API key. Saves credentials to
|
|
1041
|
+
~/.meshcode/credentials.json. Get a key from meshcode.io/settings or
|
|
1042
|
+
during the welcome wizard after signup.
|
|
1043
|
+
""",
|
|
1044
|
+
"clear": """meshcode clear <project> <name>
|
|
1045
|
+
|
|
1046
|
+
Clear inbox: marks all unread messages as read for an agent.
|
|
1047
|
+
""",
|
|
1048
|
+
"unregister": """meshcode unregister <project> <name>
|
|
1049
|
+
|
|
1050
|
+
Remove an agent from a meshwork (deletes the row from mc_agents).
|
|
1051
|
+
""",
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def show_subcommand_help(cmd):
|
|
1056
|
+
"""Print help for a specific subcommand."""
|
|
1057
|
+
text = SUBCOMMAND_HELP.get(cmd)
|
|
1058
|
+
if text:
|
|
1059
|
+
print(text)
|
|
1060
|
+
else:
|
|
1061
|
+
print(f"[ERROR] No help available for '{cmd}'")
|
|
1062
|
+
show_help()
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
# ============================================================
|
|
1066
|
+
# MAIN — same CLI interface as v3
|
|
1067
|
+
# ============================================================
|
|
1068
|
+
|
|
1069
|
+
def parse_flags(argv):
|
|
1070
|
+
"""Parse --flag value pairs from argv, return (flags_dict, remaining_positional_args)."""
|
|
1071
|
+
flags = {}
|
|
1072
|
+
positional = []
|
|
1073
|
+
i = 0
|
|
1074
|
+
while i < len(argv):
|
|
1075
|
+
if argv[i].startswith("--") and i + 1 < len(argv):
|
|
1076
|
+
flags[argv[i][2:]] = argv[i + 1]
|
|
1077
|
+
i += 2
|
|
1078
|
+
else:
|
|
1079
|
+
positional.append(argv[i])
|
|
1080
|
+
i += 1
|
|
1081
|
+
return flags, positional
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
if __name__ == "__main__":
|
|
1085
|
+
if len(sys.argv) < 2:
|
|
1086
|
+
show_help()
|
|
1087
|
+
sys.exit(0)
|
|
1088
|
+
|
|
1089
|
+
cmd = sys.argv[1].lower()
|
|
1090
|
+
|
|
1091
|
+
# Per-subcommand help: meshcode <cmd> --help / -h / help
|
|
1092
|
+
if len(sys.argv) >= 3 and sys.argv[2] in ("--help", "-h", "help"):
|
|
1093
|
+
show_subcommand_help(cmd)
|
|
1094
|
+
sys.exit(0)
|
|
1095
|
+
|
|
1096
|
+
flags, pos = parse_flags(sys.argv[2:])
|
|
1097
|
+
|
|
1098
|
+
if cmd == "register":
|
|
1099
|
+
proj = flags.get("project", pos[0] if len(pos) > 0 else "default")
|
|
1100
|
+
name = flags.get("name", pos[1] if len(pos) > 1 else "agent")
|
|
1101
|
+
role = flags.get("role", " ".join(pos[2:]) if len(pos) > 2 else "")
|
|
1102
|
+
register(proj, name, role)
|
|
1103
|
+
|
|
1104
|
+
elif cmd == "send":
|
|
1105
|
+
# Supports both:
|
|
1106
|
+
# send <project> <from>:<to> <message>
|
|
1107
|
+
# send --project X --from Y --to Z --msg "text" [--type T]
|
|
1108
|
+
if "from" in flags and "to" in flags:
|
|
1109
|
+
proj = flags.get("project", pos[0] if len(pos) > 0 else "default")
|
|
1110
|
+
from_a = flags["from"]
|
|
1111
|
+
to_a = flags["to"]
|
|
1112
|
+
message = flags.get("msg", flags.get("message", " ".join(pos)))
|
|
1113
|
+
msg_type = flags.get("type", "msg")
|
|
1114
|
+
send_msg(proj, from_a, to_a, message, msg_type)
|
|
1115
|
+
else:
|
|
1116
|
+
proj = pos[0] if len(pos) > 0 else "default"
|
|
1117
|
+
target = pos[1] if len(pos) > 1 else ""
|
|
1118
|
+
message = " ".join(pos[2:]) if len(pos) > 2 else ""
|
|
1119
|
+
if ":" in target:
|
|
1120
|
+
from_a, to_a = target.split(":", 1)
|
|
1121
|
+
else:
|
|
1122
|
+
from_a, to_a = "?", target
|
|
1123
|
+
send_msg(proj, from_a, to_a, message)
|
|
1124
|
+
|
|
1125
|
+
elif cmd == "broadcast":
|
|
1126
|
+
if "from" in flags:
|
|
1127
|
+
proj = flags.get("project", pos[0] if len(pos) > 0 else "default")
|
|
1128
|
+
from_a = flags["from"]
|
|
1129
|
+
message = flags.get("msg", flags.get("message", " ".join(pos)))
|
|
1130
|
+
msg_type = flags.get("type", "broadcast")
|
|
1131
|
+
broadcast(proj, from_a, message, msg_type)
|
|
1132
|
+
else:
|
|
1133
|
+
proj = pos[0] if len(pos) > 0 else "default"
|
|
1134
|
+
from_a = pos[1] if len(pos) > 1 else "?"
|
|
1135
|
+
message = " ".join(pos[2:]) if len(pos) > 2 else ""
|
|
1136
|
+
broadcast(proj, from_a, message)
|
|
1137
|
+
|
|
1138
|
+
elif cmd == "read":
|
|
1139
|
+
proj = flags.get("project", pos[0] if len(pos) > 0 else "default")
|
|
1140
|
+
name = flags.get("name", pos[1] if len(pos) > 1 else "agent")
|
|
1141
|
+
read_messages(proj, name)
|
|
1142
|
+
|
|
1143
|
+
elif cmd == "check":
|
|
1144
|
+
hook_check()
|
|
1145
|
+
|
|
1146
|
+
elif cmd == "watch":
|
|
1147
|
+
proj = flags.get("project", pos[0] if len(pos) > 0 else "default")
|
|
1148
|
+
name = flags.get("name", pos[1] if len(pos) > 1 else "agent")
|
|
1149
|
+
interval = int(flags.get("interval", pos[2] if len(pos) > 2 else "10"))
|
|
1150
|
+
timeout = int(flags.get("timeout", pos[3] if len(pos) > 3 else "0"))
|
|
1151
|
+
watch(proj, name, interval, timeout)
|
|
1152
|
+
|
|
1153
|
+
elif cmd == "board":
|
|
1154
|
+
proj = flags.get("project", pos[0] if len(pos) > 0 else "default")
|
|
1155
|
+
show_board(proj)
|
|
1156
|
+
|
|
1157
|
+
elif cmd == "update":
|
|
1158
|
+
proj = flags.get("project", pos[0] if len(pos) > 0 else "default")
|
|
1159
|
+
name = flags.get("name", pos[1] if len(pos) > 1 else "agent")
|
|
1160
|
+
st = flags.get("status", pos[2] if len(pos) > 2 else "online")
|
|
1161
|
+
task = flags.get("task", " ".join(pos[3:]) if len(pos) > 3 else "")
|
|
1162
|
+
update_board(proj, name, st, task)
|
|
1163
|
+
|
|
1164
|
+
elif cmd == "status":
|
|
1165
|
+
proj = sys.argv[2] if len(sys.argv) > 2 else None
|
|
1166
|
+
show_status(proj)
|
|
1167
|
+
|
|
1168
|
+
elif cmd == "projects":
|
|
1169
|
+
list_projects()
|
|
1170
|
+
|
|
1171
|
+
elif cmd == "history":
|
|
1172
|
+
proj = sys.argv[2] if len(sys.argv) > 2 else "default"
|
|
1173
|
+
count = int(sys.argv[3]) if len(sys.argv) > 3 and sys.argv[3].isdigit() else 20
|
|
1174
|
+
between = sys.argv[4] if len(sys.argv) > 4 else None
|
|
1175
|
+
if len(sys.argv) > 3 and not sys.argv[3].isdigit():
|
|
1176
|
+
between = sys.argv[3]
|
|
1177
|
+
show_history(proj, count, between)
|
|
1178
|
+
|
|
1179
|
+
elif cmd == "clear":
|
|
1180
|
+
proj = sys.argv[2] if len(sys.argv) > 2 else "default"
|
|
1181
|
+
name = sys.argv[3] if len(sys.argv) > 3 else "agent"
|
|
1182
|
+
clear_inbox(proj, name)
|
|
1183
|
+
|
|
1184
|
+
elif cmd == "unregister":
|
|
1185
|
+
proj = sys.argv[2] if len(sys.argv) > 2 else "default"
|
|
1186
|
+
name = sys.argv[3] if len(sys.argv) > 3 else "agent"
|
|
1187
|
+
unregister(proj, name)
|
|
1188
|
+
|
|
1189
|
+
elif cmd == "connect":
|
|
1190
|
+
proj = sys.argv[2] if len(sys.argv) > 2 else "default"
|
|
1191
|
+
name = sys.argv[3] if len(sys.argv) > 3 else "agent"
|
|
1192
|
+
hook = sys.argv[4] if len(sys.argv) > 4 else "claude"
|
|
1193
|
+
connect(proj, name, hook)
|
|
1194
|
+
|
|
1195
|
+
elif cmd == "login":
|
|
1196
|
+
key = sys.argv[2] if len(sys.argv) > 2 else ""
|
|
1197
|
+
if not key:
|
|
1198
|
+
print("[ERROR] Uso: meshcode login <api_key>")
|
|
1199
|
+
sys.exit(1)
|
|
1200
|
+
login(key)
|
|
1201
|
+
|
|
1202
|
+
elif cmd in ("help", "--help", "-h"):
|
|
1203
|
+
show_help()
|
|
1204
|
+
|
|
1205
|
+
else:
|
|
1206
|
+
print(f"[ERROR] Comando desconocido: {cmd}")
|
|
1207
|
+
show_help()
|
|
1208
|
+
sys.exit(1)
|