Skill: jeffchang2024/lobster-comm (version 1.1.0)

--- name: lobster-comm description: > Bot-to-Bot P2P communication over Tailscale using the LCP/1.1 UDP protocol. Enables AI agents on different machines to exchange signed, reliable messages in real-time. Features Ed25519 cryptographic signing, application-layer ACK with retransmission, heartbeat keep-alive, duplicate detection, and local IPC daemon control. Use when you need inter-agent communication, bot-to-bot messaging, cross-machine task delegation, or distributed agent orchestration over a Tailscale network. Triggers: "bot-to-bot", "inter-agent", "P2P messaging", "lobster-comm", "cross-machine communication", "agent delegation", "distributed agents" --- # Lobster Comm 🦞 — LCP/1.1 Peer-to-peer messaging between AI agents on different machines via UDP over Tailscale. ## Architecture ``` Agent A (Machine 1) Agent B (Machine 2) ┌──────────────────┐ ┌──────────────────┐ │ lcp_node.py │ UDP :9528 │ lcp_node.py │ │ (daemon) │ ◄────────────► │ (daemon) │ │ │ Ed25519 signed │ │ │ IPC: Unix sock │ ACK + Retry │ IPC: TCP :9529 │ └──────────────────┘ └──────────────────┘ ▲ ▲ │ Local IPC │ Local IPC lcp_send / lcp_check / lcp_ack (same commands) ``` ## Features - **UDP direct**: Low-latency messaging over Tailscale (vs file-based transfers) - **Ed25519 signing**: Every message cryptographically signed and verified - **Reliable delivery**: Application-layer ACK + exponential backoff retry (5 attempts) - **Heartbeat**: 30s keep-alive, 120s peer timeout detection - **Duplicate detection**: Idempotent message receipt via seen-ID tracking - **Daemon model**: Background service with local IPC for agent interaction - **Cross-platform**: macOS (Unix socket IPC) + Windows (TCP localhost IPC) ## Prerequisites - Python 3.9+ - [PyNaCl](https://pypi.org/project/PyNaCl/) (`pip install pynacl`) - [Tailscale](https://tailscale.com/) with both machines on the same Tailnet - UDP port 9528 open between peers ## Quick Start ### 1. Configure peer IPs Edit `scripts/lcp_node.py` and set: ```python MY_NAME = "AgentA" # Your agent name PEER_NAME = "AgentB" # Peer agent name MY_IP = "100.x.x.x" # Your Tailscale IP PEER_IP = "100.y.y.y" # Peer Tailscale IP ``` ### 2. Start daemon ```bash # macOS python3 scripts/lcp_node.py # Windows python scripts/windows/lcp_node_win.py ``` ### 3. Send & receive ```bash # Send a message python3 scripts/lcp_send.py "Hello from Agent A!" python3 scripts/lcp_send.py --type task "Please process data X" python3 scripts/lcp_send.py --type result --reply-to <msg-id> "Done!" # Check inbox python3 scripts/lcp_check.py --pretty # Archive processed messages python3 scripts/lcp_ack.py # Check daemon status python3 scripts/lcp_status.py ``` ## Message Protocol JSON messages with structure: ```json { "id": "uuid", "from": "AgentA", "to": "AgentB", "timestamp": "ISO-8601", "type": "chat|task|result|ping|pong", "message": "content", "replyTo": "optional-msg-id", "security": { "algo": "ed25519", "pubkey": "<base64>", "signature": "<base64>" } } ``` ## Wire Format (UDP) ``` [Magic 4B: "LCP1"][Seq 4B][Type 1B][JSON payload...] Type: 0x01=DATA, 0x02=ACK, 0x03=HEARTBEAT ``` ## Auto-start (macOS) Create a LaunchAgent plist pointing to `lcp_node.py` with `RunAtLoad=true` and `KeepAlive` for crash recovery. ## Auto-start (Windows) Use Task Scheduler or [nssm](https://nssm.cc/) to run `lcp_node_win.py` as a service. ## Data Directories - `data/inbox/` — incoming messages (pending) - `data/inbox_archive/` — processed messages - `data/outbox/` — sent message copies - `data/seen_ids.json` — duplicate detection state ## Configuration Reference | Parameter | Default | Description | |-----------|---------|-------------| | LCP_PORT | 9528 | UDP port for peer communication | | IPC_SOCKET | /tmp/lcp.sock | Unix socket for local IPC (macOS) | | IPC_PORT | 9529 | TCP port for local IPC (Windows) | | MAX_RETRIES | 5 | ACK retry attempts | | HEARTBEAT_INTERVAL_S | 30 | Seconds between heartbeats | | PEER_TIMEOUT_S | 120 | Seconds before marking peer offline | ## Extending For multi-peer support, run multiple daemon instances on different ports or extend `lcp_node.py` with a peer registry. See `references/protocol-spec.md` for the full LCP/1.1 specification.

path: references/protocol-spec.md
content: # LCP/1.1 Protocol Specification

## Overview

Lobster Communication Protocol (LCP) version 1.1 is a lightweight, reliable messaging protocol designed for AI agent-to-agent communication over UDP. It provides message signing, acknowledgment-based reliability, and peer liveness detection.

## Design Goals

1. **Low latency**: UDP-based, no connection setup overhead
2. **Reliability**: Application-layer ACK with exponential backoff
3. **Security**: Ed25519 cryptographic message signing
4. **Simplicity**: Minimal wire format, JSON payloads
5. **Resilience**: Duplicate detection, heartbeat keep-alive, crash recovery

## Wire Format

Every LCP packet has a fixed 9-byte header followed by an optional payload:

```
Offset  Size  Field
0       4B    Magic Number: ASCII "LCP1" (0x4C435031)
4       4B    Sequence Number: uint32, big-endian, monotonically increasing
8       1B    Message Type: 0x01=DATA, 0x02=ACK, 0x03=HEARTBEAT
9+      var   Payload (JSON for DATA, empty for ACK/HEARTBEAT)
```

### Message Types

#### DATA (0x01)

Carries a JSON-encoded message. The receiver MUST:
1. Send an ACK with the same sequence number
2. Parse and verify the JSON payload
3. Check the Ed25519 signature
4. Check for duplicate message IDs
5. Store the message if valid and new

#### ACK (0x02)

Acknowledges receipt of a DATA or HEARTBEAT packet. Contains only the header (no payload). The sequence number matches the packet being acknowledged.

