Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion skills/ai-peer/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Peer-to-peer conversation rooms for AI CLI tools (Claude Code, Codex, OpenCode)
- **Auth**: Local daemon: none. Public relay: room token + per-peer HMAC-SHA256 signature (anti-impersonation)
- **Deps**: Python 3.10+ (stdlib only, zero pip deps)
- **Relay**: `https://relay.ai-peer.chat` (Durable Objects, strong consistency)
- **Operations**: 16 (daemon 3, room 4, chat 2, invite 1, quick 1, discuss 1, peer 3, export 1)
- **Operations**: 16 sub-commands across 12 top-level commands (daemon: start/stop/status, room: create/join/list/delete, chat: send/read, invite, quick, discuss, discover, list, register, history, export, identity)
- **Storage**: Local SQLite at `~/.ai-peers/peers.db`, daemon config at `~/.ai-peers/daemon.json`
- **Tests**: 69 (unit + integration + crypto + auth)

Expand Down
4 changes: 2 additions & 2 deletions skills/ai-peer/references/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ The closest attempt (claude-peers-mcp) is single-machine, Claude-only, and requi
| Component | Tech | Purpose |
|-----------|------|---------|
| **Daemon** | Python stdlib `http.server` + SQLite | Local message store + HTTP API for CLI commands |
| **CLI** | Python `argparse` | 16 commands: daemon, room, chat, invite, quick, discuss, discover, etc. |
| **CLI** | Python manual dispatch | 12 top-level commands (daemon, room, chat, invite, quick, discuss, discover, list, register, history, export, identity) with sub-commands |
| **Relay** | Cloudflare Worker + Durable Objects | Cross-internet message forwarding, strong consistency |
| **Crypto** | PBKDF2 + Fernet (`cryptography` package) | Optional E2E encryption, relay stores only ciphertext |
| **Spawner** | `subprocess.run` | Invoke AI CLI tools with conversation context as prompt |
Expand Down Expand Up @@ -186,7 +186,7 @@ The core differentiator — bring any AI CLI tool into a conversation.
### P1: Quality & Reliability
- SQLite WAL mode + threading locks for concurrent access
- Cross-platform signal handling (SIGTERM/SIGBREAK)
- 40 automated tests (unit + integration + crypto)
- 69 automated tests (unit + integration + crypto + auth)
- Grade A on skill-optimizer (40/40)
- 3-AI deep review (Claude+Codex+OpenCode), 10 HIGH issues fixed

Expand Down
20 changes: 16 additions & 4 deletions skills/ai-peer/scripts/ai_peer/__init__.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,29 @@
"""AI Peer — decentralized AI-to-AI and human-AI communication."""
import json
import sys
import os

# Force UTF-8 output on Windows to handle Unicode characters in AI responses
if sys.platform == "win32":
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
for stream_name in ("stdout", "stderr"):
stream = getattr(sys, stream_name, None)
if stream and hasattr(stream, "reconfigure"):
try:
stream.reconfigure(encoding="utf-8")
except Exception:
pass


def output(ok, data=None, error=None):
"""Standard output envelope."""
"""Standard output envelope. Returns exit code."""
result = {"ok": ok, "schema_version": "1"}
if ok:
result["data"] = data
else:
result["error"] = str(error)
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(0 if ok else 1)
return 0 if ok else 1


def main():
Expand All @@ -38,11 +50,11 @@ def main():
try:
from .ops import run_command
result = run_command(args)
output(True, result)
sys.exit(output(True, result))
except KeyboardInterrupt:
sys.exit(130)
except Exception as e:
output(False, error=e)
sys.exit(output(False, error=e))


if __name__ == "__main__":
Expand Down
18 changes: 15 additions & 3 deletions skills/ai-peer/scripts/ai_peer/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ def _req(self, method, path, data=None, timeout=30):
except Exception:
return {"error": f"HTTP {e.code}"}
except (urllib.error.URLError, OSError):
return None # Daemon not running or connection failed
return {"error": "daemon not running or connection failed"}

def is_alive(self):
r = self._req("GET", "/health", timeout=3)
return r is not None and r.get("ok")
return isinstance(r, dict) and r.get("ok")

