feat(discord-bridge): punte Discord -> Claude Code pe LXC 171

Implementeaza planul claude-master-plan-discord-bridge-20260830 (15 taskuri,
3 lane-uri paralele) — un bot subtire discord.py peste CLI-ul `claude`, cu
proces persistent per fir alimentat pe stdin cu --input-format stream-json.

Nucleu: runner (proces persistent + reaper 20min + respawn --resume), stream
(parser tolerant), session_store (scriere atomica, lock per fir, detectare PID
reuse, recovery), limits (max 4 procese, timeout tur, rate per user, plafon cost
pe zi), render (un loop de editare per canal, interval adaptiv).

Adaptor: allowlist guild/canal/user fail-closed cu respingerea webhook-urilor,
comenzi !new/!cd/!model/!status/!stop/!cleanup, cost si model in subsolul
fiecarui raspuns. Mesajul sosit in timpul unui tur devine steering, nu tur nou.

Securitate: hook PreToolUse fail-closed care cere confirmare in Discord pentru
operatiuni ireversibile, wrapper `infra` cu lista explicita de hosturi. Deny
rules raman strat cosmetic, nu bariera (verificat: /usr/bin/ssh trece pe langa).

Ops: alerte email pe conventia repo-ului, !cleanup pentru orfani, unit systemd
user cu KillMode=control-group si limite de memorie, install.sh idempotent.

Verificat: 275 teste fara retea/Discord/API (10.8s), identic cu si fara
discord.py instalat; e2e pe CLI real confirma steering-ul mid-tur (mesaj la 6s
intr-un tool call de 25s schimba raspunsul final).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B29CApsP1JkSdjYaGaHpE7
This commit is contained in:
Claude Agent
2026-08-30 10:44:39 +00:00
parent d7b4007af8
commit d466f358ce
43 changed files with 7767 additions and 0 deletions

View File