#### HEARTBEAT (0x03)

Keep-alive probe sent periodically. The receiver SHOULD respond with an ACK. Used to track peer liveness.

## Reliability

### Transmission

1. Sender assigns a unique sequence number
2. Sender transmits the packet
3. Sender waits for ACK up to `RETRY_BASE_MS * 2^attempt` milliseconds
4. If no ACK received, retransmit (up to MAX_RETRIES times)
5. If all retries exhausted, report delivery failure

### Duplicate Detection

Receivers maintain a set of seen message IDs (from the JSON `id` field). Messages with previously-seen IDs are silently dropped after sending an ACK. The seen-ID set is persisted to disk and trimmed to MAX_SEEN entries.

## Security

### Message Signing

Every DATA message includes a `security` block:

```json
{
  "security": {
    "algo": "ed25519",
    "pubkey": "<base64-encoded-public-key>",
    "signature": "<base64-encoded-signature>"
  }
}
```

**Signing process:**
1. Remove the `security` field from the message
2. Canonicalize: `JSON.dumps(msg, sort_keys=True, separators=(',', ':'))`
3. Sign the canonical bytes with the sender's Ed25519 private key
4. Attach the signature and public key in the `security` block

**Verification process:**
1. Extract and remove the `security` block
2. Canonicalize the remaining message (same method)
3. Verify the signature against the public key

### Key Management

Each node generates an Ed25519 keypair on first run. The 32-byte seed is stored locally. In production, consider a trust-on-first-use (TOFU) model or pre-shared public key exchange.

## Message Format (JSON Payload)

```json
{
  "id": "UUID v4",
  "from": "sender-agent-name",
  "to": "receiver-agent-name",
  "timestamp": "ISO-8601 with timezone",
  "type": "chat|task|result|ping|pong",
  "message": "human-readable content",
  "replyTo": "optional: ID of message being replied to",
  "security": { ... }
}
```

### Message Types

| Type | Purpose | Expected Response |
|------|---------|-------------------|
| `ping` | Connectivity test | `pong` |
| `pong` | Ping response | None |
| `chat` | General conversation | Optional `chat` reply |
| `task` | Task delegation | `result` with outcome |
| `result` | Task completion report | None |

## IPC Interface

The daemon exposes a local IPC interface for agent interaction:

| Command | Description | Response |
|---------|-------------|----------|
| `SEND` | Send a message to peer | `{ok, id}` |
| `CHECK` | List inbox messages | `{count, messages}` |
| `ACK` | Archive all inbox messages | `{archived}` |
| `STATUS` | Daemon and peer status | `{peer, peer_online, inbox_count, uptime}` |
| `PING` | Send a ping to peer | `{ok}` |

**macOS**: Unix domain socket at `/tmp/lcp.sock`
**Windows**: TCP socket at `127.0.0.1:9529`

## Configuration Defaults

```
LCP_PORT           = 9528
MAX_RETRIES        = 5
RETRY_BASE_MS      = 500
ACK_TIMEOUT_S      = 3.0
HEARTBEAT_INTERVAL = 30s
PEER_TIMEOUT       = 120s
MAX_SEEN_IDS       = 10000
```

## Future Considerations

- **Multi-peer**: Peer registry with per-peer state tracking
- **Encryption**: Add NaCl Box encryption for payload confidentiality
- **Chunking**: Fragment large messages across multiple DATA packets
- **NAT traversal**: STUN/TURN for peers without Tailscale
- **Group messaging**: Multicast or relay-based group channels


path: scripts/lcp_ack.py
content: #!/usr/bin/env python3
"""
Archive processed inbox messages via LCP daemon (IPC).
Replaces ack.py for UDP mode.

Usage:
    python3 lcp_ack.py
"""

import sys
import os
import json
import socket
import glob

IPC_SOCKET = "/tmp/lcp.sock"

SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
INBOX_DIR = os.path.join(SKILL_DIR, "data", "inbox")
INBOX_ARCHIVE = os.path.join(SKILL_DIR, "data", "inbox_archive")
os.makedirs(INBOX_ARCHIVE, exist_ok=True)

def ack_via_ipc():
    req = json.dumps({"cmd": "ACK"})
    sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    sock.settimeout(10.0)
    try:
        sock.connect(IPC_SOCKET)
        sock.sendall(req.encode('utf-8'))
        raw = sock.recv(65536)
        return json.loads(raw.decode('utf-8'))
    except (ConnectionRefusedError, FileNotFoundError):
        return None
    finally:
        sock.close()

def ack_direct():
    """Fallback: archive directly."""
    archived = 0
    for fp in glob.glob(os.path.join(INBOX_DIR, "msg_*.json")):
        try:
            fname = os.path.basename(fp)
            dst = os.path.join(INBOX_ARCHIVE, fname)
            os.rename(fp, dst)
            archived += 1
        except:
            pass
    return {"archived": archived}

if __name__ == "__main__":
    result = ack_via_ipc()
    if result is None:
        result = ack_direct()
        result["_source"] = "direct"
    else:
        result["_source"] = "daemon"

    print(json.dumps(result, ensure_ascii=False, indent=2))
    print(f"✅ Archived {result.get('archived', 0)} message(s)")


path: scripts/lcp_check.py
content: #!/usr/bin/env python3
"""
Check inbox via LCP/1.1 daemon (IPC).
Replaces check_and_notify.py for UDP mode.

Usage:
    python3 lcp_check.py           # JSON output
    python3 lcp_check.py --pretty  # Human-readable
"""

import sys
import os
import json
import socket
import glob

IPC_SOCKET = "/tmp/lcp.sock"

# Fallback: direct file read if daemon is not running
SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
INBOX_DIR = os.path.join(SKILL_DIR, "data", "inbox")

def check_via_ipc():
    """Query daemon for inbox messages."""
    req = json.dumps({"cmd": "CHECK"})
    sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    sock.settimeout(10.0)
    try:
        sock.connect(IPC_SOCKET)
        sock.sendall(req.encode('utf-8'))
        raw = sock.recv(1048576)
        return json.loads(raw.decode('utf-8'))
    except (ConnectionRefusedError, FileNotFoundError):
        return None
    finally:
        sock.close()