# === Rooms ===

Expand Down Expand Up @@ -142,7 +142,12 @@ def ensure_daemon(host=None, port=None):
# Need to restart with new config
sys.stderr.write(f"⟳ Restarting daemon ({cfg_host}:{cfg_port} → {host}:{port})...\n")
_stop_daemon()
time.sleep(0.5)
# Wait for old daemon to fully stop before starting new one
for _ in range(20):
time.sleep(0.2)
check_client = PeerClient(check_host, port)
if not check_client.is_alive():
break
else:
return client

Expand Down Expand Up @@ -187,6 +192,13 @@ def _start_daemon(host, port):

PEERS_HOME.mkdir(parents=True, exist_ok=True)

# Rotate log if too large (>1MB)
if LOG_FILE.exists() and LOG_FILE.stat().st_size > 1_000_000:
rotated = LOG_FILE.with_suffix(".log.1")
if rotated.exists():
rotated.unlink()
LOG_FILE.rename(rotated)

scripts_dir = str(Path(__file__).resolve().parent.parent)
existing_pypath = os.environ.get("PYTHONPATH", "")
pypath = scripts_dir + os.pathsep + existing_pypath if existing_pypath else scripts_dir
Expand Down
13 changes: 10 additions & 3 deletions skills/ai-peer/scripts/ai_peer/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,19 @@
"env_unset": ["CLAUDECODE"],
},
"opencode": {
"cmd": ["opencode", "-p", "{prompt}"],
"cmd": ["opencode", "run", "{prompt}"],
"env_unset": [],
},
}


def get_machine_id():
"""Stable machine identifier."""
return platform.node()
"""Stable machine identifier with fallback chain."""
name = platform.node()
if not name:
try:
import socket
name = socket.gethostname()
except Exception:
pass
return name or "unknown"
17 changes: 14 additions & 3 deletions skills/ai-peer/scripts/ai_peer/crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,21 @@ def decrypt(ciphertext: str, password: str, room_id: str, strict: bool = False)
f = Fernet(key)
try:
return f.decrypt(ciphertext.encode()).decode()
except Exception:
except InvalidToken:
return "[encrypted — wrong password or corrupted]"


def is_encrypted(text: str) -> bool:
"""Check if text looks like a Fernet token."""
return text.startswith("gAAA") and len(text) > 50
"""Check if text looks like a Fernet token by attempting to decode it."""
if not text or not isinstance(text, str):
return False
if not HAS_CRYPTO:
return text.startswith("gAAA") and len(text) > 50
try:
# Fernet tokens are URL-safe base64, decode to check structure
import base64
decoded = base64.urlsafe_b64decode(text + "==")
# Fernet tokens start with version byte (0x80) + 8-byte timestamp
return len(decoded) > 9 and decoded[0] == 0x80
except Exception:
return False
4 changes: 3 additions & 1 deletion skills/ai-peer/scripts/ai_peer/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ def __init__(self, db_path: Path):
self.conn.executescript(SCHEMA)
self._run_migrations()
self._lock = threading.Lock()
# WAL mode allows concurrent reads without locks — only writes need the lock.
# Reads (get_room, get_messages, list_rooms, room_peers) are safe without _lock.

def _run_migrations(self):
for sql in MIGRATIONS:
Expand Down Expand Up @@ -115,7 +117,7 @@ def create_room(self, name, mode="local", relay_url=None, password=None, room_id
(rid, name, mode, relay_url, password, token, _now()),
)
self.conn.commit()
return _row_to_dict(self.conn.execute("SELECT * FROM rooms WHERE id=?", (rid,)).fetchone())
return _row_to_dict(self.conn.execute("SELECT * FROM rooms WHERE id=?", (rid,)).fetchone())