@@ -0,0 +1,398 @@
"""Proces `claude` PERSISTENT per fir, alimentat pe stdin cu --input-format stream-json.
De ce persistent (T4): asa se poate face steering mid-tur -- un mesaj trimis in timpul
unui tool call lung ajunge la model si schimba raspunsul final (verificat in plan).
Totul e asyncio: NICIODATA readline blocant. stderr are task propriu si buffer circular.
"""
from __future__ import annotations
import asyncio
import collections
import contextlib
import json
import logging
import os
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, AsyncIterator, Awaitable, Callable
import config
import stream as stream_mod
log = logging.getLogger("discord-bridge.runner")
# linii mari (tool results) -> limita generoasa pe StreamReader
_READ_LIMIT = 16 * 1024 * 1024
STDERR_TAIL = 50
class TurnFailed(RuntimeError):
"""Turul nu s-a putut duce la capat (proces mort, EOF inainte de result)."""
class TurnTimeout(TurnFailed):
"""Turul a depasit timpul maxim."""
@dataclass
class SessionRestarted:
"""Eveniment sintetic: procesul murise, l-am repornit cu --resume."""
thread_id: str
sid: str | None
reason: str = "procesul claude murise"
@property
def text(self) -> str:
return f"sesiune repornita ({self.reason})"
@dataclass
class TurnOutcome:
result: stream_mod.Result | None
restarted: bool = False
events: list[Any] = field(default_factory=list)
def build_cmd(
claude_bin: str | list[str],
model: str,
sid: str | None = None,
settings: str | os.PathLike | None = None,
extra: tuple[str, ...] = (),
) -> list[str]:
"""Comanda exacta din plan; `--resume <sid>` doar la respawn."""
base = [claude_bin] if isinstance(claude_bin, str) else list(claude_bin)
cmd = base + [
"-p",
"--input-format", "stream-json",
"--output-format", "stream-json",
"--verbose",
"--permission-mode", "bypassPermissions",
]
if settings:
cmd += ["--settings", os.fspath(settings)]
cmd += ["--model", model, "--autocompact", "auto"]
if sid:
cmd += ["--resume", sid]
cmd += list(extra)
return cmd
def user_message(text: str) -> str:
return json.dumps(
{"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": text}]}},
ensure_ascii=False,
)
class ClaudeProcess:
"""Un proces claude viu, legat de un fir Discord."""
def __init__(
self,
thread_id: str,
cwd: str,
model: str,
sid: str | None = None,
*,
claude_bin: str | list[str] | None = None,
settings: str | os.PathLike | None = None,
env: dict[str, str] | None = None,
on_pid: Callable[[str, int | None], Any] | None = None,
):
self.thread_id = str(thread_id)
self.cwd = cwd
self.model = model
self.sid = sid
self.claude_bin = claude_bin or config.get("CLAUDE_BIN", "claude")
self.settings = settings
self.env = env
self.on_pid = on_pid
self.proc: asyncio.subprocess.Process | None = None
self.stderr_buf: collections.deque[str] = collections.deque(maxlen=STDERR_TAIL)
self.last_active = time.monotonic()
self.inflight = False
self.started_turns = 0
self.restarts = 0
self._stderr_task: asyncio.Task | None = None
self._parser = stream_mod.StreamParser()
self._start_lock = asyncio.Lock()
# --------------------------------------------------------------- proces
@property
def alive(self) -> bool:
return self.proc is not None and self.proc.returncode is None
@property
def pid(self) -> int | None:
return self.proc.pid if self.proc is not None else None
async def start(self) -> None:
async with self._start_lock:
if self.alive:
return
cmd = build_cmd(self.claude_bin, self.model, self.sid, self.settings)
log.info("pornesc claude pentru firul %s: %s", self.thread_id, " ".join(cmd))
env = dict(os.environ)
# Lane B: hook-ul PreToolUse afla firul Discord din mediul procesului claude
env["CLAUDE_DISCORD_THREAD_ID"] = self.thread_id
if self.sid:
env["CLAUDE_DISCORD_SESSION_ID"] = self.sid
if self.env:
env.update(self.env)
self.proc = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=self.cwd,
env=env,
limit=_READ_LIMIT,
)
self.last_active = time.monotonic()
self._stderr_task = asyncio.create_task(self._pump_stderr())
if self.on_pid:
res = self.on_pid(self.thread_id, self.proc.pid)
if asyncio.iscoroutine(res):
await res
async def _pump_stderr(self) -> None:
assert self.proc is not None and self.proc.stderr is not None
try:
async for raw in self.proc.stderr:
line = raw.decode("utf-8", "replace").rstrip("\n")
if line:
self.stderr_buf.append(line)
except (asyncio.CancelledError, ValueError):
raise
except Exception as exc: # pragma: no cover
log.debug("stderr pump s-a oprit: %s", exc)
def stderr_tail(self, n: int = STDERR_TAIL) -> list[str]:
"""Ultimele linii de stderr, pentru `!status`."""
return list(self.stderr_buf)[-n:]
async def stop(self, grace: float = 5.0) -> None:
task, self._stderr_task = self._stderr_task, None
proc, self.proc = self.proc, None
if proc is not None and proc.returncode is None:
with contextlib.suppress(ProcessLookupError):
if proc.stdin and not proc.stdin.is_closing():
proc.stdin.close()
proc.terminate()
try:
await asyncio.wait_for(proc.wait(), grace)
except (asyncio.TimeoutError, ProcessLookupError):
with contextlib.suppress(ProcessLookupError):
proc.kill()
with contextlib.suppress(Exception):
await proc.wait()
if task is not None:
task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await task
if self.on_pid:
res = self.on_pid(self.thread_id, None)
if asyncio.iscoroutine(res):
await res
# ------------------------------------------------------------- mesaje
async def send(self, text: str) -> None:
"""Trimite un mesaj de utilizator pe stdin (si mid-tur: asta e steering-ul)."""
if not self.alive or self.proc is None or self.proc.stdin is None:
raise TurnFailed("procesul claude nu e viu")
self.proc.stdin.write((user_message(text) + "\n").encode("utf-8"))
await self.proc.stdin.drain()
self.last_active = time.monotonic()
async def _stdout_lines(self) -> AsyncIterator[str]:
assert self.proc is not None and self.proc.stdout is not None
reader = self.proc.stdout
while True:
try:
raw = await reader.readline()
except ValueError: # linie peste limita: nu doboram turul
log.warning("linie de stdout peste limita, ignorata")
continue
if not raw:
return
yield raw.decode("utf-8", "replace")
async def run_turn(
self,
prompt: str,
on_event: Callable[[Any], Awaitable[None]] | None = None,
timeout: float | None = None,
) -> TurnOutcome:
"""Un tur complet: (re)porneste procesul daca trebuie, trimite promptul,
consuma stream-ul pana la `result`."""
restarted = False
if not self.alive:
if self.started_turns:
restarted = True
self.restarts += 1
await self.start()
if timeout is None:
timeout = config.get_float("TURN_TIMEOUT_S", 900.0)
self.started_turns += 1
self.inflight = True
events: list[Any] = []
try:
if restarted:
ev = SessionRestarted(self.thread_id, self.sid)
events.append(ev)
if on_event:
await on_event(ev)
await self.send(prompt)
outcome = await asyncio.wait_for(self._consume(on_event, events), timeout)
return TurnOutcome(result=outcome, restarted=restarted, events=events)
except asyncio.TimeoutError:
await self.stop()
raise TurnTimeout(f"turul a depasit {timeout:.0f}s si a fost oprit") from None
except stream_mod.StreamEOFError as exc:
tail = "\n".join(self.stderr_tail(10))
await self.stop()
raise TurnFailed(f"{exc}; stderr:\n{tail}") from None
finally:
self.inflight = False
self.last_active = time.monotonic()
async def _consume(self, on_event, events: list) -> stream_mod.Result:
parser = stream_mod.StreamParser()
self._parser = parser
async for ev in parser.aiter_events(self._stdout_lines()):
if isinstance(ev, stream_mod.SystemInit) and ev.session_id:
self.sid = ev.session_id
events.append(ev)
if on_event:
await on_event(ev)
if isinstance(ev, stream_mod.Result):
return ev
raise stream_mod.StreamEOFError("stream inchis fara result") # pragma: no cover
class RunnerManager:
"""Registrul de procese vii + reaper-ul de inactivitate (20 min implicit)."""
def __init__(
self,
store=None,
*,
claude_bin: str | list[str] | None = None,
settings: str | os.PathLike | None = None,
idle_s: float | None = None,
poll_s: float = 30.0,
is_inflight: Callable[[str], bool] | None = None,
):
self.store = store
self.claude_bin = claude_bin or config.get("CLAUDE_BIN", "claude")
self.settings = settings
self.idle_s = idle_s if idle_s is not None else config.get_float("IDLE_REAP_S", 1200.0)
self.poll_s = poll_s
self._is_inflight = is_inflight
self.procs: dict[str, ClaudeProcess] = {}
self._reaper: asyncio.Task | None = None
# ------------------------------------------------------------ procese
def _on_pid(self, thread_id: str, pid: int | None):
if self.store is not None:
with contextlib.suppress(Exception):
self.store.set_pid(thread_id, pid)
def get(self, thread_id: str, cwd: str | None = None, model: str | None = None,
sid: str | None = None) -> ClaudeProcess:
key = str(thread_id)
proc = self.procs.get(key)
if proc is None:
proc = self.procs[key] = ClaudeProcess(
key,
cwd or config.get("DEFAULT_CWD", "/workspace"),
model or config.get("MODEL_DEFAULT", "sonnet"),
sid=sid,
claude_bin=self.claude_bin,
settings=self.settings,
on_pid=self._on_pid,
)
return proc
async def set_options(self, thread_id: str, cwd: str | None = None, model: str | None = None) -> bool:
"""`!cd` / `!model`: procesul curent se opreste, urmatorul tur il reia cu --resume."""
proc = self.get(thread_id)
changed = False
if cwd and cwd != proc.cwd:
proc.cwd, changed = cwd, True
if model and model != proc.model:
proc.model, changed = model, True
if changed and proc.alive:
await proc.stop()
return changed
async def reset(self, thread_id: str, fork: bool = False) -> None:
"""`!new`: sesiune noua (fork=True pastreaza sid-ul pentru un fir nou)."""
proc = self.procs.get(str(thread_id))
if proc is not None:
await proc.stop()
if not fork:
proc.sid = None
proc.started_turns = 0
def inflight(self, thread_id: str) -> bool:
proc = self.procs.get(str(thread_id))
if proc is not None and proc.inflight:
return True
if self._is_inflight is not None:
with contextlib.suppress(Exception):
return bool(self._is_inflight(str(thread_id)))
if self.store is not None:
with contextlib.suppress(Exception):
return bool(self.store.is_inflight(str(thread_id)))
return False
def live_count(self) -> int:
return sum(1 for p in self.procs.values() if p.alive)
# ------------------------------------------------------------- reaper
async def reap_once(self, now: float | None = None) -> list[str]:
"""Omoara procesele inactive. NICIODATA un fir cu tur in zbor."""
now = time.monotonic() if now is None else now
killed: list[str] = []
for key, proc in list(self.procs.items()):
if not proc.alive:
continue
if self.inflight(key):
continue
if now - proc.last_active >= self.idle_s:
log.info("reaper: opresc claude pentru firul %s (inactiv)", key)
await proc.stop()
killed.append(key)
return killed
async def _reaper_loop(self) -> None:
while True:
try:
await asyncio.sleep(self.poll_s)
await self.reap_once()
except asyncio.CancelledError:
raise
except Exception: # pragma: no cover
log.exception("reaper a esuat, continui")
def start_reaper(self) -> asyncio.Task:
if self._reaper is None or self._reaper.done():
self._reaper = asyncio.create_task(self._reaper_loop())
return self._reaper
async def stop_all(self) -> None:
if self._reaper is not None:
self._reaper.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await self._reaper
self._reaper = None
for proc in list(self.procs.values()):
await proc.stop()
def new_turn_id() -> str:
return uuid.uuid4().hex[:12]