def check_direct():
    """Fallback: read inbox files directly."""
    messages = []
    for fp in sorted(glob.glob(os.path.join(INBOX_DIR, "msg_*.json"))):
        try:
            with open(fp, 'r', encoding='utf-8-sig') as f:
                m = json.load(f)
            m["_filepath"] = fp
            messages.append(m)
        except:
            pass
    return {"count": len(messages), "messages": messages}

def check():
    """Try IPC first, fallback to direct file read."""
    result = check_via_ipc()
    if result is None:
        result = check_direct()
        result["_source"] = "direct"
    else:
        result["_source"] = "daemon"
    return result

if __name__ == "__main__":
    import argparse
    p = argparse.ArgumentParser()
    p.add_argument("--pretty", action="store_true")
    args = p.parse_args()

    result = check()

    if args.pretty:
        print(f"📬 Inbox: {result['count']} message(s) [via {result.get('_source', '?')}]")
        for m in result.get("messages", []):
            print(f"  [{m.get('type','?')}] {m.get('from','?')}: {m.get('message','')[:80]}")
            print(f"    ID: {m.get('id','?')[:8]}  Time: {m.get('timestamp','?')}")
    else:
        print(json.dumps(result, ensure_ascii=False, indent=2))


path: scripts/lcp_crypto.py
content: import os
import json
import base64
from nacl.signing import SigningKey, VerifyKey
from nacl.exceptions import BadSignatureError

KEY_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "keys")
IDENTITY_FILE = os.path.join(KEY_DIR, "identity.pem")

def _canonicalize(msg_dict):
    """Strip security block and canonicalize JSON for signing"""
    msg_copy = msg_dict.copy()
    msg_copy.pop("security", None)
    return json.dumps(msg_copy, sort_keys=True, separators=(',', ':')).encode('utf-8')

def get_or_create_keys():
    """Load or generate Ed25519 keys"""
    if not os.path.exists(KEY_DIR):
        os.makedirs(KEY_DIR, exist_ok=True)
    
    if os.path.exists(IDENTITY_FILE):
        with open(IDENTITY_FILE, "rb") as f:
            seed = f.read()
            signing_key = SigningKey(seed)
    else:
        signing_key = SigningKey.generate()
        with open(IDENTITY_FILE, "wb") as f:
            f.write(signing_key.encode())
            
    verify_key = signing_key.verify_key
    return signing_key, verify_key

def sign_message(msg_dict):
    """Sign the message using local Ed25519 private key"""
    signing_key, verify_key = get_or_create_keys()
    
    payload = _canonicalize(msg_dict)
    signed = signing_key.sign(payload)
    
    msg_dict["security"] = {
        "algo": "ed25519",
        "pubkey": base64.b64encode(verify_key.encode()).decode('utf-8'),
        "signature": base64.b64encode(signed.signature).decode('utf-8')
    }
    return msg_dict

def verify_message(msg_dict):
    """Verify the signature in the security block"""
    if "security" not in msg_dict:
        raise ValueError("Missing security block")
        
    sec = msg_dict["security"]
    if sec.get("algo") != "ed25519":
        raise ValueError(f"Unsupported algorithm: {sec.get('algo')}")
        
    pubkey_bytes = base64.b64decode(sec["pubkey"])
    sig_bytes = base64.b64decode(sec["signature"])
    
    payload = _canonicalize(msg_dict)
    verify_key = VerifyKey(pubkey_bytes)
    
    try:
        verify_key.verify(payload, sig_bytes)
        return True
    except BadSignatureError:
        return False

# Quick test if run directly
if __name__ == "__main__":
    test_msg = {
        "id": "msg_001",
        "from": "AgentA",
        "to": "CloudAgentA",
        "timestamp": "2026-03-27T12:00:00+08:00",
        "type": "task",
        "message": "Hello P2P World"
    }
    
    # 1. Sign
    print("Original:", test_msg)
    signed_msg = sign_message(test_msg.copy())
    print("\nSigned:\n", json.dumps(signed_msg, indent=2))
    
    # 2. Verify Success
    is_valid = verify_message(signed_msg)
    print("\nVerify Valid Payload:", "OK" if is_valid else "FAIL")
    
    # 3. Verify Tampering
    tampered_msg = signed_msg.copy()
    tampered_msg["message"] = "Hacked!"
    is_valid_tampered = verify_message(tampered_msg)
    print("Verify Tampered Payload:", "FAIL" if not is_valid_tampered else "OK (VULNERABLE!)")


path: scripts/lcp_node.py
content: #!/usr/bin/env python3
"""
LCP/1.1 Node — Production UDP Daemon for Lobster-Comm

Features:
- UDP peer-to-peer messaging over Tailscale
- Ed25519 message signing & verification
- Application-layer ACK with retry (reliable delivery)
- Heartbeat keep-alive (detect peer offline)
- Local control socket (send/check/ack via IPC)
- Inbox/Outbox file persistence
- Duplicate detection (idempotent receive)
- Graceful shutdown

Usage:
    python3 lcp_node.py                    # Start daemon (foreground)
    python3 lcp_node.py --background       # Daemonize (nohup)

Architecture:
    [UDP :9528] ←→ Peer communication (DATA/ACK/HEARTBEAT)
    [Unix socket /tmp/lcp.sock] ←→ Local agent IPC (SEND/CHECK/ACK/STATUS)
"""

import sys
import os
import json
import uuid
import socket
import struct
import threading
import time
import signal
import glob
import logging
from datetime import datetime, timezone, timedelta

sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from lcp_crypto import sign_message, verify_message

# ── Config ──────────────────────────────────────────────────────────────
LCP_PORT = 9528
IPC_SOCKET = "/tmp/lcp.sock"
MAGIC = b'LCP1'
MAX_RETRIES = 5
RETRY_BASE_MS = 500       # exponential backoff base
ACK_TIMEOUT_S = 3.0
HEARTBEAT_INTERVAL_S = 30
PEER_TIMEOUT_S = 120       # consider peer offline after this

MY_NAME = "AgentA"
PEER_NAME = "AgentB"
# Tailscale IPs
MY_IP = "100.x.x.x"  # Your Tailscale IP
PEER_IP = "100.y.y.y"  # Peer Tailscale IP

# Directories
SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.path.join(SKILL_DIR, "data")
INBOX_DIR = os.path.join(DATA_DIR, "inbox")
INBOX_ARCHIVE = os.path.join(DATA_DIR, "inbox_archive")
OUTBOX_DIR = os.path.join(DATA_DIR, "outbox")
SEEN_FILE = os.path.join(DATA_DIR, "seen_ids.json")
PID_FILE = "/tmp/lcp_node.pid"