def get_room(self, room_id):
return _row_to_dict(self.conn.execute("SELECT * FROM rooms WHERE id=?", (room_id,)).fetchone())
Expand Down
12 changes: 9 additions & 3 deletions skills/ai-peer/scripts/ai_peer/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,9 @@ def _merge_messages(local_resp, relay_resp):
seen_sys.add(sys_key)
elif not msg_id:
# Non-system without ID: use content hash as fallback
fallback = hashlib.md5(
fallback = hashlib.sha256(
f"{msg.get('peer_id', '')}:{msg.get('content', '')}:{msg.get('created_at', '')}".encode()
).hexdigest()
).hexdigest()[:16]
if fallback in seen_ids:
continue
seen_ids.add(fallback)
Expand Down Expand Up @@ -186,7 +186,13 @@ def _peer_signature(peer_secret, room_id):

def get_or_create_identity():
"""Get or create local human identity. Auto-migrates old PID-based IDs."""
username = os.environ.get("USER", os.environ.get("USERNAME", "user"))
username = os.environ.get("USER", os.environ.get("USERNAME"))
if not username:
try:
import getpass
username = getpass.getuser()
Comment on lines +191 to +193

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep fallback identities stable when env vars are absent

When neither USER nor USERNAME is set, previous versions generated the human id with the stable fallback name user; this new fallback commonly returns the OS account such as root. Upgrading in that environment rewrites an existing *-human-user identity to *-human-root, so prior room membership and relay identity no longer match the user's existing peer. Preserve the old fallback for existing identities or only use getpass when creating a brand-new identity.

Useful? React with 👍 / 👎.

except Exception:
username = "user"
expected_id = f"{get_machine_id()}-human-{username}"

if IDENTITY_FILE.exists():
Expand Down
39 changes: 27 additions & 12 deletions skills/ai-peer/scripts/ai_peer/ops_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import secrets
import sys

from .constants import get_machine_id
from .constants import get_machine_id, TOOL_COMMANDS
from .client import ensure_daemon
from .relay_client import RelayClient
from .spawn import build_conversation_prompt, spawn_ai
Expand All @@ -16,6 +16,14 @@
)


def _safe_int(value, name):
"""Parse int with clear error message."""
try:
return int(value)
except (ValueError, TypeError):
raise ValueError(f"Invalid {name}: {value!r} (must be a number)")


def cmd_invite(args):
if not args:
raise ValueError(
Expand All @@ -34,7 +42,7 @@ def cmd_invite(args):
elif args[i] == "--context" and i + 1 < len(args):
context = args[i + 1]; i += 2
elif args[i] == "--timeout" and i + 1 < len(args):
timeout = int(args[i + 1]); i += 2
timeout = _safe_int(args[i + 1], "timeout"); i += 2
elif not tool:
tool = args[i]; i += 1
else:
Expand All @@ -49,7 +57,10 @@ def cmd_invite(args):
identity = get_or_create_identity()
human_secret = identity.get("peer_secret", "")
# Derive per-AI secret from human secret + tool name (true per-peer auth)
ai_secret = hmac.new(human_secret.encode(), tool.encode(), hashlib.sha256).hexdigest() if human_secret else ""
if human_secret and len(human_secret) >= 16:
ai_secret = hmac.new(human_secret.encode(), tool.encode(), hashlib.sha256).hexdigest()
else:
ai_secret = ""
ai_peer_id = make_ai_peer_id(tool)
client.register_peer(ai_peer_id, tool, "ai", tool, get_machine_id())
client.join_room(room_id, ai_peer_id)
Expand Down Expand Up @@ -93,7 +104,9 @@ def cmd_invite(args):
peer_name=tool, peer_tool=tool, peer_signature=sig,
)

mentions = re.findall(r"@(claude-code|codex|opencode)\b", response, re.IGNORECASE)
# Build dynamic @mention regex from known tools
tool_names = "|".join(re.escape(t) for t in TOOL_COMMANDS)
mentions = re.findall(rf"@({tool_names})\b", response, re.IGNORECASE)
mentions = list(dict.fromkeys(m.lower() for m in mentions if m.lower() != tool.lower()))