for d in [INBOX_DIR, INBOX_ARCHIVE, OUTBOX_DIR]:
    os.makedirs(d, exist_ok=True)

# ── Logging ─────────────────────────────────────────────────────────────
LOG_FILE = os.path.join(DATA_DIR, "lcp_node.log")
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
    handlers=[
        logging.FileHandler(LOG_FILE, encoding="utf-8"),
        logging.StreamHandler(sys.stdout),
    ],
)
log = logging.getLogger("lcp")

# ── Packet Format ───────────────────────────────────────────────────────
# [Magic 4B][Seq 4B][Type 1B][Payload...]
# Type: 0x01=DATA, 0x02=ACK, 0x03=HEARTBEAT

MSG_DATA = 0x01
MSG_ACK = 0x02
MSG_HEARTBEAT = 0x03

seq_counter = 0
seq_lock = threading.Lock()

def next_seq():
    global seq_counter
    with seq_lock:
        seq_counter = (seq_counter + 1) % 0xFFFFFFFF
        return seq_counter

def pack(msg_type, seq, payload=b''):
    return struct.pack('!4sIB', MAGIC, seq, msg_type) + payload

def unpack(data):
    if len(data) < 9:
        return None
    magic, seq, msg_type = struct.unpack('!4sIB', data[:9])
    if magic != MAGIC:
        return None
    return seq, msg_type, data[9:]

# ── Seen IDs (duplicate detection) ─────────────────────────────────────
seen_ids = set()
seen_lock = threading.Lock()
MAX_SEEN = 10000

def load_seen():
    global seen_ids
    try:
        if os.path.exists(SEEN_FILE):
            with open(SEEN_FILE, 'r') as f:
                seen_ids = set(json.load(f))
    except:
        seen_ids = set()

def save_seen():
    with seen_lock:
        # Trim to last MAX_SEEN
        ids = list(seen_ids)[-MAX_SEEN:]
        with open(SEEN_FILE, 'w') as f:
            json.dump(ids, f)

def mark_seen(msg_id):
    with seen_lock:
        seen_ids.add(msg_id)
    # Save periodically (every 50)
    if len(seen_ids) % 50 == 0:
        save_seen()

def is_seen(msg_id):
    with seen_lock:
        return msg_id in seen_ids

# ── Pending ACKs ───────────────────────────────────────────────────────
pending_acks = {}  # seq -> threading.Event
ack_lock = threading.Lock()

# ── Peer Status ─────────────────────────────────────────────────────────
peer_last_seen = 0  # timestamp
peer_status_lock = threading.Lock()

def update_peer_seen():
    global peer_last_seen
    with peer_status_lock:
        peer_last_seen = time.time()

def is_peer_online():
    with peer_status_lock:
        return (time.time() - peer_last_seen) < PEER_TIMEOUT_S

# ── UDP Socket ──────────────────────────────────────────────────────────
udp_sock = None

def init_udp():
    global udp_sock
    udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    udp_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    # Bind to all interfaces on LCP_PORT
    udp_sock.bind(("0.0.0.0", LCP_PORT))
    udp_sock.settimeout(1.0)  # for clean shutdown
    log.info(f"UDP bound on 0.0.0.0:{LCP_PORT}")

def udp_send_raw(packet, target_ip=PEER_IP):
    try:
        udp_sock.sendto(packet, (target_ip, LCP_PORT))
    except Exception as e:
        log.error(f"UDP send error: {e}")

# ── Reliable Send ───────────────────────────────────────────────────────
def send_message(payload_dict):
    """Sign, pack, send with ACK retry. Returns (success, msg_dict)."""
    tz = timezone(timedelta(hours=8))

    if "id" not in payload_dict:
        payload_dict["id"] = str(uuid.uuid4())
    if "from" not in payload_dict:
        payload_dict["from"] = MY_NAME
    if "to" not in payload_dict:
        payload_dict["to"] = PEER_NAME
    if "timestamp" not in payload_dict:
        payload_dict["timestamp"] = datetime.now(tz).isoformat()

    # Sign
    try:
        signed = sign_message(payload_dict)
    except Exception as e:
        log.error(f"Signing failed: {e}")
        return False, payload_dict

    payload_bytes = json.dumps(signed, ensure_ascii=False).encode('utf-8')
    seq = next_seq()
    packet = pack(MSG_DATA, seq, payload_bytes)

    # Setup ACK event
    ack_event = threading.Event()
    with ack_lock:
        pending_acks[seq] = ack_event

    success = False
    for attempt in range(MAX_RETRIES):
        delay = (RETRY_BASE_MS * (2 ** attempt)) / 1000.0
        udp_send_raw(packet)
        log.info(f"Sent seq={seq} attempt={attempt+1}/{MAX_RETRIES}")

        if ack_event.wait(min(delay, ACK_TIMEOUT_S)):
            success = True
            log.info(f"ACK received for seq={seq}")
            break

    with ack_lock:
        pending_acks.pop(seq, None)

    if success:
        # Save to outbox
        fname = f"msg_{signed['id'][:8]}.json"
        with open(os.path.join(OUTBOX_DIR, fname), 'w', encoding='utf-8') as f:
            json.dump(signed, f, ensure_ascii=False, indent=2)
        log.info(f"Delivered: [{signed.get('type')}] {signed.get('message','')[:60]}")
    else:
        log.warning(f"Failed to deliver seq={seq} after {MAX_RETRIES} attempts")

    return success, signed

# ── UDP Listener ────────────────────────────────────────────────────────
shutdown_flag = threading.Event()