result = {"tool": tool, "response": response, "room": room_id}
Expand Down Expand Up @@ -126,7 +139,7 @@ def cmd_quick(args):
elif args[i] == "--name" and i + 1 < len(args):
room_name = args[i + 1]; i += 2
elif args[i] == "--timeout" and i + 1 < len(args):
timeout = int(args[i + 1]); i += 2
timeout = _safe_int(args[i + 1], "timeout"); i += 2
elif not question and not args[i].startswith("-"):
question = args[i]; i += 1
else:
Expand Down Expand Up @@ -168,8 +181,8 @@ def cmd_quick(args):
for tool in tools:
sys.stderr.write(f"⏳ Inviting {tool}...\n")
try:
result = cmd_invite(["--tool", tool, "--room", room_id,
"--timeout", str(timeout)])
invite_args = ["--tool", tool, "--room", room_id, "--timeout", str(timeout)]
result = cmd_invite(invite_args)
responses[tool] = result.get("response", "")
sys.stderr.write(f"✓ {tool} responded ({len(responses[tool])} chars)\n")
except Exception as e:
Expand All @@ -190,11 +203,11 @@ def cmd_discuss(args):
"""Run a multi-round discussion between AIs."""
if not args:
raise ValueError(
"Usage: peer discuss --tools <t1,t2> --rounds N [--room <id>] [--context \"topic\"]\n"
"Usage: peer discuss --tools <t1,t2> --rounds N [--room <id>] [--name <name>] [--context \"topic\"]\n"
" Without --room, creates a new room automatically."
)

room_id = tools_csv = context = None
room_id = tools_csv = context = room_name = None
rounds = 2
timeout = 120
i = 0
Expand All @@ -204,11 +217,13 @@ def cmd_discuss(args):
elif args[i] == "--tools" and i + 1 < len(args):
tools_csv = args[i + 1]; i += 2
elif args[i] == "--rounds" and i + 1 < len(args):
rounds = int(args[i + 1]); i += 2
rounds = _safe_int(args[i + 1], "rounds"); i += 2
elif args[i] == "--context" and i + 1 < len(args):
context = args[i + 1]; i += 2
elif args[i] == "--timeout" and i + 1 < len(args):
timeout = int(args[i + 1]); i += 2
timeout = _safe_int(args[i + 1], "timeout"); i += 2
elif args[i] == "--name" and i + 1 < len(args):
room_name = args[i + 1]; i += 2
else:
i += 1

Expand All @@ -222,7 +237,7 @@ def cmd_discuss(args):
client = ensure_daemon()

if not room_id:
room = client.create_room("discussion", "local")
room = client.create_room(room_name or "discussion", "local")
if not room or room.get("error"):
raise RuntimeError(f"Failed to create room: {(room or {}).get('error', 'daemon unreachable')}")
room_id = room["id"]
Expand Down
13 changes: 9 additions & 4 deletions skills/ai-peer/scripts/ai_peer/ops_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ def cmd_chat(args):
elif rest[i] in ("--interactive", "-i"):
interactive = True; i += 1
elif rest[i] == "-n" and i + 1 < len(rest):
limit = int(rest[i + 1]); i += 2
try:
limit = int(rest[i + 1])
except ValueError:
raise ValueError(f"Invalid number for -n: {rest[i + 1]}")
i += 2
else:
message_parts.append(rest[i]); i += 1

Expand Down Expand Up @@ -108,7 +112,7 @@ def _display_msg(msg):

def _on_ws_message(data):
if data.get("type") == "message" and data.get("data"):
msg = data["data"]
msg = dict(data["data"]) # Copy to avoid mutating shared data
# WS delivers raw ciphertext — decrypt here (poll path decrypts in _read_messages)
if password and msg.get("type") != "system":
msg["content"] = decrypt(msg["content"], password, room_id, strict=True)
Expand Down Expand Up @@ -136,8 +140,8 @@ def _on_ws_close():
)
ws_client.on_close = _on_ws_close
ws_connected[0] = True
except Exception:
pass
except Exception as e:
sys.stderr.write(f" WebSocket unavailable: {e}\n")

# Always fetch initial context regardless of WS status
try:
Expand Down Expand Up @@ -241,5 +245,6 @@ def poll():
running[0] = False
if ws_client:
ws_client.close()
poller.join(timeout=2)
sys.stderr.write("\nLeft the room.\n")
return {"status": "exited"}
Loading