def udp_listener():
    """Main UDP receive loop."""
    while not shutdown_flag.is_set():
        try:
            data, addr = udp_sock.recvfrom(65535)
        except socket.timeout:
            continue
        except OSError:
            break

        parsed = unpack(data)
        if not parsed:
            continue

        seq, msg_type, payload = parsed
        update_peer_seen()

        if msg_type == MSG_ACK:
            with ack_lock:
                ev = pending_acks.get(seq)
                if ev:
                    ev.set()

        elif msg_type == MSG_HEARTBEAT:
            # Reply with ACK to heartbeat
            udp_send_raw(pack(MSG_ACK, seq))
            log.debug(f"Heartbeat from {addr}, replied ACK")

        elif msg_type == MSG_DATA:
            # 1. ACK immediately
            udp_send_raw(pack(MSG_ACK, seq), addr[0])

            # 2. Parse JSON
            try:
                msg = json.loads(payload.decode('utf-8'))
            except json.JSONDecodeError:
                log.warning(f"Invalid JSON from {addr}")
                continue

            msg_id = msg.get("id", "")

            # 3. Duplicate check
            if is_seen(msg_id):
                log.info(f"Duplicate msg {msg_id[:8]}, skipped")
                continue

            # 4. Verify signature
            try:
                if not verify_message(msg):
                    log.warning(f"Invalid signature from {addr}")
                    continue
            except Exception as e:
                log.warning(f"Signature check error: {e}")
                continue

            # 5. Save to inbox
            mark_seen(msg_id)
            fname = f"msg_{msg_id[:8]}.json"
            fpath = os.path.join(INBOX_DIR, fname)
            with open(fpath, 'w', encoding='utf-8') as f:
                json.dump(msg, f, ensure_ascii=False, indent=2)

            log.info(f"Received [{msg.get('type')}] from {msg.get('from')}: {msg.get('message','')[:60]}")

# ── Heartbeat Sender ───────────────────────────────────────────────────
def heartbeat_loop():
    """Periodically send heartbeat to peer."""
    while not shutdown_flag.is_set():
        seq = next_seq()
        udp_send_raw(pack(MSG_HEARTBEAT, seq))
        log.debug(f"Heartbeat sent seq={seq}")
        shutdown_flag.wait(HEARTBEAT_INTERVAL_S)

# ── IPC Server (Unix Socket) ──────────────────────────────────────────
def handle_ipc_client(conn):
    """Handle one IPC request from local agent."""
    try:
        raw = conn.recv(65536)
        if not raw:
            return
        req = json.loads(raw.decode('utf-8'))
        cmd = req.get("cmd", "").upper()

        if cmd == "SEND":
            payload = req.get("payload", {})
            ok, msg = send_message(payload)
            resp = {"ok": ok, "id": msg.get("id")}

        elif cmd == "CHECK":
            # List inbox messages (don't archive)
            messages = []
            for fp in sorted(glob.glob(os.path.join(INBOX_DIR, "msg_*.json"))):
                try:
                    with open(fp, 'r', encoding='utf-8-sig') as f:
                        m = json.load(f)
                    m["_filepath"] = fp
                    messages.append(m)
                except:
                    pass
            resp = {"count": len(messages), "messages": messages}

        elif cmd == "ACK":
            # Archive processed messages
            archived = 0
            for fp in glob.glob(os.path.join(INBOX_DIR, "msg_*.json")):
                try:
                    fname = os.path.basename(fp)
                    dst = os.path.join(INBOX_ARCHIVE, fname)
                    os.rename(fp, dst)
                    archived += 1
                except:
                    pass
            resp = {"archived": archived}

        elif cmd == "STATUS":
            inbox_count = len(glob.glob(os.path.join(INBOX_DIR, "msg_*.json")))
            resp = {
                "peer": PEER_NAME,
                "peer_ip": PEER_IP,
                "peer_online": is_peer_online(),
                "peer_last_seen": peer_last_seen,
                "inbox_count": inbox_count,
                "uptime": time.time() - start_time,
            }

        elif cmd == "PING":
            # Send a ping to peer
            ok, msg = send_message({"type": "ping", "message": "ping"})
            resp = {"ok": ok}

        else:
            resp = {"error": f"Unknown command: {cmd}"}

        conn.sendall(json.dumps(resp, ensure_ascii=False).encode('utf-8'))

    except Exception as e:
        try:
            conn.sendall(json.dumps({"error": str(e)}).encode('utf-8'))
        except:
            pass
    finally:
        conn.close()

def ipc_server():
    """Listen on Unix domain socket for local agent commands."""
    if os.path.exists(IPC_SOCKET):
        os.remove(IPC_SOCKET)

    srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    srv.bind(IPC_SOCKET)
    srv.listen(5)
    srv.settimeout(1.0)
    os.chmod(IPC_SOCKET, 0o600)
    log.info(f"IPC listening on {IPC_SOCKET}")

    while not shutdown_flag.is_set():
        try:
            conn, _ = srv.accept()
            threading.Thread(target=handle_ipc_client, args=(conn,), daemon=True).start()
        except socket.timeout:
            continue
        except OSError:
            break

    srv.close()
    if os.path.exists(IPC_SOCKET):
        os.remove(IPC_SOCKET)

# ── IPC Client Helper ──────────────────────────────────────────────────
def ipc_call(cmd, **kwargs):
    """Send command to running daemon via IPC. Returns response dict."""
    req = {"cmd": cmd}
    req.update(kwargs)

    sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    sock.settimeout(10.0)
    try:
        sock.connect(IPC_SOCKET)
        sock.sendall(json.dumps(req, ensure_ascii=False).encode('utf-8'))
        raw = sock.recv(1048576)  # 1MB max
        return json.loads(raw.decode('utf-8'))
    finally:
        sock.close()

# ── Lifecycle ───────────────────────────────────────────────────────────
start_time = 0

def write_pid():
    with open(PID_FILE, 'w') as f:
        f.write(str(os.getpid()))

def cleanup(_sig=None, _frame=None):
    log.info("Shutting down...")
    shutdown_flag.set()
    save_seen()
    if os.path.exists(PID_FILE):
        os.remove(PID_FILE)
    if os.path.exists(IPC_SOCKET):
        os.remove(IPC_SOCKET)
    sys.exit(0)

def main():
    global start_time
    start_time = time.time()

    signal.signal(signal.SIGINT, cleanup)
    signal.signal(signal.SIGTERM, cleanup)

    load_seen()
    write_pid()
    init_udp()

    # Start threads
    threads = [
        threading.Thread(target=udp_listener, name="udp-listener", daemon=True),
        threading.Thread(target=heartbeat_loop, name="heartbeat", daemon=True),
        threading.Thread(target=ipc_server, name="ipc-server", daemon=True),
    ]
    for t in threads:
        t.start()

    log.info(f"LCP/1.1 Node started — {MY_NAME} ({MY_IP}) ↔ {PEER_NAME} ({PEER_IP})")
    log.info(f"UDP port {LCP_PORT} | IPC {IPC_SOCKET} | Heartbeat every {HEARTBEAT_INTERVAL_S}s")

    # Main thread waits
    try:
        while not shutdown_flag.is_set():
            shutdown_flag.wait(1.0)
    except KeyboardInterrupt:
        cleanup()

if __name__ == "__main__":
    import argparse
    p = argparse.ArgumentParser(description="LCP/1.1 Node Daemon")
    p.add_argument("--peer-ip", default=PEER_IP)
    p.add_argument("--my-ip", default=MY_IP)
    p.add_argument("--port", type=int, default=LCP_PORT)
    args = p.parse_args()
    PEER_IP = args.peer_ip
    MY_IP = args.my_ip
    LCP_PORT = args.port
    main()


path: scripts/lcp_send.py
content: #!/usr/bin/env python3
"""
Send a message via LCP/1.1 daemon (IPC).
Falls back to direct UDP if daemon is not running.

Usage:
    python3 lcp_send.py "Hello!"
    python3 lcp_send.py --type task "Please do X"
    python3 lcp_send.py --type pong --reply-to msg_id "pong"
"""

import sys
import os
import json
import socket

IPC_SOCKET = "/tmp/lcp.sock"

def send_via_ipc(message, msg_type="chat", reply_to=None):
    """Send message through running LCP daemon."""
    payload = {
        "type": msg_type,
        "message": message,
    }
    if reply_to:
        payload["replyTo"] = reply_to

    req = json.dumps({"cmd": "SEND", "payload": payload}, ensure_ascii=False)

    sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    sock.settimeout(15.0)
    try:
        sock.connect(IPC_SOCKET)
        sock.sendall(req.encode('utf-8'))
        raw = sock.recv(65536)
        resp = json.loads(raw.decode('utf-8'))
        return resp
    except ConnectionRefusedError:
        return {"error": "Daemon not running. Start with: python3 lcp_node.py"}
    except FileNotFoundError:
        return {"error": "Daemon not running (no IPC socket). Start with: python3 lcp_node.py"}
    finally:
        sock.close()

if __name__ == "__main__":
    import argparse
    p = argparse.ArgumentParser(description="Send LCP/1.1 message")
    p.add_argument("message", nargs="?", default="ping")
    p.add_argument("--type", default="chat", help="Message type: chat|task|result|ping|pong")
    p.add_argument("--reply-to", default=None, help="Reply to message ID")
    args = p.parse_args()

    resp = send_via_ipc(args.message, args.type, args.reply_to)
    print(json.dumps(resp, ensure_ascii=False, indent=2))

    if resp.get("ok"):
        print(f"✅ Sent [{args.type}]: {args.message[:80]}")
    elif "error" in resp:
        print(f"❌ {resp['error']}", file=sys.stderr)
        sys.exit(1)
    else:
        print(f"⚠️ Send failed (no ACK from peer)", file=sys.stderr)
        sys.exit(1)


path: scripts/lcp_status.py
content: #!/usr/bin/env python3
"""
Query LCP daemon status.

Usage:
    python3 lcp_status.py
"""

import sys
import os
import json
import socket
from datetime import datetime, timedelta, timezone

IPC_SOCKET = "/tmp/lcp.sock"

def status():
    req = json.dumps({"cmd": "STATUS"})
    sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    sock.settimeout(5.0)
    try:
        sock.connect(IPC_SOCKET)
        sock.sendall(req.encode('utf-8'))
        raw = sock.recv(65536)
        return json.loads(raw.decode('utf-8'))
    except (ConnectionRefusedError, FileNotFoundError):
        return {"error": "Daemon not running"}
    finally:
        sock.close()

if __name__ == "__main__":
    s = status()
    if "error" in s:
        print(f"❌ {s['error']}")
        sys.exit(1)

    tz = timezone(timedelta(hours=8))
    uptime_m = s.get("uptime", 0) / 60
    peer_online = "🟢 Online" if s.get("peer_online") else "🔴 Offline"

    last_seen = s.get("peer_last_seen", 0)
    if last_seen > 0:
        last_seen_str = datetime.fromtimestamp(last_seen, tz).strftime("%H:%M:%S")
    else:
        last_seen_str = "never"

    print(f"🦞 LCP/1.1 Node Status")
    print(f"  Peer: {s.get('peer')} ({s.get('peer_ip')})")
    print(f"  Status: {peer_online}")
    print(f"  Last seen: {last_seen_str}")
    print(f"  Inbox: {s.get('inbox_count', 0)} message(s)")
    print(f"  Uptime: {uptime_m:.1f} min")


path: scripts/windows/lcp_crypto_win.py
content: """
LCP/1.1 Crypto Module for Windows
Same as lcp_crypto.py but with Windows-friendly paths.

Requirements: pip install pynacl
"""

import os
import json
import base64
from nacl.signing import SigningKey, VerifyKey
from nacl.exceptions import BadSignatureError

# Windows key directory (same folder as script)
KEY_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "keys")
IDENTITY_FILE = os.path.join(KEY_DIR, "identity.pem")

def _canonicalize(msg_dict):
    msg_copy = msg_dict.copy()
    msg_copy.pop("security", None)
    return json.dumps(msg_copy, sort_keys=True, separators=(',', ':')).encode('utf-8')

def get_or_create_keys():
    if not os.path.exists(KEY_DIR):
        os.makedirs(KEY_DIR, exist_ok=True)

    if os.path.exists(IDENTITY_FILE):
        with open(IDENTITY_FILE, "rb") as f:
            seed = f.read()
            signing_key = SigningKey(seed)
    else:
        signing_key = SigningKey.generate()
        with open(IDENTITY_FILE, "wb") as f:
            f.write(signing_key.encode())
        print(f"Generated new Ed25519 keypair at {IDENTITY_FILE}")

    return signing_key, signing_key.verify_key

def sign_message(msg_dict):
    signing_key, verify_key = get_or_create_keys()
    payload = _canonicalize(msg_dict)
    signed = signing_key.sign(payload)
    msg_dict["security"] = {
        "algo": "ed25519",
        "pubkey": base64.b64encode(verify_key.encode()).decode('utf-8'),
        "signature": base64.b64encode(signed.signature).decode('utf-8')
    }
    return msg_dict

def verify_message(msg_dict):
    if "security" not in msg_dict:
        raise ValueError("Missing security block")
    sec = msg_dict["security"]
    if sec.get("algo") != "ed25519":
        raise ValueError(f"Unsupported algo: {sec.get('algo')}")

    pubkey_bytes = base64.b64decode(sec["pubkey"])
    sig_bytes = base64.b64decode(sec["signature"])
    payload = _canonicalize(msg_dict)
    verify_key = VerifyKey(pubkey_bytes)

    try:
        verify_key.verify(payload, sig_bytes)
        return True
    except BadSignatureError:
        return False

if __name__ == "__main__":
    test_msg = {
        "id": "test_001", "from": "AgentB", "to": "AgentA",
        "timestamp": "2026-03-28T12:00:00+08:00", "type": "ping", "message": "hello"
    }
    signed = sign_message(test_msg.copy())
    print("Signed:", json.dumps(signed, indent=2))
    print("Verify:", "OK" if verify_message(signed) else "FAIL")


path: scripts/windows/lcp_node_win.py
content: #!/usr/bin/env python3
"""
LCP/1.1 Node for Windows
Mirror of lcp_node.py adapted for Windows:
- No Unix socket IPC (uses TCP localhost:9529 instead)
- Windows paths
- No LaunchAgent (use Task Scheduler or nssm)

Deploy:
    1. Copy this file + lcp_crypto_win.py to D:\lobster-comm\
    2. pip install pynacl
    3. python lcp_node_win.py

Requirements:
    - Python 3.9+
    - PyNaCl (pip install pynacl)
    - Tailscale running (UDP port 9528 reachable)
"""

import sys
import os
import json
import uuid
import socket
import struct
import threading
import time
import signal
import glob
import logging
from datetime import datetime, timezone, timedelta

# Import crypto module (same directory)
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
try:
    from lcp_crypto_win import sign_message, verify_message
except ImportError:
    print("ERROR: lcp_crypto_win.py not found. Copy it to same directory.", file=sys.stderr)
    sys.exit(1)

# ── Config ──────────────────────────────────────────────────────────────
LCP_PORT = 9528
IPC_PORT = 9529  # TCP localhost for Windows IPC (no Unix sockets)
MAGIC = b'LCP1'
MAX_RETRIES = 5
RETRY_BASE_MS = 500
ACK_TIMEOUT_S = 3.0
HEARTBEAT_INTERVAL_S = 30
PEER_TIMEOUT_S = 120

MY_NAME = "AgentB"
PEER_NAME = "AgentA"
MY_IP = "100.y.y.y"  # Your Tailscale IP
PEER_IP = "100.x.x.x"  # Peer Tailscale IP

# Windows paths
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "data")
INBOX_DIR = os.path.join(DATA_DIR, "inbox")
INBOX_ARCHIVE = os.path.join(DATA_DIR, "inbox_archive")
OUTBOX_DIR = os.path.join(DATA_DIR, "outbox")
SEEN_FILE = os.path.join(DATA_DIR, "seen_ids.json")
KEY_DIR = os.path.join(BASE_DIR, "keys")

for d in [INBOX_DIR, INBOX_ARCHIVE, OUTBOX_DIR, KEY_DIR]:
    os.makedirs(d, exist_ok=True)

# ── Logging ─────────────────────────────────────────────────────────────
LOG_FILE = os.path.join(DATA_DIR, "lcp_node.log")
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
    handlers=[
        logging.FileHandler(LOG_FILE, encoding="utf-8"),
        logging.StreamHandler(sys.stdout),
    ],
)
log = logging.getLogger("lcp")

# ── Packet ──────────────────────────────────────────────────────────────
MSG_DATA = 0x01
MSG_ACK = 0x02
MSG_HEARTBEAT = 0x03

seq_counter = 0
seq_lock = threading.Lock()

def next_seq():
    global seq_counter
    with seq_lock:
        seq_counter = (seq_counter + 1) % 0xFFFFFFFF
        return seq_counter

def pack(msg_type, seq, payload=b''):
    return struct.pack('!4sIB', MAGIC, seq, msg_type) + payload

def unpack(data):
    if len(data) < 9:
        return None
    magic, seq, msg_type = struct.unpack('!4sIB', data[:9])
    if magic != MAGIC:
        return None
    return seq, msg_type, data[9:]

# ── Seen IDs ────────────────────────────────────────────────────────────
seen_ids = set()
seen_lock = threading.Lock()

def load_seen():
    global seen_ids
    try:
        if os.path.exists(SEEN_FILE):
            with open(SEEN_FILE, 'r') as f:
                seen_ids = set(json.load(f))
    except:
        seen_ids = set()

def save_seen():
    with seen_lock:
        ids = list(seen_ids)[-10000:]
        with open(SEEN_FILE, 'w') as f:
            json.dump(ids, f)

def mark_seen(msg_id):
    with seen_lock:
        seen_ids.add(msg_id)
    if len(seen_ids) % 50 == 0:
        save_seen()

def is_seen(msg_id):
    with seen_lock:
        return msg_id in seen_ids

# ── ACKs & Peer ─────────────────────────────────────────────────────────
pending_acks = {}
ack_lock = threading.Lock()
peer_last_seen = 0
peer_status_lock = threading.Lock()

def update_peer_seen():
    global peer_last_seen
    with peer_status_lock:
        peer_last_seen = time.time()

def is_peer_online():
    with peer_status_lock:
        return (time.time() - peer_last_seen) < PEER_TIMEOUT_S

# ── UDP ─────────────────────────────────────────────────────────────────
udp_sock = None
shutdown_flag = threading.Event()

def init_udp():
    global udp_sock
    udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    udp_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    udp_sock.bind(("0.0.0.0", LCP_PORT))
    udp_sock.settimeout(1.0)
    log.info(f"UDP bound on 0.0.0.0:{LCP_PORT}")

def udp_send_raw(packet, target_ip=PEER_IP):
    try:
        udp_sock.sendto(packet, (target_ip, LCP_PORT))
    except Exception as e:
        log.error(f"UDP send error: {e}")

def send_message(payload_dict):
    tz = timezone(timedelta(hours=8))
    if "id" not in payload_dict:
        payload_dict["id"] = str(uuid.uuid4())
    if "from" not in payload_dict:
        payload_dict["from"] = MY_NAME
    if "to" not in payload_dict:
        payload_dict["to"] = PEER_NAME
    if "timestamp" not in payload_dict:
        payload_dict["timestamp"] = datetime.now(tz).isoformat()

    try:
        signed = sign_message(payload_dict)
    except Exception as e:
        log.error(f"Signing failed: {e}")
        return False, payload_dict

    payload_bytes = json.dumps(signed, ensure_ascii=False).encode('utf-8')
    seq = next_seq()
    packet = pack(MSG_DATA, seq, payload_bytes)

    ack_event = threading.Event()
    with ack_lock:
        pending_acks[seq] = ack_event

    success = False
    for attempt in range(MAX_RETRIES):
        delay = (RETRY_BASE_MS * (2 ** attempt)) / 1000.0
        udp_send_raw(packet)
        log.info(f"Sent seq={seq} attempt={attempt+1}/{MAX_RETRIES}")
        if ack_event.wait(min(delay, ACK_TIMEOUT_S)):
            success = True
            log.info(f"ACK received for seq={seq}")
            break

    with ack_lock:
        pending_acks.pop(seq, None)

    if success:
        fname = f"msg_{signed['id'][:8]}.json"
        with open(os.path.join(OUTBOX_DIR, fname), 'w', encoding='utf-8') as f:
            json.dump(signed, f, ensure_ascii=False, indent=2)
    else:
        log.warning(f"Failed to deliver seq={seq}")

    return success, signed

# ── UDP Listener ────────────────────────────────────────────────────────
def udp_listener():
    while not shutdown_flag.is_set():
        try:
            data, addr = udp_sock.recvfrom(65535)
        except socket.timeout:
            continue
        except OSError:
            break

        parsed = unpack(data)
        if not parsed:
            continue

        seq, msg_type, payload = parsed
        update_peer_seen()

        if msg_type == MSG_ACK:
            with ack_lock:
                ev = pending_acks.get(seq)
                if ev:
                    ev.set()

        elif msg_type == MSG_HEARTBEAT:
            udp_send_raw(pack(MSG_ACK, seq), addr[0])

        elif msg_type == MSG_DATA:
            udp_send_raw(pack(MSG_ACK, seq), addr[0])
            try:
                msg = json.loads(payload.decode('utf-8'))
            except json.JSONDecodeError:
                continue

            msg_id = msg.get("id", "")
            if is_seen(msg_id):
                continue

            try:
                if not verify_message(msg):
                    log.warning(f"Invalid signature from {addr}")
                    continue
            except Exception as e:
                log.warning(f"Sig error: {e}")
                continue

            mark_seen(msg_id)
            fname = f"msg_{msg_id[:8]}.json"
            with open(os.path.join(INBOX_DIR, fname), 'w', encoding='utf-8') as f:
                json.dump(msg, f, ensure_ascii=False, indent=2)
            log.info(f"Received [{msg.get('type')}] from {msg.get('from')}: {msg.get('message','')[:60]}")

# ── Heartbeat ───────────────────────────────────────────────────────────
def heartbeat_loop():
    while not shutdown_flag.is_set():
        seq = next_seq()
        udp_send_raw(pack(MSG_HEARTBEAT, seq))
        shutdown_flag.wait(HEARTBEAT_INTERVAL_S)

# ── TCP IPC (Windows replacement for Unix socket) ──────────────────────
def handle_ipc_client(conn):
    try:
        raw = conn.recv(65536)
        if not raw:
            return
        req = json.loads(raw.decode('utf-8'))
        cmd = req.get("cmd", "").upper()

        if cmd == "SEND":
            payload = req.get("payload", {})
            ok, msg = send_message(payload)
            resp = {"ok": ok, "id": msg.get("id")}

        elif cmd == "CHECK":
            messages = []
            for fp in sorted(glob.glob(os.path.join(INBOX_DIR, "msg_*.json"))):
                try:
                    with open(fp, 'r', encoding='utf-8-sig') as f:
                        m = json.load(f)
                    m["_filepath"] = fp
                    messages.append(m)
                except:
                    pass
            resp = {"count": len(messages), "messages": messages}

        elif cmd == "ACK":
            archived = 0
            for fp in glob.glob(os.path.join(INBOX_DIR, "msg_*.json")):
                try:
                    os.rename(fp, os.path.join(INBOX_ARCHIVE, os.path.basename(fp)))
                    archived += 1
                except:
                    pass
            resp = {"archived": archived}

        elif cmd == "STATUS":
            inbox_count = len(glob.glob(os.path.join(INBOX_DIR, "msg_*.json")))
            resp = {
                "peer": PEER_NAME, "peer_ip": PEER_IP,
                "peer_online": is_peer_online(),
                "peer_last_seen": peer_last_seen,
                "inbox_count": inbox_count,
                "uptime": time.time() - start_time,
            }

        else:
            resp = {"error": f"Unknown: {cmd}"}

        conn.sendall(json.dumps(resp, ensure_ascii=False).encode('utf-8'))
    except Exception as e:
        try:
            conn.sendall(json.dumps({"error": str(e)}).encode('utf-8'))
        except:
            pass
    finally:
        conn.close()

def ipc_server():
    srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind(("127.0.0.1", IPC_PORT))
    srv.listen(5)
    srv.settimeout(1.0)
    log.info(f"IPC listening on 127.0.0.1:{IPC_PORT}")

    while not shutdown_flag.is_set():
        try:
            conn, _ = srv.accept()
            threading.Thread(target=handle_ipc_client, args=(conn,), daemon=True).start()
        except socket.timeout:
            continue
        except OSError:
            break
    srv.close()

# ── Main ────────────────────────────────────────────────────────────────
start_time = 0

def cleanup(_sig=None, _frame=None):
    log.info("Shutting down...")
    shutdown_flag.set()
    save_seen()
    sys.exit(0)

def main():
    global start_time
    start_time = time.time()
    signal.signal(signal.SIGINT, cleanup)
    signal.signal(signal.SIGTERM, cleanup)

    load_seen()
    init_udp()

    threads = [
        threading.Thread(target=udp_listener, daemon=True),
        threading.Thread(target=heartbeat_loop, daemon=True),
        threading.Thread(target=ipc_server, daemon=True),
    ]
    for t in threads:
        t.start()

    log.info(f"LCP/1.1 Node (Windows) — {MY_NAME} ({MY_IP}) ↔ {PEER_NAME} ({PEER_IP})")
    log.info(f"UDP :{LCP_PORT} | IPC 127.0.0.1:{IPC_PORT}")

    try:
        while not shutdown_flag.is_set():
            shutdown_flag.wait(1.0)
    except KeyboardInterrupt:
        cleanup()

if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)