# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///

#!/usr/bin/env python3
import os, sys, json, time, select, subprocess, fcntl, struct, termios, traceback, signal, threading, re, shlex, hashlib
import tty
from importlib.metadata import PackageNotFoundError, version as pkg_version
import pty


# ---------------- Tuning (non-security) ----------------
def _env_int(name: str, default: int) -> int:
    """读取整型环境变量，失败则回退默认值。"""
    val = os.environ.get(name)
    if val is None:
        return default
    try:
        return int(val)
    except Exception:
        return default


MAX_SESSIONS = _env_int("PTY_MCP_MAX_SESSIONS", 20)
IDLE_TIMEOUT_SEC = _env_int("PTY_MCP_IDLE_TIMEOUT_SEC", 15 * 60)
IDLE_GRACE_SEC = _env_int("PTY_MCP_IDLE_GRACE_SEC", 0)
GC_INTERVAL_SEC = _env_int("PTY_MCP_GC_INTERVAL_SEC", 5)
RING_MAX_BYTES = _env_int("PTY_MCP_RING_MAX_BYTES", 1 * 1024 * 1024)
DEFAULT_READ_TIMEOUT_MS = _env_int("PTY_MCP_READ_TIMEOUT_MS", 300)
DEFAULT_MAX_CHARS = _env_int("PTY_MCP_MAX_CHARS", 8000)
READ_CHUNK_BYTES = _env_int("PTY_MCP_READ_CHUNK_BYTES", 4096)
READ_UNTIL_MAX_MS = _env_int("PTY_MCP_READ_UNTIL_MAX_MS", 60000)
SPAWN_TIMEOUT_SEC = _env_int("PTY_MCP_SPAWN_TIMEOUT_SEC", 0)
OUTPUT_RATE_BPS = _env_int("PTY_MCP_OUTPUT_RATE_BPS", 0)
INPUT_RATE_BPS = _env_int("PTY_MCP_INPUT_RATE_BPS", 0)
QUIESCENCE_MS = _env_int("PTY_MCP_QUIESCENCE_MS", 300)
SESSION_LOG_DIR = os.environ.get("PTY_MCP_SESSION_LOG_DIR")
DEFAULT_BACKEND = os.environ.get("PTY_MCP_BACKEND", "subprocess")
DEFAULT_CWD = None
DANGEROUS_CONFIRM_TTL_SEC = _env_int("PTY_MCP_DANGEROUS_CONFIRM_TTL_SEC", 300)
REQUIRE_OWNER = os.environ.get("PTY_MCP_REQUIRE_OWNER", "1") in (
    "1",
    "true",
    "yes",
)
ENABLE_ADMIN_TOOLS = os.environ.get("PTY_MCP_ENABLE_ADMIN_TOOLS", "0") in (
    "1",
    "true",
    "yes",
)
ENABLE_CONTROL_TOOLS = os.environ.get("PTY_MCP_ENABLE_CONTROL_TOOLS", "0") in (
    "1",
    "true",
    "yes",
)
DANGEROUS_LEGACY_MODE = os.environ.get("PTY_MCP_DANGEROUS_LEGACY_MODE", "0") in (
    "1",
    "true",
    "yes",
)
TMUX_CAPTURE_DIR = os.environ.get("PTY_MCP_TMUX_CAPTURE_DIR", "/tmp/pty-mcp")
STATE_DIR = os.environ.get("PTY_MCP_STATE_DIR", os.path.join(TMUX_CAPTURE_DIR, "state"))
TMUX_PANE_FORMAT = (
    "#{pane_id}\t#{pane_dead}\t#{pane_dead_status}\t"
    "#{pane_current_command}\t#{pane_width}\t#{pane_height}"
)

FRAMING = None  # "lsp" (Content-Length) or "line" (NDJSON)

_last_gc = 0.0
_log_rate = {}  # key -> last_ts
_LOG_LEVEL = os.environ.get("PTY_MCP_LOG", "info").lower()
_LOG_FORMAT = os.environ.get("PTY_MCP_LOG_FORMAT", "plain").lower()
# 是否在 pty_spawn 返回元数据
_SPAWN_META = os.environ.get("PTY_MCP_SPAWN_META", "0") in ("1", "true", "yes")
_LOG_LEVELS = {"debug": 10, "info": 20, "warn": 30, "error": 40}
_server_started = time.time()


def _should_log(level: str) -> bool:
    return _LOG_LEVELS.get(level, 20) >= _LOG_LEVELS.get(_LOG_LEVEL, 20)


def _log(*a, level: str = "info"):
    if not _should_log(level):
        return
    if _LOG_FORMAT == "json":
        # 结构化日志便于检索与聚合
        msg = " ".join(str(x) for x in a)
        payload = {
            "ts": time.time(),
            "level": level,
            "pid": os.getpid(),
            "msg": msg,
        }
        sys.stderr.write(json.dumps(payload, ensure_ascii=False) + "\n")
        sys.stderr.flush()
    else:
        print(*a, file=sys.stderr, flush=True)


def _log_limited(key: str, *a, interval_sec: float = 5.0):
    now = time.time()
    last = _log_rate.get(key, 0.0)
    if now - last >= interval_sec:
        _log_rate[key] = now
        _log(*a, level="warn")


def _dangerous_patterns():
    raw = os.environ.get("PTY_MCP_DANGEROUS_PATTERNS")
    if raw:
        return [p for p in raw.split(";") if p]
    return [
        r"\brm\s+-rf\s+/$",
        r"\brm\s+-rf\s+/\b",
        r"\bmkfs\b",
        r"\bdd\s+if=/dev/zero\b",
        r"\bshutdown\b",
        r"\breboot\b",
        r"\bpoweroff\b",
    ]


def _is_dangerous_command(command: str):
    cmd = _strish(command, "")
    matches = []
    for pat in _dangerous_patterns():
        try:
            if re.search(pat, cmd):
                matches.append(pat)
        except Exception:
            continue
    return matches


def _new_confirm_token():
    return f"dc_{os.urandom(16).hex()}"


def _normalize_command_for_confirmation(command: str) -> str:
    return _strish(command, "").strip()


def _gc_dangerous_confirmations():
    now = _now()
    expired = []
    for token, item in dangerous_confirmations.items():
        if item.get("used"):
            expired.append(token)
            continue
        if item.get("expires_at", 0) <= now:
            expired.append(token)
    for token in expired:
        dangerous_confirmations.pop(token, None)


def _issue_dangerous_confirmation(
    command: str, justification: str, owner=None, scope=None
):
    _gc_dangerous_confirmations()
    token = _new_confirm_token()
    now = _now()
    normalized = _normalize_command_for_confirmation(command)
    matches = _is_dangerous_command(normalized)
    item = {
        "token": token,
        "command": normalized,
        "owner": _strish(owner, "").strip(),
        "scope": _strish(scope, "").strip(),
        "created_at": now,
        "expires_at": now + DANGEROUS_CONFIRM_TTL_SEC,
        "used": False,
        "justification": _strish(justification, "").strip(),
        "matches": matches,
    }
    dangerous_confirmations[token] = item
    return item


def _consume_dangerous_confirmation(token: str, command: str, owner=None, scope=None):
    _gc_dangerous_confirmations()
    token = _strish(token, "").strip()
    item = dangerous_confirmations.get(token)
    if not item:
        raise RuntimeError("invalid or expired dangerous confirmation token")
    if item.get("used"):
        raise RuntimeError("dangerous confirmation token already used")

    normalized = _normalize_command_for_confirmation(command)
    if item.get("command") != normalized:
        raise RuntimeError("dangerous confirmation token does not match command")

    expected_owner = _strish(item.get("owner"), "").strip()
    actual_owner = _strish(owner, "").strip()
    if expected_owner and expected_owner != actual_owner:
        raise RuntimeError("dangerous confirmation token owner mismatch")

    expected_scope = _strish(item.get("scope"), "").strip()
    actual_scope = _strish(scope, "").strip()
    if expected_scope and expected_scope != actual_scope:
        raise RuntimeError("dangerous confirmation token scope mismatch")

    item["used"] = True
    dangerous_confirmations.pop(token, None)
    return item


def _tool_access_level(name: str) -> str:
    if name in (
        "pty_close_all",
        "set_limits",
        "set_default_cwd",
        "get_limits",
        "get_default_cwd",
        "pty_metrics",
        "pty_health",
    ):
        return "admin"
    if name in ("pty_signal",):
        return "control"
    if name in ("pty_attach", "pty_detach", "pty_handoff", "pty_takeover"):
        return "read"
    if name in ("pty_write", "pty_resize", "pty_close", "pty_prompt"):
        return "write"
    if name in ("pty_spawn",):
        return "spawn"
    return "read"


def _scope_allows(scope: str, level: str) -> bool:
    scope = _strish(scope, "read-write") or "read-write"
    if scope == "read-only":
        return level in ("read",)
    if scope == "read-write":
        return level in ("read", "write", "spawn")
    if scope == "control":
        return level in ("read", "write", "spawn", "control")
    if scope == "admin":
        return True
    return False


def _require_session_owner(s, owner):
    if not REQUIRE_OWNER:
        return
    expected = _strish(s.get("owner"), "").strip()
    actual = _strish(owner, "").strip()
    if expected and not actual:
        raise RuntimeError("owner is required for this session")
    if expected and expected != actual:
        raise RuntimeError("session owner mismatch")


def _normalize_client_id(client_id):
    client_id = _strish(client_id, "").strip()
    return client_id or None


def _tool_requires_attached_client(name: str) -> bool:
    return name in {
        "pty_read",
        "pty_read_stdout",
        "pty_read_stderr",
        "pty_read_until",
        "pty_read_until_any",
        "pty_read_quiescent",
        "pty_prompt",
        "pty_write",
        "pty_resize",
        "pty_close",
        "pty_signal",
    }


def _require_attached_client(s, client_id, tool_name: str):
    attached_client_id = _normalize_client_id(s.get("attached_client_id"))
    if not attached_client_id:
        return
    actual_client_id = _normalize_client_id(client_id)
    if not actual_client_id:
        raise RuntimeError(
            f"client_id is required for attached session tool '{tool_name}'"
        )
    if actual_client_id != attached_client_id:
        raise RuntimeError("session is attached to another client")


def _apply_client_lease(s, client_id, event_name: str, payload=None):
    client_id = _normalize_client_id(client_id)
    if not client_id:
        raise RuntimeError("client_id is required")
    payload = dict(payload or {})
    now = _now()
    s["attached_client_id"] = client_id
    s["attached_at"] = now
    s["detached_at"] = None
    s["last_lease_at"] = now
    s["last_lease_event"] = event_name
    s["last_lease_from_client_id"] = _normalize_client_id(payload.get("from_client_id"))
    s["last_lease_to_client_id"] = (
        _normalize_client_id(payload.get("to_client_id")) or client_id
    )
    s["last_lease_reason"] = _strish(payload.get("reason"), "").strip() or None
    _log_session_event(s, event_name, {"client_id": client_id, **payload})


def _clear_client_lease(s, client_id, event_name: str, payload=None):
    client_id = _normalize_client_id(client_id)
    if not client_id:
        raise RuntimeError("client_id is required")
    payload = dict(payload or {})
    now = _now()
    s["attached_client_id"] = None
    s["attached_at"] = None
    s["detached_at"] = now
    s["last_lease_at"] = now
    s["last_lease_event"] = event_name
    s["last_lease_from_client_id"] = (
        _normalize_client_id(payload.get("from_client_id")) or client_id
    )
    s["last_lease_to_client_id"] = _normalize_client_id(payload.get("to_client_id"))
    s["last_lease_reason"] = _strish(payload.get("reason"), "").strip() or None
    _log_session_event(s, event_name, {"client_id": client_id, **payload})


def _normalize_lease_reason(reason):
    return _strish(reason, "").strip() or None


def _workspace_takeover_sessions(s, client_id):
    tmux_workspace = _normalize_tmux_workspace(s.get("tmux_workspace"))
    if not (_is_tmux_backend_session(s) and tmux_workspace):
        return []
    out = []
    for sid, other in _iter_tmux_workspace_sessions(
        tmux_workspace, owner=s.get("owner"), exclude_session_id=None
    ):
        other_client_id = _normalize_client_id(other.get("attached_client_id"))
        if not other_client_id or other_client_id == client_id:
            continue
        out.append((sid, other, other_client_id))
    return out


def _workspace_client_sessions(s, client_id):
    tmux_workspace = _normalize_tmux_workspace(s.get("tmux_workspace"))
    if not (_is_tmux_backend_session(s) and tmux_workspace):
        return [(s.get("session_id"), s)]
    out = []
    for sid, other in _iter_tmux_workspace_sessions(
        tmux_workspace, owner=s.get("owner"), exclude_session_id=None
    ):
        other_client_id = _normalize_client_id(other.get("attached_client_id"))
        if other_client_id != client_id:
            continue
        out.append((sid, other))
    return out


def _authorize_tool_call(name, args, session=None):
    level = _tool_access_level(name)

    if level == "admin" and not ENABLE_ADMIN_TOOLS:
        raise RuntimeError(f"tool disabled by policy: {name}")
    if level == "control" and not ENABLE_CONTROL_TOOLS:
        raise RuntimeError(f"tool disabled by policy: {name}")

    if (
        name == "pty_spawn"
        and REQUIRE_OWNER
        and not _strish(args.get("owner"), "").strip()
    ):
        raise RuntimeError("owner is required for spawn")

    if (
        name == "pty_list"
        and REQUIRE_OWNER
        and not _strish(args.get("owner"), "").strip()
    ):
        raise RuntimeError("owner is required for pty_list")

    if (
        name == "pty_resume"
        and REQUIRE_OWNER
        and not _strish(args.get("owner"), "").strip()
    ):
        raise RuntimeError("owner is required for pty_resume")

    if (
        name == "pty_recover"
        and REQUIRE_OWNER
        and not _strish(args.get("owner"), "").strip()
    ):
        raise RuntimeError("owner is required for pty_recover")

    if session is not None:
        _require_session_owner(session, args.get("owner"))
        scope = _strish(session.get("scope"), "read-write")
        if not _scope_allows(scope, level):
            raise RuntimeError(f"scope '{scope}' does not allow tool '{name}'")
        if _tool_requires_attached_client(name):
            _require_attached_client(session, args.get("client_id"), name)


def _get_session_for_args(args):
    sid = _strish((args or {}).get("session_id"), "").strip()
    if not sid:
        return None
    return sessions.get(sid)


def read_messages():
    global FRAMING
    fd = sys.stdin.fileno()
    buf = b""
    while True:
        try:
            chunk = os.read(fd, 4096)
        except OSError:
            return
        if not chunk:
            return
        buf += chunk
        if FRAMING is None:
            head = buf.lstrip()
            if head.startswith(b"Content-Length:") or b"\r\n\r\n" in buf[:512]:
                FRAMING = "lsp"
                _log("pty-runner(py) detected framing: lsp(Content-Length)")
            elif head.startswith(b"{") and (b"\n" in buf or b"\r\n" in buf):
                FRAMING = "line"
                _log("pty-runner(py) detected framing: line(NDJSON)")
        progressed = True
        while progressed:
            progressed = False
            if FRAMING in (None, "lsp"):
                header_end = buf.find(b"\r\n\r\n")
                if header_end != -1:
                    header = buf[:header_end].decode("utf-8", errors="replace")
                    rest = buf[header_end + 4 :]
                    content_length = None
                    for line in header.split("\r\n"):
                        if line.lower().startswith("content-length:"):
                            try:
                                content_length = int(line.split(":", 1)[1].strip())
                            except Exception:
                                content_length = None
                    if content_length is not None and len(rest) >= content_length:
                        body = rest[:content_length]
                        buf = rest[content_length:]
                        try:
                            yield json.loads(body.decode("utf-8", errors="replace"))
                        except Exception:
                            _log_limited(
                                "parse_lsp",
                                "failed to parse LSP JSON body:",
                                body[:200],
                            )
                        progressed = True
                        continue
            if FRAMING in (None, "line"):
                nl = buf.find(b"\n")
                if nl != -1:
                    line = buf[:nl]
                    buf = buf[nl + 1 :]
                    line = line.strip()
                    if not line:
                        progressed = True
                        continue
                    try:
                        yield json.loads(line.decode("utf-8", errors="replace"))
                    except Exception:
                        _log_limited(
                            "parse_line", "failed to parse line JSON:", line[:200]
                        )
                    progressed = True
                    continue


def send(msg: dict):
    raw = json.dumps(msg, ensure_ascii=False).encode("utf-8")
    framing = FRAMING or "lsp"
    if framing == "line":
        sys.stdout.buffer.write(raw + b"\n")
    else:
        sys.stdout.buffer.write(
            f"Content-Length: {len(raw)}\r\n\r\n".encode("ascii") + raw
        )
    sys.stdout.buffer.flush()


def ok(id_, result):
    send({"jsonrpc": "2.0", "id": id_, "result": result})


def err(id_, code, message, data=None):
    e = {"jsonrpc": "2.0", "id": id_, "error": {"code": code, "message": message}}
    if data is not None:
        e["error"]["data"] = data
    send(e)


def _intish(v, default):
    if v is None:
        return default
    try:
        return int(v)
    except Exception:
        return default


def _strish(v, default=None):
    if v is None:
        return default
    return str(v)


def _now():
    return time.time()


def set_default_cwd(cwd):
    global DEFAULT_CWD
    DEFAULT_CWD = _normalize_cwd(cwd)
    return {"ok": True, "cwd": DEFAULT_CWD}


def get_default_cwd():
    return {"cwd": _normalize_cwd(DEFAULT_CWD)}


def set_limits(
    max_output_bytes=None,
    spawn_timeout_sec=None,
    idle_timeout_sec=None,
    read_timeout_ms=None,
    rate_limit_bps=None,
    input_rate_bps=None,
):
    global \
        RING_MAX_BYTES, \
        SPAWN_TIMEOUT_SEC, \
        IDLE_TIMEOUT_SEC, \
        DEFAULT_READ_TIMEOUT_MS, \
        OUTPUT_RATE_BPS, \
        INPUT_RATE_BPS
    if max_output_bytes is not None:
        new_max = _intish(max_output_bytes, RING_MAX_BYTES)
        if new_max > 0:
            RING_MAX_BYTES = new_max
            for s in sessions.values():
                s["max_output_bytes"] = RING_MAX_BYTES
    if spawn_timeout_sec is not None:
        new_timeout = _intish(spawn_timeout_sec, SPAWN_TIMEOUT_SEC)
        SPAWN_TIMEOUT_SEC = max(0, new_timeout)
        for s in sessions.values():
            s["max_runtime_sec"] = SPAWN_TIMEOUT_SEC
    if idle_timeout_sec is not None:
        new_idle = _intish(idle_timeout_sec, IDLE_TIMEOUT_SEC)
        IDLE_TIMEOUT_SEC = max(0, new_idle)
    if read_timeout_ms is not None:
        new_read = _intish(read_timeout_ms, DEFAULT_READ_TIMEOUT_MS)
        DEFAULT_READ_TIMEOUT_MS = max(0, new_read)
    if rate_limit_bps is not None:
        new_rate = _intish(rate_limit_bps, OUTPUT_RATE_BPS)
        OUTPUT_RATE_BPS = max(0, new_rate)
        for s in sessions.values():
            s["rate_limit_bps"] = OUTPUT_RATE_BPS
    if input_rate_bps is not None:
        new_rate = _intish(input_rate_bps, INPUT_RATE_BPS)
        INPUT_RATE_BPS = max(0, new_rate)
        for s in sessions.values():
            s["input_rate_bps"] = INPUT_RATE_BPS
    return {
        "max_output_bytes": RING_MAX_BYTES,
        "spawn_timeout_sec": SPAWN_TIMEOUT_SEC,
        "idle_timeout_sec": IDLE_TIMEOUT_SEC,
        "read_timeout_ms": DEFAULT_READ_TIMEOUT_MS,
        "rate_limit_bps": OUTPUT_RATE_BPS,
        "input_rate_bps": INPUT_RATE_BPS,
    }


def get_limits():
    return {
        "max_output_bytes": RING_MAX_BYTES,
        "spawn_timeout_sec": SPAWN_TIMEOUT_SEC,
        "idle_timeout_sec": IDLE_TIMEOUT_SEC,
        "read_timeout_ms": DEFAULT_READ_TIMEOUT_MS,
        "rate_limit_bps": OUTPUT_RATE_BPS,
        "input_rate_bps": INPUT_RATE_BPS,
    }


def _server_version() -> str:
    for dist_name in ("pty-mcp", "pty_mcp"):
        try:
            return pkg_version(dist_name)
        except PackageNotFoundError:
            continue
        except Exception:
            break
    return "unknown"


sessions = {}
dangerous_confirmations = {}


def _is_tmux_backend_session(s) -> bool:
    return _strish(s.get("backend"), "subprocess") == "tmux"


def _tmux_output_path(session_id: str) -> str:
    os.makedirs(TMUX_CAPTURE_DIR, exist_ok=True)
    return os.path.join(TMUX_CAPTURE_DIR, f"{session_id}.log")


def _tmux_session_name(session_key: str) -> str:
    raw = _strish(session_key, "")
    safe = re.sub(r"[^A-Za-z0-9_-]", "-", raw).strip("-")
    if len(safe) > 40:
        safe = safe[:40]
    digest = hashlib.sha1(raw.encode("utf-8", errors="replace")).hexdigest()[:10]
    return f"ptymcp-{safe or 'session'}-{digest}"


def _tmux_run(args, input_bytes=None, allow_failure=False):
    try:
        res = subprocess.run(
            ["tmux", *args],
            input=input_bytes,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            check=False,
        )
    except FileNotFoundError as exc:
        raise RuntimeError("tmux is not installed") from exc
    if res.returncode != 0 and not allow_failure:
        stderr = res.stderr.decode("utf-8", errors="replace").strip()
        stdout = res.stdout.decode("utf-8", errors="replace").strip()
        raise RuntimeError(stderr or stdout or "tmux command failed")
    return res


def _tmux_get_pane_info(s):
    target = _strish(s.get("tmux_pane"), "") or _strish(s.get("tmux_session"), "")
    if not target:
        return None
    res = _tmux_run(
        ["list-panes", "-t", target, "-F", TMUX_PANE_FORMAT], allow_failure=True
    )
    if res.returncode != 0:
        return None
    text = res.stdout.decode("utf-8", errors="replace").strip()
    if not text:
        return None
    parts = text.splitlines()[0].split("\t")
    while len(parts) < 6:
        parts.append("")
    return {
        "pane_id": parts[0],
        "dead": parts[1] == "1",
        "dead_status": _intish(parts[2], 0),
        "current_command": parts[3],
        "cols": _intish(parts[4], s.get("cols", 80)),
        "rows": _intish(parts[5], s.get("rows", 24)),
    }


def _tmux_poll_code(s):
    info = _tmux_get_pane_info(s)
    if info is None:
        return s.get("tmux_exit_code", 0)
    s["cols"] = info["cols"]
    s["rows"] = info["rows"]
    s["tmux_current_command"] = info["current_command"]
    if info["dead"]:
        s["tmux_exit_code"] = info["dead_status"]
        if s.get("exit_reason") is None:
            s["exit_reason"] = "exited"
        return info["dead_status"]
    return None


def _tmux_kill_session(s):
    session_name = _strish(s.get("tmux_session"), "")
    if not session_name:
        return
    _tmux_run(["kill-session", "-t", session_name], allow_failure=True)


def _tmux_stop_capture(s):
    pane_id = _strish(s.get("tmux_pane"), "")
    if not pane_id:
        return
    _tmux_run(["pipe-pane", "-t", pane_id], allow_failure=True)


def _tmux_workspace_member_count(tmux_session: str, exclude_session_id=None) -> int:
    session_name = _strish(tmux_session, "")
    count = 0
    for sid, item in sessions.items():
        if sid == exclude_session_id:
            continue
        if not _is_tmux_backend_session(item):
            continue
        if _strish(item.get("tmux_session"), "") != session_name:
            continue
        count += 1
    return count


def _tmux_kill_pane(s):
    pane_id = _strish(s.get("tmux_pane"), "")
    if not pane_id:
        return
    _tmux_stop_capture(s)
    _tmux_run(["kill-pane", "-t", pane_id], allow_failure=True)


def _tmux_close_member(s, exclude_session_id=None):
    tmux_session = _strish(s.get("tmux_session"), "")
    _tmux_kill_pane(s)
    if (
        tmux_session
        and _tmux_workspace_member_count(
            tmux_session, exclude_session_id=exclude_session_id
        )
        <= 0
    ):
        _tmux_run(["kill-session", "-t", tmux_session], allow_failure=True)


def _tmux_build_shell_command(command: str, cwd: str, env_override):
    env_parts = []
    if isinstance(env_override, dict):
        for key, value in env_override.items():
            if key is None:
                continue
            env_parts.append(
                shlex.quote(f"{str(key)}={'' if value is None else str(value)}")
            )
    runner = f"bash -lc {shlex.quote(_strish(command, ''))}"
    if env_parts:
        runner = f"env {' '.join(env_parts)} {runner}"
    if cwd:
        return f"cd {shlex.quote(cwd)} && exec {runner}"
    return f"exec {runner}"


def _tmux_sync_output(s):
    path = _strish(s.get("tmux_output_path"), "")
    if not path:
        return 0
    try:
        size = os.path.getsize(path)
    except FileNotFoundError:
        return 0
    offset = _intish(s.get("tmux_output_offset"), 0)
    if size < offset:
        offset = 0
    if size == offset:
        return 0
    with open(path, "rb") as f:
        f.seek(offset)
        data = f.read()
    s["tmux_output_offset"] = offset + len(data)
    if not data:
        return 0
    with s["cond"]:
        _append_stream(s, data, stream="pty")
        s["cond"].notify_all()
    return len(data)


class _TmuxProcShim:
    stdout = None
    stderr = None

    def __init__(self, s):
        self._session = s

    def poll(self):
        return _tmux_poll_code(self._session)

    def terminate(self):
        _tmux_close_member(
            self._session, exclude_session_id=self._session.get("session_id")
        )

    def kill(self):
        _tmux_close_member(
            self._session, exclude_session_id=self._session.get("session_id")
        )


def new_id():
    return f"{int(time.time() * 1000)}-{os.getpid()}-{os.urandom(4).hex()}"


def set_winsz(fd, rows, cols):
    winsz = struct.pack("HHHH", rows, cols, 0, 0)
    fcntl.ioctl(fd, termios.TIOCSWINSZ, winsz)


def set_raw(fd):
    """将 PTY 从机设置为 raw 模式，提升交互式程序兼容性。"""
    try:
        tty.setraw(fd)
    except Exception:
        pass


def _touch(s):
    s["last_active"] = _now()


def _normalize_session_name(name):
    name = _strish(name, "").strip()
    return name or None


def _normalize_tmux_workspace(workspace):
    workspace = _strish(workspace, "").strip()
    return workspace or None


def _tmux_workspace_key(workspace, owner=None):
    normalized = _normalize_tmux_workspace(workspace)
    if not normalized:
        return None
    owner_value = _strish(owner, "").strip() or "shared"
    return f"workspace-{owner_value}-{normalized}"


def _find_tmux_workspace(workspace, owner=None):
    normalized = _normalize_tmux_workspace(workspace)
    if not normalized:
        raise RuntimeError("tmux_workspace is required")

    owner_value = _strish(owner, "").strip() or None
    matches = []
    for sid, s in sessions.items():
        if not _is_tmux_backend_session(s):
            continue
        if s.get("tmux_workspace") != normalized:
            continue
        if owner_value is not None and s.get("owner") != owner_value:
            continue
        matches.append((sid, s))

    if not matches:
        return None, None

    if owner_value is None:
        distinct_owners = {
            _strish(item.get("owner"), "").strip() or None for _, item in matches
        }
        if len(distinct_owners) > 1:
            raise RuntimeError(
                f"tmux workspace '{normalized}' is ambiguous; provide owner"
            )

    return matches[0]


def _iter_tmux_workspace_sessions(workspace, owner=None, exclude_session_id=None):
    normalized = _normalize_tmux_workspace(workspace)
    if not normalized:
        return []
    owner_value = _strish(owner, "").strip() or None
    out = []
    for sid, s in sessions.items():
        if exclude_session_id is not None and sid == exclude_session_id:
            continue
        if not _is_tmux_backend_session(s):
            continue
        if s.get("tmux_workspace") != normalized:
            continue
        if owner_value is not None and s.get("owner") != owner_value:
            continue
        out.append((sid, s))
    return out


def _tmux_workspace_attached_client_state(
    workspace, owner=None, exclude_session_id=None
):
    attached_client_ids = set()
    attached_session_count = 0
    for _sid, s in _iter_tmux_workspace_sessions(
        workspace, owner=owner, exclude_session_id=exclude_session_id
    ):
        client_id = _normalize_client_id(s.get("attached_client_id"))
        if not client_id:
            continue
        attached_client_ids.add(client_id)
        attached_session_count += 1
    if len(attached_client_ids) > 1:
        raise RuntimeError("tmux workspace has conflicting attached clients")
    workspace_client_id = next(iter(attached_client_ids), None)
    return {
        "attached": workspace_client_id is not None,
        "attached_client_id": workspace_client_id,
        "attached_session_count": attached_session_count,
    }


def _find_named_session(name, owner=None):
    normalized = _normalize_session_name(name)
    if not normalized:
        raise RuntimeError("name is required")

    owner_value = _strish(owner, "").strip() or None
    matches = []
    for sid, s in sessions.items():
        if s.get("name") != normalized:
            continue
        if owner_value is not None and s.get("owner") != owner_value:
            continue
        matches.append((sid, s))

    if not matches:
        raise RuntimeError(f"unknown session name: {normalized}")
    if owner_value is None and len(matches) > 1:
        raise RuntimeError(f"session name '{normalized}' is ambiguous; provide owner")
    return matches[0]


def _offset_fields(buf_key: str):
    if buf_key == "stdout_buf":
        return "stdout_start_offset", "stdout_total_offset"
    if buf_key == "stderr_buf":
        return "stderr_start_offset", "stderr_total_offset"
    return "buf_start_offset", "buf_total_offset"


def _append_ring(s, data: bytes, buf_key: str = "buf", bypass_rate: bool = False):
    if not data:
        return b""
    s["bytes_read"] += len(data)
    if not bypass_rate and s.get("rate_limit_bps", 0) > 0:
        now = _now()
        window_ts = s.get("rate_window_ts", 0.0)
        if now - window_ts >= 1.0:
            s["rate_window_ts"] = now
            s["rate_window_bytes"] = 0
            s["rate_notice_ts"] = 0.0
        limit = s.get("rate_limit_bps", 0)
        window_bytes = s.get("rate_window_bytes", 0)
        if window_bytes >= limit:
            s["rate_dropped"] = s.get("rate_dropped", 0) + len(data)
            if now - s.get("rate_notice_ts", 0.0) >= 1.0:
                s["rate_notice_ts"] = now
                _append_ring(
                    s,
                    f"\n[pty-mcp] output throttled (limit={limit} bytes/sec)\n".encode(
                        "utf-8", errors="replace"
                    ),
                    buf_key=buf_key,
                    bypass_rate=True,
                )
            return b""
        remaining = limit - window_bytes
        if len(data) > remaining:
            s["rate_window_bytes"] = limit
            s["rate_dropped"] = s.get("rate_dropped", 0) + (len(data) - remaining)
            data = data[:remaining]
            if now - s.get("rate_notice_ts", 0.0) >= 1.0:
                s["rate_notice_ts"] = now
                _append_ring(
                    s,
                    f"\n[pty-mcp] output throttled (limit={limit} bytes/sec)\n".encode(
                        "utf-8", errors="replace"
                    ),
                    buf_key=buf_key,
                    bypass_rate=True,
                )
        else:
            s["rate_window_bytes"] = window_bytes + len(data)
    if not data:
        return b""
    b = s[buf_key]
    b.extend(data)
    start_field, total_field = _offset_fields(buf_key)
    s[total_field] = s.get(total_field, 0) + len(data)
    max_bytes = s.get("max_output_bytes") or RING_MAX_BYTES
    if len(b) > max_bytes:
        drop = len(b) - max_bytes
        del b[:drop]
        s[start_field] = s.get(start_field, 0) + drop
        if not s.get("truncated", False):
            s["truncated"] = True
        flag_key = f"truncated_{buf_key}"
        if not s.get(flag_key, False):
            s[flag_key] = True
            marker = f"\n[pty-mcp] output truncated (limit={max_bytes} bytes)\n".encode(
                "utf-8", errors="replace"
            )
            b.extend(marker)
            if len(b) > max_bytes:
                drop = len(b) - max_bytes
                del b[:drop]
                s[start_field] = s.get(start_field, 0) + drop
    return data


def _append_stream(s, data: bytes, stream: str = "pty"):
    appended = _append_ring(s, data, buf_key="buf", bypass_rate=False)
    if not appended:
        return
    text = appended.decode("utf-8", errors="replace")
    _append_transcript(s, text)
    if stream == "stdout":
        _append_ring(s, appended, buf_key="stdout_buf", bypass_rate=True)
    elif stream == "stderr":
        _append_ring(s, appended, buf_key="stderr_buf", bypass_rate=True)


def _drain_fd(fd, max_bytes=65536):
    """非阻塞尽量读取可用数据，作为读线程异常时的兜底。"""
    chunks = []
    total = 0
    while total < max_bytes:
        try:
            r, _, _ = select.select([fd], [], [], 0)
        except Exception:
            break
        if not r:
            break
        try:
            chunk = os.read(fd, READ_CHUNK_BYTES)
        except Exception:
            break
        if not chunk:
            break
        chunks.append(chunk)
        total += len(chunk)
    return b"".join(chunks)


def _reader_loop_fd(session_id: str, fd: int, stream: str, close_fd: bool):
    s = sessions.get(session_id)
    if not s:
        return
    proc = s["proc"]
    cond = s["cond"]
    try:
        while True:
            with cond:
                if s["closed"]:
                    return
            try:
                r, _, _ = select.select([fd], [], [], 0.2)
            except Exception:
                r = []
            if r:
                try:
                    chunk = os.read(fd, READ_CHUNK_BYTES)
                except Exception:
                    chunk = b""
                if chunk:
                    with cond:
                        _append_stream(s, chunk, stream=stream)
                        cond.notify_all()
            if proc.poll() is not None:
                if s.get("exit_reason") is None:
                    s["exit_reason"] = "exited"
                with cond:
                    cond.notify_all()
                time.sleep(0.05)
    except Exception:
        _log_limited("reader_loop", "reader loop error:", traceback.format_exc())
        with cond:
            s["closed"] = True
            cond.notify_all()
    finally:
        if close_fd:
            try:
                os.close(fd)
            except Exception:
                pass


def _cleanup_session(session_id, s):
    with s["cond"]:
        s["closed"] = True
        s["cond"].notify_all()
    proc = s["proc"]
    if _is_tmux_backend_session(s):
        try:
            if proc.poll() is None and s.get("exit_reason") is None:
                s["exit_reason"] = "killed"
        except Exception:
            pass
        try:
            _tmux_close_member(s, exclude_session_id=session_id)
        except Exception:
            pass
        path = _strish(s.get("tmux_output_path"), "")
        if path:
            try:
                os.remove(path)
            except FileNotFoundError:
                pass
            except Exception:
                pass
        _forget_recovery_manifest(s, exclude_session_id=session_id)
        return
    try:
        if proc.poll() is None:
            s["exit_reason"] = s.get("exit_reason") or "killed"
            try:
                os.killpg(s["pgid"], signal.SIGTERM)
            except Exception:
                try:
                    proc.terminate()
                except Exception:
                    pass
            deadline = _now() + 1.0
            while _now() < deadline and proc.poll() is None:
                time.sleep(0.05)
            if proc.poll() is None:
                try:
                    os.killpg(s["pgid"], signal.SIGKILL)
                except Exception:
                    try:
                        proc.kill()
                    except Exception:
                        pass
    except Exception:
        pass
    try:
        os.close(s["master_fd"])
    except Exception:
        pass
    try:
        if proc.stdout is not None:
            proc.stdout.close()
    except Exception:
        pass
    try:
        if proc.stderr is not None:
            proc.stderr.close()
    except Exception:
        pass
    _forget_recovery_manifest(s, exclude_session_id=session_id)


def gc_sessions(force=False):
    global _last_gc
    now = _now()
    _gc_dangerous_confirmations()
    if not force and (now - _last_gc) < GC_INTERVAL_SEC:
        return
    _last_gc = now
    to_close = []
    for sid, s in list(sessions.items()):
        if now - s["last_active"] > (IDLE_TIMEOUT_SEC + IDLE_GRACE_SEC):
            to_close.append(sid)
            continue
        if s["proc"].poll() is not None and now - s["last_active"] > IDLE_GRACE_SEC:
            to_close.append(sid)
    for sid in to_close:
        s = sessions.pop(sid, None)
        if s:
            _cleanup_session(sid, s)
    if len(sessions) > MAX_SESSIONS:
        exited, running = [], []
        for sid, s in sessions.items():
            if s["proc"].poll() is None:
                running.append((sid, s))
            else:
                exited.append((sid, s))
        exited.sort(key=lambda kv: kv[1]["last_active"])
        running.sort(key=lambda kv: kv[1]["last_active"])
        ordered = exited + running
        need = len(sessions) - MAX_SESSIONS
        for sid, _s in ordered[:need]:
            s = sessions.pop(sid, None)
            if s:
                _cleanup_session(sid, s)


def _normalize_cwd(cwd):
    if cwd is None:
        return None
    cwd = _strish(cwd, "")
    if not cwd:
        return None
    return os.path.realpath(os.path.expanduser(cwd))


def _infer_cwd_from_command(command: str):
    cmd = _strish(command, "").strip()
    if not cmd:
        return None
    first = None
    try:
        parts = shlex.split(cmd, posix=True)
        if parts:
            first = parts[0]
    except Exception:
        first = None
    if first is None:
        first = cmd.split()[0] if cmd.split() else None
    if not first or not first.startswith("/"):
        return None
    path = os.path.realpath(first)
    if os.path.isdir(path):
        return path
    if os.path.isfile(path):
        return os.path.dirname(path)
    return None


def _consume_from_buffer(s, buf_key: str, max_chars: int):
    b = s[buf_key]
    if not b:
        return b""
    take = min(len(b), max_chars)
    data = bytes(b[:take])
    del b[:take]
    start_field, _ = _offset_fields(buf_key)
    s[start_field] = s.get(start_field, 0) + take
    return data


def _next_chunk_id(s):
    s["chunk_seq"] = s.get("chunk_seq", 0) + 1
    return s["chunk_seq"]


def _format_read_response(s, text: str, stream: str, fmt: str, chunk_id: int):
    if fmt != "json":
        return text
    payload = {
        "chunk_id": chunk_id,
        "stream": stream,
        "text": text,
        "timestamp": _now(),
    }
    return json.dumps(payload, ensure_ascii=False)


def _ensure_session_log_dir(s):
    if not SESSION_LOG_DIR:
        return None
    base = os.path.realpath(os.path.expanduser(SESSION_LOG_DIR))
    session_dir = os.path.join(base, s["session_id"])
    try:
        os.makedirs(session_dir, exist_ok=True)
    except Exception:
        return None
    return session_dir


def _append_transcript(s, text: str):
    if not text:
        return
    session_dir = _ensure_session_log_dir(s)
    if not session_dir:
        return
    path = os.path.join(session_dir, "transcript.log")
    try:
        with open(path, "a", encoding="utf-8", errors="replace") as f:
            f.write(text)
    except Exception:
        pass


def _log_session_event(s, event: str, payload: dict):
    session_dir = _ensure_session_log_dir(s)
    if not session_dir:
        return
    path = os.path.join(session_dir, "events.jsonl")
    record = {
        "ts": _now(),
        "event": event,
        **payload,
    }
    try:
        with open(path, "a", encoding="utf-8", errors="replace") as f:
            f.write(json.dumps(record, ensure_ascii=False) + "\n")
    except Exception:
        pass


def _state_dir():
    base = os.path.realpath(os.path.expanduser(STATE_DIR))
    os.makedirs(base, exist_ok=True)
    return base


def _recovery_group_key_for_values(owner, name=None, tmux_workspace=None):
    owner_value = _strish(owner, "").strip()
    workspace_value = _normalize_tmux_workspace(tmux_workspace)
    if workspace_value:
        return f"workspace::{owner_value}::{workspace_value}"
    name_value = _normalize_session_name(name)
    if name_value:
        return f"session::{owner_value}::{name_value}"
    return None


def _recovery_group_key(s):
    return _recovery_group_key_for_values(
        s.get("owner"), name=s.get("name"), tmux_workspace=s.get("tmux_workspace")
    )


def _recovery_manifest_path_from_key(group_key):
    group_key = _strish(group_key, "")
    if not group_key:
        return None
    prefix = "workspace" if group_key.startswith("workspace::") else "session"
    digest = hashlib.sha1(group_key.encode("utf-8", errors="replace")).hexdigest()[:16]
    return os.path.join(_state_dir(), f"{prefix}-{digest}.json")


def _recovery_manifest_path_for_session(s):
    return _recovery_manifest_path_from_key(_recovery_group_key(s))


def _delete_recovery_manifest_path(path):
    if not path:
        return
    try:
        os.remove(path)
    except FileNotFoundError:
        pass


def _is_recoverable_session(s) -> bool:
    return bool(
        _is_tmux_backend_session(s)
        and _normalize_session_name(s.get("name"))
        and _strish(s.get("owner"), "").strip()
    )


def _recovery_group_members(s):
    tmux_workspace = _normalize_tmux_workspace(s.get("tmux_workspace"))
    if _is_tmux_backend_session(s) and tmux_workspace:
        return _iter_tmux_workspace_sessions(tmux_workspace, owner=s.get("owner"))
    session_id = _strish(s.get("session_id"), "")
    current = sessions.get(session_id)
    if session_id and current is not None:
        return [(session_id, current)]
    return []


def _build_recovery_manifest(s):
    if not _is_recoverable_session(s):
        return None
    members = _recovery_group_members(s)
    if not members:
        return None

    owner_value = _strish(s.get("owner"), "").strip()
    workspace_value = _normalize_tmux_workspace(s.get("tmux_workspace"))
    group_key = _recovery_group_key(s)
    tmux_session_ids = set()
    manifest_members = []

    for sid, item in members:
        if not _is_recoverable_session(item):
            return None
        if _strish(item.get("owner"), "").strip() != owner_value:
            return None
        if _normalize_tmux_workspace(item.get("tmux_workspace")) != workspace_value:
            return None
        tmux_session = _strish(item.get("tmux_session"), "").strip()
        tmux_pane = _strish(item.get("tmux_pane"), "").strip()
        if not tmux_session or not tmux_pane:
            return None
        tmux_session_ids.add(tmux_session)
        manifest_members.append(
            {
                "session_id": sid,
                "name": item.get("name"),
                "owner": item.get("owner"),
                "backend": item.get("backend"),
                "command": item.get("command"),
                "cwd": item.get("cwd"),
                "cols": item.get("cols"),
                "rows": item.get("rows"),
                "tag": item.get("tag"),
                "scope": item.get("scope"),
                "max_output_bytes": item.get("max_output_bytes"),
                "max_runtime_sec": item.get("max_runtime_sec"),
                "rate_limit_bps": item.get("rate_limit_bps"),
                "input_rate_bps": item.get("input_rate_bps"),
                "read_timeout_ms": item.get("read_timeout_ms"),
                "env_override": item.get("env_override"),
                "created_at": item.get("created_at"),
                "tmux_session": tmux_session,
                "tmux_pane": tmux_pane,
                "tmux_workspace": workspace_value,
                "last_lease_at": item.get("last_lease_at"),
                "last_lease_event": item.get("last_lease_event"),
                "last_lease_from_client_id": item.get("last_lease_from_client_id"),
                "last_lease_to_client_id": item.get("last_lease_to_client_id"),
                "last_lease_reason": item.get("last_lease_reason"),
                "detached_at": item.get("detached_at"),
            }
        )

    if len(tmux_session_ids) != 1:
        return None

    manifest_members.sort(key=lambda item: item["session_id"])
    return {
        "schema_version": 1,
        "group_key": group_key,
        "group_type": "tmux_workspace" if workspace_value else "tmux_single",
        "owner": owner_value,
        "tmux_workspace": workspace_value,
        "tmux_session": next(iter(tmux_session_ids)),
        "updated_at": _now(),
        "members": manifest_members,
    }


def _write_manifest_file(path, manifest):
    if not path or manifest is None:
        return
    directory = os.path.dirname(path)
    os.makedirs(directory, exist_ok=True)
    tmp_path = f"{path}.tmp-{os.getpid()}-{os.urandom(4).hex()}"
    try:
        with open(tmp_path, "w", encoding="utf-8") as f:
            json.dump(manifest, f, ensure_ascii=False, sort_keys=True)
        os.replace(tmp_path, path)
    finally:
        try:
            if os.path.exists(tmp_path):
                os.remove(tmp_path)
        except Exception:
            pass


def _write_recovery_manifest(s):
    path = _recovery_manifest_path_for_session(s)
    if not path:
        return False
    manifest = _build_recovery_manifest(s)
    if manifest is None:
        _delete_recovery_manifest_path(path)
        return False
    _write_manifest_file(path, manifest)
    return True


def _delete_or_rewrite_recovery_manifest(s, exclude_session_id=None):
    path = _recovery_manifest_path_for_session(s)
    if not path:
        return
    tmux_workspace = _normalize_tmux_workspace(s.get("tmux_workspace"))
    if _is_tmux_backend_session(s) and tmux_workspace:
        remaining = _iter_tmux_workspace_sessions(
            tmux_workspace, owner=s.get("owner"), exclude_session_id=exclude_session_id
        )
        if not remaining:
            _delete_recovery_manifest_path(path)
            return
        manifest = _build_recovery_manifest(remaining[0][1])
        if manifest is None:
            _delete_recovery_manifest_path(path)
            return
        _write_manifest_file(path, manifest)
        return
    _delete_recovery_manifest_path(path)


def _sync_recovery_manifest(s):
    try:
        _write_recovery_manifest(s)
    except Exception:
        _log_limited(
            "recovery_manifest_write",
            "recovery manifest write error:",
            traceback.format_exc(),
        )


def _forget_recovery_manifest(s, exclude_session_id=None):
    try:
        _delete_or_rewrite_recovery_manifest(s, exclude_session_id=exclude_session_id)
    except Exception:
        _log_limited(
            "recovery_manifest_delete",
            "recovery manifest cleanup error:",
            traceback.format_exc(),
        )


def _load_recovery_manifest(name, owner):
    name_value = _normalize_session_name(name)
    owner_value = _strish(owner, "").strip()
    if not name_value:
        raise RuntimeError("name is required")
    if not owner_value:
        raise RuntimeError("owner is required for pty_recover")
    try:
        filenames = sorted(os.listdir(_state_dir()))
    except FileNotFoundError:
        filenames = []
    matches = []
    for filename in filenames:
        if not filename.endswith(".json"):
            continue
        path = os.path.join(_state_dir(), filename)
        try:
            with open(path, "r", encoding="utf-8") as f:
                manifest = json.load(f)
        except Exception:
            continue
        members = manifest.get("members")
        if not isinstance(members, list):
            continue
        for member in members:
            if not isinstance(member, dict):
                continue
            if _normalize_session_name(member.get("name")) != name_value:
                continue
            if _strish(member.get("owner"), "").strip() != owner_value:
                continue
            matches.append((path, manifest, member))
            break
    if not matches:
        raise RuntimeError(
            f"no recoverable session named '{name_value}' for owner '{owner_value}'"
        )
    if len(matches) > 1:
        raise RuntimeError(f"recovery manifest for '{name_value}' is ambiguous")
    return matches[0]


def _tmux_session_panes(tmux_session: str):
    tmux_session = _strish(tmux_session, "").strip()
    if not tmux_session:
        raise RuntimeError("tmux session is required")
    res = _tmux_run(["list-panes", "-t", f"{tmux_session}:0", "-F", TMUX_PANE_FORMAT])
    panes = {}
    text = res.stdout.decode("utf-8", errors="replace").strip()
    for line in text.splitlines():
        parts = line.split("\t")
        while len(parts) < 6:
            parts.append("")
        panes[parts[0]] = {
            "pane_id": parts[0],
            "dead": parts[1] == "1",
            "dead_status": _intish(parts[2], 0),
            "current_command": parts[3],
            "cols": _intish(parts[4], 0),
            "rows": _intish(parts[5], 0),
        }
    return panes


def _tmux_validate_recovery_member(member, pane_map):
    if _strish(member.get("backend"), "") != "tmux":
        raise RuntimeError("only tmux sessions are recoverable")
    if not _normalize_session_name(member.get("name")):
        raise RuntimeError("recovery member is missing name")
    if not _strish(member.get("owner"), "").strip():
        raise RuntimeError("recovery member is missing owner")
    pane_id = _strish(member.get("tmux_pane"), "").strip()
    if not pane_id:
        raise RuntimeError("recovery member is missing tmux pane")
    info = pane_map.get(pane_id)
    if info is None:
        raise RuntimeError("recovery pane is missing")
    if info.get("dead"):
        raise RuntimeError("recovery pane is not live")
    return info


def _tmux_rearm_capture(s):
    pane_id = _strish(s.get("tmux_pane"), "")
    if not pane_id:
        raise RuntimeError("recovery session is missing tmux pane")
    tmux_output_path = _tmux_output_path(s.get("session_id"))
    with open(tmux_output_path, "wb"):
        pass
    _tmux_run(["pipe-pane", "-t", pane_id], allow_failure=True)
    _tmux_run(
        [
            "pipe-pane",
            "-t",
            pane_id,
            "-O",
            f"cat >> {shlex.quote(tmux_output_path)}",
        ]
    )
    s["tmux_output_path"] = tmux_output_path
    s["tmux_output_offset"] = 0


def _read_from_buffer(s, buf_key: str, offset: int, max_chars: int):
    start_field, _ = _offset_fields(buf_key)
    start_offset = s.get(start_field, 0)
    b = s[buf_key]
    if offset < start_offset:
        return {"truncated": True, "text": "", "next_offset": start_offset}
    rel = offset - start_offset
    if rel > len(b):
        rel = len(b)
    take = min(len(b) - rel, max_chars)
    data = bytes(b[rel : rel + take])
    next_offset = start_offset + rel + take
    return {
        "truncated": False,
        "text": data.decode("utf-8", errors="replace"),
        "next_offset": next_offset,
    }


def _check_runtime(s):
    max_runtime = s.get("max_runtime_sec")
    if not max_runtime or max_runtime <= 0:
        return False
    if _now() - s["created_at"] <= max_runtime:
        return False
    proc = s["proc"]
    try:
        os.killpg(s["pgid"], signal.SIGTERM)
    except Exception:
        try:
            proc.terminate()
        except Exception:
            pass
    s["closed"] = True
    s["exit_reason"] = "timeout"
    _append_ring(
        s, f"\n[pty-mcp] session timed out (limit={max_runtime}s)\n".encode("utf-8")
    )
    return True


def _check_input_rate(s, size):
    limit = s.get("input_rate_bps", 0)
    if not limit or limit <= 0:
        return True
    now = _now()
    window_ts = s.get("input_window_ts", 0.0)
    if now - window_ts >= 1.0:
        s["input_window_ts"] = now
        s["input_window_bytes"] = 0
        s["input_notice_ts"] = 0.0
    window_bytes = s.get("input_window_bytes", 0)
    if window_bytes >= limit:
        s["input_dropped"] = s.get("input_dropped", 0) + size
        if now - s.get("input_notice_ts", 0.0) >= 1.0:
            s["input_notice_ts"] = now
            _append_ring(
                s,
                f"\n[pty-mcp] input throttled (limit={limit} bytes/sec)\n".encode(
                    "utf-8", errors="replace"
                ),
                buf_key="buf",
                bypass_rate=True,
            )
        return False
    remaining = limit - window_bytes
    if size > remaining:
        s["input_window_bytes"] = limit
        s["input_dropped"] = s.get("input_dropped", 0) + (size - remaining)
        if now - s.get("input_notice_ts", 0.0) >= 1.0:
            s["input_notice_ts"] = now
            _append_ring(
                s,
                f"\n[pty-mcp] input throttled (limit={limit} bytes/sec)\n".encode(
                    "utf-8", errors="replace"
                ),
                buf_key="buf",
                bypass_rate=True,
            )
        return False
    s["input_window_bytes"] = window_bytes + size
    return True


def pty_spawn(
    command,
    cwd=None,
    cols=120,
    rows=30,
    separate_streams=False,
    name=None,
    tag=None,
    owner=None,
    scope=None,
    max_output_bytes=None,
    spawn_timeout_sec=None,
    rate_limit_bps=None,
    input_rate_bps=None,
    read_timeout_ms=None,
    env_override=None,
    backend=None,
    tmux_workspace=None,
    dangerous_confirmed=False,
    dangerous_justification=None,
    dangerous_confirm_token=None,
):
    gc_sessions()
    if len(sessions) >= MAX_SESSIONS:
        raise RuntimeError(f"too many sessions (max {MAX_SESSIONS})")
    command = _strish(command, "")
    if not command:
        raise RuntimeError("command is required")
    backend = _strish(backend, DEFAULT_BACKEND) or DEFAULT_BACKEND
    if backend not in ("subprocess", "tmux"):
        raise RuntimeError(f"unsupported backend: {backend}")
    name = _normalize_session_name(name)
    if name is not None:
        existing_sid, _existing = None, None
        try:
            existing_sid, _existing = _find_named_session(name, owner=owner)
        except RuntimeError as exc:
            if not str(exc).startswith("unknown session name:"):
                raise
        if existing_sid is not None:
            raise RuntimeError(
                f"session name '{name}' already exists; close or resume it instead"
            )
    matches = _is_dangerous_command(command)
    confirmation_mode = "none"
    if matches:
        token = _strish(dangerous_confirm_token, "").strip()
        if token:
            _consume_dangerous_confirmation(
                token=token,
                command=command,
                owner=owner,
                scope=scope,
            )
            confirmation_mode = "token"
        elif DANGEROUS_LEGACY_MODE:
            legacy_justification = _strish(dangerous_justification, "").strip()
            if not dangerous_confirmed and not legacy_justification:
                raise RuntimeError(
                    "dangerous command detected; use confirm_dangerous_command"
                )
            confirmation_mode = "legacy"
            _log("dangerous legacy confirmation path used", level="warn")
        else:
            raise RuntimeError(
                "dangerous command detected; confirmation token required"
            )
    cols = _intish(cols, 120)
    rows = _intish(rows, 30)
    cwd = _normalize_cwd(cwd)
    inferred_cwd = _infer_cwd_from_command(command)
    effective_cwd = cwd or _normalize_cwd(DEFAULT_CWD) or inferred_cwd or os.getcwd()
    sid = new_id()
    cond = threading.Condition()
    max_output_bytes = _intish(max_output_bytes, RING_MAX_BYTES)
    if max_output_bytes <= 0:
        max_output_bytes = RING_MAX_BYTES
    max_runtime_sec = _intish(spawn_timeout_sec, SPAWN_TIMEOUT_SEC)
    if max_runtime_sec < 0:
        max_runtime_sec = 0
    rate_limit_bps = _intish(rate_limit_bps, OUTPUT_RATE_BPS)
    if rate_limit_bps < 0:
        rate_limit_bps = 0
    input_rate_bps = _intish(input_rate_bps, INPUT_RATE_BPS)
    if input_rate_bps < 0:
        input_rate_bps = 0
    read_timeout_ms = _intish(read_timeout_ms, DEFAULT_READ_TIMEOUT_MS)
    if read_timeout_ms < 0:
        read_timeout_ms = DEFAULT_READ_TIMEOUT_MS
    scope = _strish(scope, "read-write") or "read-write"
    separate_streams = bool(separate_streams)
    tmux_workspace = _normalize_tmux_workspace(tmux_workspace)

    workspace_sid, workspace_session = None, None
    if tmux_workspace is not None:
        if backend != "tmux":
            raise RuntimeError("tmux_workspace requires backend='tmux'")
        workspace_sid, workspace_session = _find_tmux_workspace(
            tmux_workspace, owner=owner
        )
        if workspace_session is not None:
            workspace_scope = (
                _strish(workspace_session.get("scope"), "read-write") or "read-write"
            )
            if workspace_scope != scope:
                raise RuntimeError(
                    f"tmux workspace '{tmux_workspace}' already exists with scope '{workspace_scope}'"
                )

    if backend == "subprocess":
        master_fd, slave_fd = pty.openpty()
        try:
            set_winsz(slave_fd, rows, cols)
        except Exception:
            pass
        # 尝试启用 raw 模式，改善交互式安装器的 TTY 判断/行为。
        set_raw(slave_fd)
        fl = fcntl.fcntl(master_fd, fcntl.F_GETFL)
        fcntl.fcntl(master_fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
        env = os.environ.copy()
        if isinstance(env_override, dict):
            for k, v in env_override.items():
                if k is None:
                    continue
                env[str(k)] = "" if v is None else str(v)
        # 透传/补齐终端环境变量，尽量模拟真实 TTY 环境。
        env.setdefault("TERM", "xterm-256color")
        env.setdefault("COLORTERM", "truecolor")
        env.setdefault("LANG", "C.UTF-8")
        env.setdefault("LC_ALL", env.get("LANG", "C.UTF-8"))
        stdout_target = slave_fd
        stderr_target = slave_fd
        if separate_streams:
            stdout_target = subprocess.PIPE
            stderr_target = subprocess.PIPE
        proc = subprocess.Popen(
            ["bash", "-lc", command],
            stdin=slave_fd,
            stdout=stdout_target,
            stderr=stderr_target,
            cwd=effective_cwd,
            env=env,
            start_new_session=True,
            close_fds=True,
        )
        os.close(slave_fd)
        try:
            pgid = os.getpgid(proc.pid)
        except Exception:
            pgid = proc.pid
        backend_state = {
            "master_fd": master_fd,
            "proc": proc,
            "pid": proc.pid,
            "pgid": pgid,
            "separate_streams": separate_streams,
            "reader_threads": [],
        }
    else:
        if separate_streams:
            raise RuntimeError("separate_streams is unsupported for tmux backend")
        workspace_key = _tmux_workspace_key(tmux_workspace, owner=owner)
        tmux_session = _tmux_session_name(workspace_key or sid)
        tmux_output_path = _tmux_output_path(sid)
        pane_id = None
        try:
            with open(tmux_output_path, "wb"):
                pass
            if workspace_session is None:
                _tmux_run(["kill-session", "-t", tmux_session], allow_failure=True)
                _tmux_run(
                    [
                        "new-session",
                        "-d",
                        "-s",
                        tmux_session,
                        "-x",
                        str(cols),
                        "-y",
                        str(rows),
                        "-c",
                        effective_cwd,
                        "sleep 3600",
                    ]
                )
            else:
                tmux_session = _strish(workspace_session.get("tmux_session"), "")
                pane_res = _tmux_run(
                    [
                        "split-window",
                        "-d",
                        "-P",
                        "-F",
                        "#{pane_id}",
                        "-t",
                        f"{tmux_session}:0",
                        "-c",
                        effective_cwd,
                        "sleep 3600",
                    ]
                )
                pane_id = pane_res.stdout.decode("utf-8", errors="replace").strip()
            _tmux_run(["set-option", "-t", f"{tmux_session}:0", "remain-on-exit", "on"])
            if not pane_id:
                pane_res = _tmux_run(
                    ["list-panes", "-t", f"{tmux_session}:0", "-F", "#{pane_id}"]
                )
                pane_id = (
                    pane_res.stdout.decode("utf-8", errors="replace")
                    .strip()
                    .splitlines()[0]
                )
            _tmux_run(
                [
                    "pipe-pane",
                    "-t",
                    pane_id,
                    "-O",
                    f"cat >> {shlex.quote(tmux_output_path)}",
                ]
            )
            tmux_command = _tmux_build_shell_command(
                command, effective_cwd, env_override
            )
            _tmux_run(["respawn-pane", "-k", "-t", pane_id, tmux_command])
            _tmux_run(
                ["resize-pane", "-t", pane_id, "-x", str(cols), "-y", str(rows)],
                allow_failure=True,
            )
        except Exception:
            if pane_id:
                _tmux_run(["pipe-pane", "-t", pane_id], allow_failure=True)
                _tmux_run(["kill-pane", "-t", pane_id], allow_failure=True)
            if workspace_session is None:
                _tmux_run(["kill-session", "-t", tmux_session], allow_failure=True)
            try:
                os.remove(tmux_output_path)
            except Exception:
                pass
            raise
        backend_state = {
            "master_fd": -1,
            "proc": None,
            "pid": None,
            "pgid": None,
            "separate_streams": False,
            "reader_threads": [],
            "tmux_session": tmux_session,
            "tmux_pane": pane_id,
            "tmux_output_path": tmux_output_path,
            "tmux_output_offset": 0,
            "tmux_exit_code": None,
            "tmux_current_command": None,
            "tmux_workspace": tmux_workspace,
        }

    s = {
        "created_at": _now(),
        "last_active": _now(),
        "backend": backend,
        "command": command,
        "cwd": effective_cwd,
        "cols": cols,
        "rows": rows,
        "buf": bytearray(),
        "stdout_buf": bytearray(),
        "stderr_buf": bytearray(),
        "buf_start_offset": 0,
        "buf_total_offset": 0,
        "stdout_start_offset": 0,
        "stdout_total_offset": 0,
        "stderr_start_offset": 0,
        "stderr_total_offset": 0,
        "closed": False,
        "cond": cond,
        "max_output_bytes": max_output_bytes,
        "max_runtime_sec": max_runtime_sec,
        "rate_limit_bps": rate_limit_bps,
        "rate_window_ts": 0.0,
        "rate_window_bytes": 0,
        "rate_notice_ts": 0.0,
        "rate_dropped": 0,
        "input_rate_bps": input_rate_bps,
        "input_window_ts": 0.0,
        "input_window_bytes": 0,
        "input_notice_ts": 0.0,
        "input_dropped": 0,
        "name": name,
        "tag": _strish(tag, None),
        "owner": _strish(owner, None),
        "scope": scope,
        "tmux_workspace": tmux_workspace,
        "attached_client_id": None,
        "attached_at": None,
        "detached_at": None,
        "last_lease_at": None,
        "last_lease_event": None,
        "last_lease_from_client_id": None,
        "last_lease_to_client_id": None,
        "last_lease_reason": None,
        "recovered": False,
        "recovered_at": None,
        "read_timeout_ms": read_timeout_ms,
        "chunk_seq": 0,
        "session_id": sid,
        "env_override": env_override if isinstance(env_override, dict) else None,
        "truncated": False,
        # 统计数据
        "bytes_read": 0,
        "bytes_written": 0,
    }
    s.update(backend_state)
    if backend == "tmux":
        s["proc"] = _TmuxProcShim(s)
    sessions[sid] = s
    _log_session_event(
        s,
        "spawn",
        {
            "command": command,
            "cwd": effective_cwd,
            "cols": cols,
            "rows": rows,
            "name": s.get("name"),
            "tag": s.get("tag"),
            "owner": s.get("owner"),
            "scope": s.get("scope"),
            "backend": backend,
            "dangerous": bool(matches),
            "dangerous_matches": matches,
            "confirmation_mode": confirmation_mode,
            "dangerous_confirm_token_present": bool(
                _strish(dangerous_confirm_token, "").strip()
            ),
            "dangerous_justification_present": bool(
                _strish(dangerous_justification, "").strip()
            ),
        },
    )
    _sync_recovery_manifest(s)
    if backend == "subprocess":
        proc = s["proc"]
        threads = []
        if separate_streams and proc.stdout is not None and proc.stderr is not None:
            t_out = threading.Thread(
                target=_reader_loop_fd,
                args=(sid, proc.stdout.fileno(), "stdout", True),
                daemon=True,
            )
            t_err = threading.Thread(
                target=_reader_loop_fd,
                args=(sid, proc.stderr.fileno(), "stderr", True),
                daemon=True,
            )
            threads.extend([t_out, t_err])
            t_out.start()
            t_err.start()
        else:
            t = threading.Thread(
                target=_reader_loop_fd,
                args=(sid, s["master_fd"], "pty", False),
                daemon=True,
            )
            threads.append(t)
            t.start()
        s["reader_threads"] = threads
    else:
        _tmux_sync_output(s)
    return sid


def pty_read(
    session_id,
    max_chars=DEFAULT_MAX_CHARS,
    timeout_ms=None,
    format=None,
    client_id=None,
):
    gc_sessions()
    s = sessions.get(session_id)
    if not s:
        raise RuntimeError("unknown session_id")
    if _check_runtime(s):
        return ""
    _require_attached_client(s, client_id, "pty_read")
    _touch(s)
    max_chars = _intish(max_chars, DEFAULT_MAX_CHARS)
    if timeout_ms is None:
        timeout_ms = s.get("read_timeout_ms", DEFAULT_READ_TIMEOUT_MS)
    timeout_ms = _intish(timeout_ms, DEFAULT_READ_TIMEOUT_MS)
    if _is_tmux_backend_session(s):
        deadline = (
            _now() + (timeout_ms / 1000.0) if timeout_ms and timeout_ms > 0 else None
        )
        while True:
            _tmux_sync_output(s)
            with s["cond"]:
                if s["buf"]:
                    data = _consume_from_buffer(s, "buf", max_chars)
                    text = data.decode("utf-8", errors="replace")
                    chunk_id = _next_chunk_id(s)
                    return _format_read_response(
                        s, text, "pty", format or "text", chunk_id
                    )
            if s["proc"].poll() is not None:
                return ""
            if deadline is None or _now() >= deadline:
                return ""
            time.sleep(0.05)
    deadline = (
        _now() + (timeout_ms / 1000.0) if timeout_ms and timeout_ms > 0 else _now()
    )
    with s["cond"]:
        while not s["buf"] and not s["closed"] and timeout_ms and timeout_ms > 0:
            remaining = deadline - _now()
            if remaining <= 0:
                break
            s["cond"].wait(timeout=min(0.2, remaining))
        if not s["buf"]:
            direct = _drain_fd(s["master_fd"])
            if direct:
                _append_stream(s, direct, stream="pty")
        if not s["buf"]:
            return ""
        data = _consume_from_buffer(s, "buf", max_chars)
    text = data.decode("utf-8", errors="replace")
    chunk_id = _next_chunk_id(s)
    return _format_read_response(s, text, "pty", format or "text", chunk_id)


def pty_read_stdout(
    session_id,
    max_chars=DEFAULT_MAX_CHARS,
    timeout_ms=None,
    format=None,
    client_id=None,
):
    gc_sessions()
    s = sessions.get(session_id)
    if not s:
        raise RuntimeError("unknown session_id")
    if _check_runtime(s):
        return ""
    _require_attached_client(s, client_id, "pty_read_stdout")
    _touch(s)
    max_chars = _intish(max_chars, DEFAULT_MAX_CHARS)
    if timeout_ms is None:
        timeout_ms = s.get("read_timeout_ms", DEFAULT_READ_TIMEOUT_MS)
    timeout_ms = _intish(timeout_ms, DEFAULT_READ_TIMEOUT_MS)
    deadline = (
        _now() + (timeout_ms / 1000.0) if timeout_ms and timeout_ms > 0 else _now()
    )
    with s["cond"]:
        while not s["stdout_buf"] and not s["closed"] and timeout_ms and timeout_ms > 0:
            remaining = deadline - _now()
            if remaining <= 0:
                break
            s["cond"].wait(timeout=min(0.2, remaining))
        if not s["stdout_buf"]:
            return ""
        data = _consume_from_buffer(s, "stdout_buf", max_chars)
    text = data.decode("utf-8", errors="replace")
    chunk_id = _next_chunk_id(s)
    return _format_read_response(s, text, "stdout", format or "text", chunk_id)


def pty_read_stderr(
    session_id,
    max_chars=DEFAULT_MAX_CHARS,
    timeout_ms=None,
    format=None,
    client_id=None,
):
    gc_sessions()
    s = sessions.get(session_id)
    if not s:
        raise RuntimeError("unknown session_id")
    if _check_runtime(s):
        return ""
    _require_attached_client(s, client_id, "pty_read_stderr")
    _touch(s)
    max_chars = _intish(max_chars, DEFAULT_MAX_CHARS)
    if timeout_ms is None:
        timeout_ms = s.get("read_timeout_ms", DEFAULT_READ_TIMEOUT_MS)
    timeout_ms = _intish(timeout_ms, DEFAULT_READ_TIMEOUT_MS)
    deadline = (
        _now() + (timeout_ms / 1000.0) if timeout_ms and timeout_ms > 0 else _now()
    )
    with s["cond"]:
        while not s["stderr_buf"] and not s["closed"] and timeout_ms and timeout_ms > 0:
            remaining = deadline - _now()
            if remaining <= 0:
                break
            s["cond"].wait(timeout=min(0.2, remaining))
        if not s["stderr_buf"]:
            return ""
        data = _consume_from_buffer(s, "stderr_buf", max_chars)
    text = data.decode("utf-8", errors="replace")
    chunk_id = _next_chunk_id(s)
    return _format_read_response(s, text, "stderr", format or "text", chunk_id)


def pty_read_at(session_id, offset=0, max_chars=DEFAULT_MAX_CHARS, stream=None):
    gc_sessions()
    s = sessions.get(session_id)
    if not s:
        raise RuntimeError("unknown session_id")
    if _check_runtime(s):
        return {"truncated": True, "text": "", "next_offset": offset}
    _touch(s)
    max_chars = _intish(max_chars, DEFAULT_MAX_CHARS)
    offset = _intish(offset, 0)
    if offset < 0:
        offset = 0
    key = "buf"
    if stream == "stdout":
        key = "stdout_buf"
    elif stream == "stderr":
        key = "stderr_buf"
    if _is_tmux_backend_session(s):
        _tmux_sync_output(s)
    res = _read_from_buffer(s, key, offset, max_chars)
    res["chunk_id"] = _next_chunk_id(s)
    res["stream"] = stream or "pty"
    res["timestamp"] = _now()
    return res


def pty_read_until(
    session_id,
    pattern: str,
    timeout_ms=10000,
    max_chars=DEFAULT_MAX_CHARS,
    regex=False,
    client_id=None,
):
    gc_sessions()
    s = sessions.get(session_id)
    if not s:
        raise RuntimeError("unknown session_id")
    _require_attached_client(s, client_id, "pty_read_until")
    _touch(s)
    pattern = _strish(pattern, "")
    if not pattern:
        raise RuntimeError("pattern is required")
    timeout_ms = _intish(timeout_ms, 10000)
    if READ_UNTIL_MAX_MS and timeout_ms > READ_UNTIL_MAX_MS:
        timeout_ms = READ_UNTIL_MAX_MS
    max_chars = _intish(max_chars, DEFAULT_MAX_CHARS)
    regex = bool(regex)
    deadline = _now() + (timeout_ms / 1000.0)
    collected = bytearray()
    rx = re.compile(pattern) if regex else None

    def _matches(text: str) -> bool:
        return rx.search(text) is not None if rx else (pattern in text)

    while True:
        step_timeout = int(min(300, max(0, (deadline - _now()) * 1000)))
        chunk = pty_read(
            session_id,
            max_chars=max_chars,
            timeout_ms=step_timeout,
            client_id=client_id,
        )
        if chunk:
            collected.extend(
                chunk.encode("utf-8", errors="replace")
                if isinstance(chunk, str)
                else chunk
            )
            if len(collected) > max_chars:
                collected = collected[-max_chars:]
            text = collected.decode("utf-8", errors="replace")
            if _matches(text):
                return {
                    "matched": True,
                    "text": text,
                    "chunk_id": _next_chunk_id(s),
                    "stream": "pty",
                }
        if _now() >= deadline:
            text = collected.decode("utf-8", errors="replace")
            return {
                "matched": False,
                "text": text,
                "chunk_id": _next_chunk_id(s),
                "stream": "pty",
            }


def pty_read_until_any(
    session_id,
    patterns,
    timeout_ms=10000,
    max_chars=DEFAULT_MAX_CHARS,
    regex=False,
    client_id=None,
):
    gc_sessions()
    s = sessions.get(session_id)
    if not s:
        raise RuntimeError("unknown session_id")
    _require_attached_client(s, client_id, "pty_read_until_any")
    _touch(s)
    patterns = patterns or []
    patterns = [str(p) for p in patterns if p is not None]
    if not patterns:
        raise RuntimeError("patterns is required")
    timeout_ms = _intish(timeout_ms, 10000)
    if READ_UNTIL_MAX_MS and timeout_ms > READ_UNTIL_MAX_MS:
        timeout_ms = READ_UNTIL_MAX_MS
    max_chars = _intish(max_chars, DEFAULT_MAX_CHARS)
    regex = bool(regex)
    deadline = _now() + (timeout_ms / 1000.0)
    collected = bytearray()
    rx_list = [re.compile(p) for p in patterns] if regex else None

    def _matches(text: str):
        if rx_list:
            for idx, rx in enumerate(rx_list):
                if rx.search(text) is not None:
                    return idx
            return None
        for idx, p in enumerate(patterns):
            if p in text:
                return idx
        return None

    while True:
        step_timeout = int(min(300, max(0, (deadline - _now()) * 1000)))
        chunk = pty_read(
            session_id,
            max_chars=max_chars,
            timeout_ms=step_timeout,
            client_id=client_id,
        )
        if chunk:
            collected.extend(
                chunk.encode("utf-8", errors="replace")
                if isinstance(chunk, str)
                else chunk
            )
            if len(collected) > max_chars:
                collected = collected[-max_chars:]
            text = collected.decode("utf-8", errors="replace")
            idx = _matches(text)
            if idx is not None:
                return {
                    "matched": True,
                    "pattern": patterns[idx],
                    "text": text,
                    "chunk_id": _next_chunk_id(s),
                    "stream": "pty",
                }
        if _now() >= deadline:
            text = collected.decode("utf-8", errors="replace")
            return {
                "matched": False,
                "pattern": None,
                "text": text,
                "chunk_id": _next_chunk_id(s),
                "stream": "pty",
            }


def pty_read_quiescent(
    session_id,
    quiescence_ms=None,
    timeout_ms=10000,
    max_chars=DEFAULT_MAX_CHARS,
    format=None,
    client_id=None,
):
    gc_sessions()
    s = sessions.get(session_id)
    if not s:
        raise RuntimeError("unknown session_id")
    if _check_runtime(s):
        return ""
    _require_attached_client(s, client_id, "pty_read_quiescent")
    _touch(s)
    max_chars = _intish(max_chars, DEFAULT_MAX_CHARS)
    timeout_ms = _intish(timeout_ms, 10000)
    quiescence_ms = _intish(quiescence_ms, QUIESCENCE_MS)
    if quiescence_ms < 0:
        quiescence_ms = QUIESCENCE_MS
    deadline = _now() + (timeout_ms / 1000.0)
    quiet_deadline = None
    collected = bytearray()
    while True:
        step_timeout = int(min(300, max(0, (deadline - _now()) * 1000)))
        chunk = pty_read(
            session_id,
            max_chars=max_chars,
            timeout_ms=step_timeout,
            client_id=client_id,
        )
        if chunk:
            collected.extend(
                chunk.encode("utf-8", errors="replace")
                if isinstance(chunk, str)
                else chunk
            )
            if len(collected) > max_chars:
                collected = collected[-max_chars:]
            quiet_deadline = _now() + (quiescence_ms / 1000.0)
        if quiet_deadline is not None and _now() >= quiet_deadline:
            break
        if _now() >= deadline:
            break
    text = collected.decode("utf-8", errors="replace")
    chunk_id = _next_chunk_id(s)
    return _format_read_response(s, text, "pty", format or "text", chunk_id)


def pty_prompt(
    session_id,
    data: str,
    patterns,
    timeout_ms=10000,
    max_chars=DEFAULT_MAX_CHARS,
    regex=False,
    client_id=None,
):
    s = sessions.get(session_id)
    if not s:
        raise RuntimeError("unknown session_id")
    _require_attached_client(s, client_id, "pty_prompt")
    pty_write(session_id, _strish(data, ""), client_id=client_id)
    return pty_read_until_any(
        session_id,
        patterns=patterns,
        regex=regex,
        timeout_ms=timeout_ms,
        max_chars=max_chars,
        client_id=client_id,
    )


def confirm_dangerous_command(
    command: str, justification: str = "", owner=None, scope=None
):
    matches = _is_dangerous_command(command)
    if not matches:
        return {"ok": True, "dangerous": False, "matches": []}
    justification = _strish(justification, "").strip()
    if not justification:
        return {
            "ok": False,
            "dangerous": True,
            "matches": matches,
            "error": "justification required",
        }
    item = _issue_dangerous_confirmation(
        command=command,
        justification=justification,
        owner=owner,
        scope=scope,
    )
    return {
        "ok": True,
        "dangerous": True,
        "matches": matches,
        "confirm_token": item["token"],
        "expires_at": item["expires_at"],
    }


def pty_write(session_id, data: str, client_id=None):
    gc_sessions()
    s = sessions.get(session_id)
    if not s:
        raise RuntimeError("unknown session_id")
    if _check_runtime(s):
        return
    _require_attached_client(s, client_id, "pty_write")
    if s.get("scope") == "read-only":
        raise RuntimeError("read-only session")
    _touch(s)
    data = _strish(data, "")
    payload = data.encode("utf-8", errors="replace")
    if not _check_input_rate(s, len(payload)):
        return
    if _is_tmux_backend_session(s):
        buffer_name = f"ptymcp-{session_id}"
        _tmux_run(["load-buffer", "-b", buffer_name, "-"], input_bytes=payload)
        _tmux_run(["paste-buffer", "-b", buffer_name, "-d", "-t", s["tmux_pane"]])
    else:
        os.write(s["master_fd"], payload)
    # 记录累计写入字节数，便于诊断交互输入
    s["bytes_written"] += len(payload)
    _log_session_event(s, "write", {"bytes": len(payload)})


def pty_resize(session_id, cols: int, rows: int, client_id=None):
    gc_sessions()
    s = sessions.get(session_id)
    if not s:
        raise RuntimeError("unknown session_id")
    _require_attached_client(s, client_id, "pty_resize")
    _touch(s)
    cols = _intish(cols, s["cols"])
    rows = _intish(rows, s["rows"])
    s["cols"], s["rows"] = cols, rows
    if _is_tmux_backend_session(s):
        if not s.get("tmux_workspace"):
            _tmux_run(
                [
                    "resize-window",
                    "-t",
                    f"{s['tmux_session']}:0",
                    "-x",
                    str(cols),
                    "-y",
                    str(rows),
                ],
                allow_failure=True,
            )
        _tmux_run(
            ["resize-pane", "-t", s["tmux_pane"], "-x", str(cols), "-y", str(rows)],
            allow_failure=True,
        )
    else:
        try:
            set_winsz(s["master_fd"], rows, cols)
        except Exception:
            pass
    _sync_recovery_manifest(s)


def pty_close(session_id, client_id=None):
    s = sessions.get(session_id)
    if s is None:
        return
    _require_attached_client(s, client_id, "pty_close")
    s = sessions.pop(session_id, None)
    if s:
        _cleanup_session(session_id, s)


def pty_close_all():
    for sid in list(sessions.keys()):
        try:
            pty_close(sid)
        except Exception:
            pass


def pty_status(session_id):
    gc_sessions()
    s = sessions.get(session_id)
    if not s:
        raise RuntimeError("unknown session_id")
    _check_runtime(s)
    _touch(s)
    proc = s["proc"]
    if _is_tmux_backend_session(s):
        _tmux_sync_output(s)
    workspace_state = _tmux_workspace_attached_client_state(
        s.get("tmux_workspace"), owner=s.get("owner"), exclude_session_id=None
    )
    code = proc.poll()
    return {
        "session_id": session_id,
        "backend": s.get("backend", "subprocess"),
        "pid": s["pid"],
        "pgid": s["pgid"],
        "running": code is None,
        "exit_code": code,
        "command": s["command"],
        "cwd": s["cwd"],
        "name": s.get("name"),
        "cols": s["cols"],
        "rows": s["rows"],
        "tag": s.get("tag"),
        "owner": s.get("owner"),
        "tmux_workspace": s.get("tmux_workspace"),
        "attached": bool(_normalize_client_id(s.get("attached_client_id"))),
        "attached_client_id": _normalize_client_id(s.get("attached_client_id")),
        "attached_at": s.get("attached_at"),
        "detached_at": s.get("detached_at"),
        "last_lease_at": s.get("last_lease_at"),
        "last_lease_event": s.get("last_lease_event"),
        "last_lease_from_client_id": s.get("last_lease_from_client_id"),
        "last_lease_to_client_id": s.get("last_lease_to_client_id"),
        "last_lease_reason": s.get("last_lease_reason"),
        "recovered": bool(s.get("recovered", False)),
        "recovered_at": s.get("recovered_at"),
        "workspace_attached": workspace_state["attached"],
        "workspace_attached_client_id": workspace_state["attached_client_id"],
        "workspace_attached_session_count": workspace_state["attached_session_count"],
        "separate_streams": s.get("separate_streams", False),
        "created_at": s["created_at"],
        "last_active": s["last_active"],
        "exit_reason": s.get("exit_reason"),
        "buffer_bytes": len(s["buf"]),
        "stdout_buffer_bytes": len(s["stdout_buf"]),
        "stderr_buffer_bytes": len(s["stderr_buf"]),
        "buf_start_offset": s.get("buf_start_offset", 0),
        "buf_total_offset": s.get("buf_total_offset", 0),
        "stdout_start_offset": s.get("stdout_start_offset", 0),
        "stdout_total_offset": s.get("stdout_total_offset", 0),
        "stderr_start_offset": s.get("stderr_start_offset", 0),
        "stderr_total_offset": s.get("stderr_total_offset", 0),
        "max_output_bytes": s.get("max_output_bytes"),
        "max_runtime_sec": s.get("max_runtime_sec"),
        "rate_limit_bps": s.get("rate_limit_bps"),
        "rate_dropped": s.get("rate_dropped", 0),
        "input_rate_bps": s.get("input_rate_bps"),
        "input_dropped": s.get("input_dropped", 0),
        "truncated": s.get("truncated", False),
        "bytes_read": s["bytes_read"],
        "bytes_written": s["bytes_written"],
    }


def pty_wait(session_id, timeout_ms=10000):
    gc_sessions()
    s = sessions.get(session_id)
    if not s:
        raise RuntimeError("unknown session_id")
    _check_runtime(s)
    _touch(s)
    timeout_ms = _intish(timeout_ms, 10000)
    proc = s["proc"]
    deadline = _now() + (timeout_ms / 1000.0)
    while _now() < deadline:
        if _is_tmux_backend_session(s):
            _tmux_sync_output(s)
        code = proc.poll()
        if code is not None:
            return {
                "running": False,
                "exit_code": code,
                "exit_reason": s.get("exit_reason"),
            }
        time.sleep(0.05)
    return {"running": True, "exit_code": None, "exit_reason": s.get("exit_reason")}


_SIGMAP = {
    "SIGINT": signal.SIGINT,
    "SIGTERM": signal.SIGTERM,
    "SIGKILL": signal.SIGKILL,
    "SIGHUP": signal.SIGHUP,
    "SIGQUIT": signal.SIGQUIT,
}


def pty_signal(session_id, sig: str, client_id=None):
    gc_sessions()
    s = sessions.get(session_id)
    if not s:
        raise RuntimeError("unknown session_id")
    if _check_runtime(s):
        return {"ok": False, "error": "session timed out"}
    _require_attached_client(s, client_id, "pty_signal")
    _touch(s)
    if _is_tmux_backend_session(s):
        return {"ok": False, "error": "pty_signal is unsupported for tmux backend"}
    sig = _strish(sig, "SIGTERM").upper()
    if not sig.startswith("SIG"):
        sig = "SIG" + sig
    if sig not in _SIGMAP:
        raise RuntimeError(f"unsupported signal: {sig}")
    proc = s["proc"]
    if proc.poll() is not None:
        return {"ok": True, "note": "already exited", "exit_code": proc.poll()}
    try:
        os.killpg(s["pgid"], _SIGMAP[sig])
        s["exit_reason"] = "signaled"
    except Exception as e:
        return {"ok": False, "error": str(e)}
    return {"ok": True}


def pty_list(tag=None, owner=None):
    gc_sessions()
    out = []
    for sid, s in sessions.items():
        if tag is not None and s.get("tag") != tag:
            continue
        if owner is not None and s.get("owner") != owner:
            continue
        if _is_tmux_backend_session(s):
            _tmux_sync_output(s)
        workspace_state = _tmux_workspace_attached_client_state(
            s.get("tmux_workspace"), owner=s.get("owner"), exclude_session_id=None
        )
        code = s["proc"].poll()
        out.append(
            {
                "session_id": sid,
                "backend": s.get("backend", "subprocess"),
                "pid": s["pid"],
                "pgid": s["pgid"],
                "running": code is None,
                "exit_code": code,
                "command": s["command"],
                "cwd": s["cwd"],
                "name": s.get("name"),
                "cols": s["cols"],
                "rows": s["rows"],
                "tag": s.get("tag"),
                "owner": s.get("owner"),
                "tmux_workspace": s.get("tmux_workspace"),
                "attached": bool(_normalize_client_id(s.get("attached_client_id"))),
                "attached_client_id": _normalize_client_id(s.get("attached_client_id")),
                "attached_at": s.get("attached_at"),
                "detached_at": s.get("detached_at"),
                "last_lease_at": s.get("last_lease_at"),
                "last_lease_event": s.get("last_lease_event"),
                "last_lease_from_client_id": s.get("last_lease_from_client_id"),
                "last_lease_to_client_id": s.get("last_lease_to_client_id"),
                "last_lease_reason": s.get("last_lease_reason"),
                "recovered": bool(s.get("recovered", False)),
                "recovered_at": s.get("recovered_at"),
                "workspace_attached": workspace_state["attached"],
                "workspace_attached_client_id": workspace_state["attached_client_id"],
                "workspace_attached_session_count": workspace_state[
                    "attached_session_count"
                ],
                "created_at": s["created_at"],
                "last_active": s["last_active"],
                "buffer_bytes": len(s["buf"]),
                "bytes_read": s["bytes_read"],
                "bytes_written": s["bytes_written"],
            }
        )
    out.sort(key=lambda x: x["last_active"])
    return out


def pty_resume(name, owner=None):
    sid, _s = _find_named_session(name, owner=owner)
    status = pty_status(sid)
    status["resumed_via"] = "name"
    return status


def pty_attach(session_id, client_id):
    gc_sessions()
    s = sessions.get(session_id)
    if not s:
        raise RuntimeError("unknown session_id")
    if _check_runtime(s):
        raise RuntimeError("session timed out")
    client_id = _normalize_client_id(client_id)
    if not client_id:
        raise RuntimeError("client_id is required")
    tmux_workspace = _normalize_tmux_workspace(s.get("tmux_workspace"))
    if _is_tmux_backend_session(s) and tmux_workspace:
        workspace_state = _tmux_workspace_attached_client_state(
            tmux_workspace,
            owner=s.get("owner"),
            exclude_session_id=session_id,
        )
        workspace_client_id = workspace_state["attached_client_id"]
        if workspace_client_id and workspace_client_id != client_id:
            raise RuntimeError("tmux workspace is attached to another client")
    current_client_id = _normalize_client_id(s.get("attached_client_id"))
    if current_client_id and current_client_id != client_id:
        raise RuntimeError("session is attached to another client")
    changed = current_client_id != client_id
    if changed:
        _apply_client_lease(
            s,
            client_id,
            "attach",
            {"from_client_id": current_client_id, "to_client_id": client_id},
        )
        _sync_recovery_manifest(s)
    _touch(s)
    status = pty_status(session_id)
    status["attach_changed"] = changed
    return status


def pty_detach(session_id, client_id):
    gc_sessions()
    s = sessions.get(session_id)
    if not s:
        raise RuntimeError("unknown session_id")
    if _check_runtime(s):
        raise RuntimeError("session timed out")
    client_id = _normalize_client_id(client_id)
    if not client_id:
        raise RuntimeError("client_id is required")
    current_client_id = _normalize_client_id(s.get("attached_client_id"))
    if current_client_id and current_client_id != client_id:
        raise RuntimeError("session is attached to another client")
    changed = current_client_id is not None
    if changed:
        _clear_client_lease(
            s,
            client_id,
            "detach",
            {"from_client_id": current_client_id, "to_client_id": None},
        )
        _sync_recovery_manifest(s)
    _touch(s)
    status = pty_status(session_id)
    status["detach_changed"] = changed
    return status


def pty_handoff(session_id, client_id, target_client_id, reason=None):
    gc_sessions()
    s = sessions.get(session_id)
    if not s:
        raise RuntimeError("unknown session_id")
    if _check_runtime(s):
        raise RuntimeError("session timed out")
    client_id = _normalize_client_id(client_id)
    if not client_id:
        raise RuntimeError("client_id is required")
    target_client_id = _normalize_client_id(target_client_id)
    if not target_client_id:
        raise RuntimeError("target_client_id is required")
    if target_client_id == client_id:
        raise RuntimeError("target_client_id must be different from client_id")
    reason = _normalize_lease_reason(reason)
    if not reason:
        raise RuntimeError("reason is required")

    current_client_id = _normalize_client_id(s.get("attached_client_id"))
    if not current_client_id:
        raise RuntimeError("session is not attached")
    if current_client_id != client_id:
        raise RuntimeError("session is attached to another client")

    affected = _workspace_client_sessions(s, client_id)
    affected_ids = [sid for sid, _other in affected]
    for _sid, other in affected:
        _apply_client_lease(
            other,
            target_client_id,
            "handoff",
            {
                "from_client_id": client_id,
                "to_client_id": target_client_id,
                "reason": reason,
                "affected_session_ids": affected_ids,
                "workspace_handoff": bool(
                    _is_tmux_backend_session(other)
                    and _normalize_tmux_workspace(other.get("tmux_workspace"))
                ),
            },
        )
        _sync_recovery_manifest(other)
        _touch(other)

    status = pty_status(session_id)
    status["handoff_changed"] = True
    status["affected_session_ids"] = affected_ids
    status["affected_session_count"] = len(affected_ids)
    return status


def pty_takeover(session_id, client_id, from_client_id, reason=None):
    gc_sessions()
    s = sessions.get(session_id)
    if not s:
        raise RuntimeError("unknown session_id")
    if _check_runtime(s):
        raise RuntimeError("session timed out")
    client_id = _normalize_client_id(client_id)
    if not client_id:
        raise RuntimeError("client_id is required")
    from_client_id = _normalize_client_id(from_client_id)
    if not from_client_id:
        raise RuntimeError("from_client_id is required")
    if client_id == from_client_id:
        raise RuntimeError("client_id must be different from from_client_id")
    reason = _normalize_lease_reason(reason)
    if not reason:
        raise RuntimeError("reason is required")

    current_client_id = _normalize_client_id(s.get("attached_client_id"))
    if not current_client_id:
        raise RuntimeError("session is not attached")
    if current_client_id != from_client_id:
        raise RuntimeError("session is not attached to expected client")

    workspace_conflicts = _workspace_takeover_sessions(s, client_id)
    for _sid, _other, other_client_id in workspace_conflicts:
        if other_client_id != from_client_id:
            raise RuntimeError("tmux workspace has conflicting attached clients")

    affected = _workspace_client_sessions(s, from_client_id)
    affected_ids = [sid for sid, _other in affected]
    for _sid, other in affected:
        _apply_client_lease(
            other,
            client_id,
            "takeover",
            {
                "from_client_id": from_client_id,
                "to_client_id": client_id,
                "reason": reason,
                "affected_session_ids": affected_ids,
                "workspace_takeover": bool(
                    _is_tmux_backend_session(other)
                    and _normalize_tmux_workspace(other.get("tmux_workspace"))
                ),
            },
        )
        _sync_recovery_manifest(other)
        _touch(other)

    status = pty_status(session_id)
    status["takeover_changed"] = True
    status["affected_session_ids"] = affected_ids
    status["affected_session_count"] = len(affected_ids)
    return status


def pty_recover(name, owner=None):
    gc_sessions(force=True)
    owner_value = _strish(owner, "").strip()
    if not owner_value:
        raise RuntimeError("owner is required for pty_recover")

    normalized_name = _normalize_session_name(name)
    if not normalized_name:
        raise RuntimeError("name is required")

    existing_sid = None
    try:
        existing_sid, _existing = _find_named_session(
            normalized_name, owner=owner_value
        )
    except RuntimeError as exc:
        if not str(exc).startswith("unknown session name:"):
            raise
    if existing_sid is not None:
        status = pty_status(existing_sid)
        status["already_live"] = True
        status["recovered_via"] = "recover"
        return status

    _path, manifest, matched_member = _load_recovery_manifest(
        normalized_name, owner_value
    )
    members = manifest.get("members")
    if not isinstance(members, list) or not members:
        raise RuntimeError("recovery manifest is invalid")

    tmux_session = _strish(manifest.get("tmux_session"), "").strip()
    if not tmux_session:
        raise RuntimeError("recovery manifest is missing tmux session")

    manifest_workspace = _normalize_tmux_workspace(manifest.get("tmux_workspace"))
    group_type = _strish(manifest.get("group_type"), "")
    if manifest_workspace and group_type != "tmux_workspace":
        raise RuntimeError("recovery manifest group type is invalid")
    if not manifest_workspace and group_type != "tmux_single":
        raise RuntimeError("recovery manifest group type is invalid")

    pane_map = _tmux_session_panes(tmux_session)
    recovered_at = _now()
    recovery_group_key = _strish(manifest.get("group_key"), "").strip() or None
    staged = []
    member_ids = []

    for member in members:
        if not isinstance(member, dict):
            raise RuntimeError("recovery manifest member is invalid")
        member_owner = _strish(member.get("owner"), "").strip()
        member_name = _normalize_session_name(member.get("name"))
        member_session_id = _strish(member.get("session_id"), "").strip()
        member_tmux_session = _strish(member.get("tmux_session"), "").strip()
        member_workspace = _normalize_tmux_workspace(member.get("tmux_workspace"))
        if not member_owner or not member_name or not member_session_id:
            raise RuntimeError("recovery manifest member is incomplete")
        if member_owner != owner_value:
            raise RuntimeError("recovery manifest owner mismatch")
        if member_tmux_session != tmux_session:
            raise RuntimeError("recovery manifest tmux session mismatch")
        if member_workspace != manifest_workspace:
            raise RuntimeError("recovery manifest workspace mismatch")
        if member_session_id in sessions:
            raise RuntimeError("recovery session_id collision")
        _tmux_validate_recovery_member(member, pane_map)
        member_ids.append(member_session_id)

    member_ids.sort()
    target_session_id = _strish(matched_member.get("session_id"), "").strip()
    if target_session_id not in member_ids:
        raise RuntimeError("recovery target is missing from manifest")

    for member in members:
        member_session_id = _strish(member.get("session_id"), "").strip()
        pane_info = pane_map[_strish(member.get("tmux_pane"), "").strip()]
        cols = _intish(member.get("cols"), pane_info.get("cols", 120))
        rows = _intish(member.get("rows"), pane_info.get("rows", 30))
        s = {
            "created_at": member.get("created_at") or recovered_at,
            "last_active": recovered_at,
            "backend": "tmux",
            "command": _strish(member.get("command"), ""),
            "cwd": _normalize_cwd(member.get("cwd")) or os.getcwd(),
            "cols": cols,
            "rows": rows,
            "buf": bytearray(),
            "stdout_buf": bytearray(),
            "stderr_buf": bytearray(),
            "buf_start_offset": 0,
            "buf_total_offset": 0,
            "stdout_start_offset": 0,
            "stdout_total_offset": 0,
            "stderr_start_offset": 0,
            "stderr_total_offset": 0,
            "closed": False,
            "cond": threading.Condition(),
            "max_output_bytes": _intish(member.get("max_output_bytes"), RING_MAX_BYTES),
            "max_runtime_sec": _intish(
                member.get("max_runtime_sec"), SPAWN_TIMEOUT_SEC
            ),
            "rate_limit_bps": _intish(member.get("rate_limit_bps"), OUTPUT_RATE_BPS),
            "rate_window_ts": 0.0,
            "rate_window_bytes": 0,
            "rate_notice_ts": 0.0,
            "rate_dropped": 0,
            "input_rate_bps": _intish(member.get("input_rate_bps"), INPUT_RATE_BPS),
            "input_window_ts": 0.0,
            "input_window_bytes": 0,
            "input_notice_ts": 0.0,
            "input_dropped": 0,
            "name": member.get("name"),
            "tag": _strish(member.get("tag"), None),
            "owner": owner_value,
            "scope": _strish(member.get("scope"), "read-write") or "read-write",
            "tmux_workspace": manifest_workspace,
            "attached_client_id": None,
            "attached_at": None,
            "detached_at": recovered_at,
            "last_lease_at": member.get("last_lease_at"),
            "last_lease_event": member.get("last_lease_event"),
            "last_lease_from_client_id": member.get("last_lease_from_client_id"),
            "last_lease_to_client_id": member.get("last_lease_to_client_id"),
            "last_lease_reason": member.get("last_lease_reason"),
            "recovered": True,
            "recovered_at": recovered_at,
            "read_timeout_ms": _intish(
                member.get("read_timeout_ms"), DEFAULT_READ_TIMEOUT_MS
            ),
            "chunk_seq": 0,
            "session_id": member_session_id,
            "env_override": member.get("env_override")
            if isinstance(member.get("env_override"), dict)
            else None,
            "truncated": False,
            "bytes_read": 0,
            "bytes_written": 0,
            "master_fd": -1,
            "proc": None,
            "pid": None,
            "pgid": None,
            "separate_streams": False,
            "reader_threads": [],
            "tmux_session": tmux_session,
            "tmux_pane": _strish(member.get("tmux_pane"), "").strip(),
            "tmux_output_path": _tmux_output_path(member_session_id),
            "tmux_output_offset": 0,
            "tmux_exit_code": None,
            "tmux_current_command": pane_info.get("current_command"),
        }
        if s["max_output_bytes"] <= 0:
            s["max_output_bytes"] = RING_MAX_BYTES
        if s["max_runtime_sec"] < 0:
            s["max_runtime_sec"] = 0
        if s["rate_limit_bps"] < 0:
            s["rate_limit_bps"] = 0
        if s["input_rate_bps"] < 0:
            s["input_rate_bps"] = 0
        if s["read_timeout_ms"] < 0:
            s["read_timeout_ms"] = DEFAULT_READ_TIMEOUT_MS
        s["proc"] = _TmuxProcShim(s)
        staged.append((member_session_id, s))

    rearmed = []
    try:
        for _sid, s in staged:
            _tmux_rearm_capture(s)
            rearmed.append(s)
    except Exception:
        for s in rearmed:
            try:
                _tmux_run(["pipe-pane", "-t", s["tmux_pane"]], allow_failure=True)
            except Exception:
                pass
            try:
                os.remove(_strish(s.get("tmux_output_path"), ""))
            except Exception:
                pass
        raise

    for sid, s in staged:
        sessions[sid] = s

    try:
        for _sid, s in staged:
            _log_session_event(
                s,
                "recover",
                {
                    "session_id": s["session_id"],
                    "name": s.get("name"),
                    "owner": s.get("owner"),
                    "recovery_group_key": recovery_group_key,
                    "recovery_group_session_ids": member_ids,
                },
            )
        _sync_recovery_manifest(sessions[target_session_id])
        status = pty_status(target_session_id)
        status["recovered_via"] = "recover"
        status["already_live"] = False
        status["recovery_group_key"] = recovery_group_key
        status["recovery_group_session_ids"] = member_ids
        status["recovery_group_session_count"] = len(member_ids)
        return status
    except Exception:
        for sid, s in staged:
            sessions.pop(sid, None)
            try:
                _tmux_run(["pipe-pane", "-t", s["tmux_pane"]], allow_failure=True)
            except Exception:
                pass
            try:
                os.remove(_strish(s.get("tmux_output_path"), ""))
            except Exception:
                pass
        raise


def pty_metrics():
    gc_sessions()
    total = len(sessions)
    running = 0
    bytes_read_total = 0
    bytes_written_total = 0
    truncated = 0
    rate_dropped_total = 0
    for s in sessions.values():
        if s["proc"].poll() is None:
            running += 1
        bytes_read_total += s.get("bytes_read", 0)
        bytes_written_total += s.get("bytes_written", 0)
        rate_dropped_total += s.get("rate_dropped", 0)
        if s.get("truncated"):
            truncated += 1
    return {
        "uptime_sec": int(_now() - _server_started),
        "session_count": total,
        "running_count": running,
        "max_sessions": MAX_SESSIONS,
        "bytes_read_total": bytes_read_total,
        "bytes_written_total": bytes_written_total,
        "rate_dropped_total": rate_dropped_total,
        "input_dropped_total": sum(
            s.get("input_dropped", 0) for s in sessions.values()
        ),
        "truncated_sessions": truncated,
    }


def pty_health():
    return {
        "ok": True,
        "uptime_sec": int(_now() - _server_started),
        "session_count": len(sessions),
        "max_sessions": MAX_SESSIONS,
    }


TOOLS = [
    {
        "name": "pty_spawn",
        "description": "Spawn a command in a pseudo-terminal. Returns session_id.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "command": {"type": "string"},
                "cwd": {"type": ["string", "null"]},
                "cols": {"type": ["integer", "string", "null"]},
                "rows": {"type": ["integer", "string", "null"]},
                "separate_streams": {"type": ["boolean", "null"]},
                "name": {"type": ["string", "null"]},
                "tag": {"type": ["string", "null"]},
                "owner": {"type": ["string", "null"]},
                "scope": {"type": ["string", "null"]},
                "max_output_bytes": {"type": ["integer", "string", "null"]},
                "spawn_timeout_sec": {"type": ["integer", "string", "null"]},
                "rate_limit_bps": {"type": ["integer", "string", "null"]},
                "input_rate_bps": {"type": ["integer", "string", "null"]},
                "read_timeout_ms": {"type": ["integer", "string", "null"]},
                "env": {"type": ["object", "null"]},
                "backend": {"type": ["string", "null"]},
                "tmux_workspace": {"type": ["string", "null"]},
                "dangerous_confirmed": {"type": ["boolean", "null"]},
                "dangerous_justification": {"type": ["string", "null"]},
                "dangerous_confirm_token": {"type": ["string", "null"]},
            },
            "required": ["command"],
            "additionalProperties": False,
        },
    },
    {
        "name": "pty_resume",
        "description": "Resolve a named session and return its current status.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "name": {"type": "string"},
                "owner": {"type": ["string", "null"]},
            },
            "required": ["name"],
            "additionalProperties": False,
        },
    },
    {
        "name": "pty_recover",
        "description": "Explicitly recover a persisted tmux-backed named session.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "name": {"type": "string"},
                "owner": {"type": ["string", "null"]},
            },
            "required": ["name", "owner"],
            "additionalProperties": False,
        },
    },
    {
        "name": "pty_attach",
        "description": "Attach a logical client lease to an existing session.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "session_id": {"type": "string"},
                "owner": {"type": ["string", "null"]},
                "client_id": {"type": "string"},
            },
            "required": ["session_id", "client_id"],
            "additionalProperties": False,
        },
    },
    {
        "name": "pty_detach",
        "description": "Release a logical client lease without closing the session.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "session_id": {"type": "string"},
                "owner": {"type": ["string", "null"]},
                "client_id": {"type": "string"},
            },
            "required": ["session_id", "client_id"],
            "additionalProperties": False,
        },
    },
    {
        "name": "pty_handoff",
        "description": "Explicitly hand off a logical client lease to another client.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "session_id": {"type": "string"},
                "owner": {"type": ["string", "null"]},
                "client_id": {"type": "string"},
                "target_client_id": {"type": "string"},
                "reason": {"type": "string"},
            },
            "required": ["session_id", "client_id", "target_client_id", "reason"],
            "additionalProperties": False,
        },
    },
    {
        "name": "pty_takeover",
        "description": "Explicitly take over a logical client lease from another client.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "session_id": {"type": "string"},
                "owner": {"type": ["string", "null"]},
                "client_id": {"type": "string"},
                "from_client_id": {"type": "string"},
                "reason": {"type": "string"},
            },
            "required": ["session_id", "client_id", "from_client_id", "reason"],
            "additionalProperties": False,
        },
    },
    {
        "name": "pty_read",
        "description": "Read and consume output from the PTY session ring buffer.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "session_id": {"type": "string"},
                "owner": {"type": ["string", "null"]},
                "client_id": {"type": ["string", "null"]},
                "max_chars": {"type": ["integer", "string", "null"]},
                "timeout_ms": {"type": ["integer", "string", "null"]},
                "format": {"type": ["string", "null"]},
            },
            "required": ["session_id"],
            "additionalProperties": False,
        },
    },
    {
        "name": "pty_read_stdout",
        "description": "Read and consume stdout buffer from the PTY session.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "session_id": {"type": "string"},
                "owner": {"type": ["string", "null"]},
                "client_id": {"type": ["string", "null"]},
                "max_chars": {"type": ["integer", "string", "null"]},
                "timeout_ms": {"type": ["integer", "string", "null"]},
                "format": {"type": ["string", "null"]},
            },
            "required": ["session_id"],
            "additionalProperties": False,
        },
    },
    {
        "name": "pty_read_stderr",
        "description": "Read and consume stderr buffer from the PTY session.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "session_id": {"type": "string"},
                "owner": {"type": ["string", "null"]},
                "client_id": {"type": ["string", "null"]},
                "max_chars": {"type": ["integer", "string", "null"]},
                "timeout_ms": {"type": ["integer", "string", "null"]},
                "format": {"type": ["string", "null"]},
            },
            "required": ["session_id"],
            "additionalProperties": False,
        },
    },
    {
        "name": "pty_read_at",
        "description": "Read output from offset without consuming.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "session_id": {"type": "string"},
                "owner": {"type": ["string", "null"]},
                "offset": {"type": ["integer", "string", "null"]},
                "max_chars": {"type": ["integer", "string", "null"]},
                "stream": {"type": ["string", "null"]},
            },
            "required": ["session_id"],
            "additionalProperties": False,
        },
    },
    {
        "name": "pty_read_until",
        "description": "Read until substring/regex is matched or timeout.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "session_id": {"type": "string"},
                "owner": {"type": ["string", "null"]},
                "pattern": {"type": "string"},
                "client_id": {"type": ["string", "null"]},
                "regex": {"type": ["boolean", "null"]},
                "timeout_ms": {"type": ["integer", "string", "null"]},
                "max_chars": {"type": ["integer", "string", "null"]},
            },
            "required": ["session_id", "pattern"],
            "additionalProperties": False,
        },
    },
    {
        "name": "pty_read_until_any",
        "description": "Read until any pattern matches or timeout.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "session_id": {"type": "string"},
                "owner": {"type": ["string", "null"]},
                "patterns": {"type": "array", "items": {"type": "string"}},
                "client_id": {"type": ["string", "null"]},
                "regex": {"type": ["boolean", "null"]},
                "timeout_ms": {"type": ["integer", "string", "null"]},
                "max_chars": {"type": ["integer", "string", "null"]},
            },
            "required": ["session_id", "patterns"],
            "additionalProperties": False,
        },
    },
    {
        "name": "pty_read_quiescent",
        "description": "Read until output is silent for quiescence_ms or timeout.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "session_id": {"type": "string"},
                "owner": {"type": ["string", "null"]},
                "client_id": {"type": ["string", "null"]},
                "quiescence_ms": {"type": ["integer", "string", "null"]},
                "timeout_ms": {"type": ["integer", "string", "null"]},
                "max_chars": {"type": ["integer", "string", "null"]},
                "format": {"type": ["string", "null"]},
            },
            "required": ["session_id"],
            "additionalProperties": False,
        },
    },
    {
        "name": "pty_prompt",
        "description": "Write input then read until any pattern matches.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "session_id": {"type": "string"},
                "owner": {"type": ["string", "null"]},
                "data": {"type": "string"},
                "patterns": {"type": "array", "items": {"type": "string"}},
                "client_id": {"type": ["string", "null"]},
                "regex": {"type": ["boolean", "null"]},
                "timeout_ms": {"type": ["integer", "string", "null"]},
                "max_chars": {"type": ["integer", "string", "null"]},
            },
            "required": ["session_id", "data", "patterns"],
            "additionalProperties": False,
        },
    },
    {
        "name": "confirm_dangerous_command",
        "description": "Confirm a dangerous command with justification.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "command": {"type": "string"},
                "justification": {"type": ["string", "null"]},
                "owner": {"type": ["string", "null"]},
                "scope": {"type": ["string", "null"]},
            },
            "required": ["command"],
            "additionalProperties": False,
        },
    },
    {
        "name": "pty_write",
        "description": "Write text to the PTY session (include \\n for Enter).",
        "inputSchema": {
            "type": "object",
            "properties": {
                "session_id": {"type": "string"},
                "owner": {"type": ["string", "null"]},
                "data": {"type": "string"},
                "client_id": {"type": ["string", "null"]},
            },
            "required": ["session_id", "data"],
            "additionalProperties": False,
        },
    },
    {
        "name": "pty_resize",
        "description": "Resize the PTY session.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "session_id": {"type": "string"},
                "owner": {"type": ["string", "null"]},
                "cols": {"type": ["integer", "string"]},
                "rows": {"type": ["integer", "string"]},
                "client_id": {"type": ["string", "null"]},
            },
            "required": ["session_id", "cols", "rows"],
            "additionalProperties": False,
        },
    },
    {
        "name": "pty_close",
        "description": "Close the PTY session.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "session_id": {"type": "string"},
                "owner": {"type": ["string", "null"]},
                "client_id": {"type": ["string", "null"]},
            },
            "required": ["session_id"],
            "additionalProperties": False,
        },
    },
    {
        "name": "pty_close_all",
        "description": "Close all PTY sessions.",
        "inputSchema": {
            "type": "object",
            "properties": {},
            "additionalProperties": False,
        },
    },
    {
        "name": "pty_status",
        "description": "Get status of the session process and metadata.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "session_id": {"type": "string"},
                "owner": {"type": ["string", "null"]},
            },
            "required": ["session_id"],
            "additionalProperties": False,
        },
    },
    {
        "name": "pty_wait",
        "description": "Wait for process exit up to timeout_ms.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "session_id": {"type": "string"},
                "owner": {"type": ["string", "null"]},
                "timeout_ms": {"type": ["integer", "string", "null"]},
            },
            "required": ["session_id"],
            "additionalProperties": False,
        },
    },
    {
        "name": "pty_signal",
        "description": "Send a signal to the session process group.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "session_id": {"type": "string"},
                "owner": {"type": ["string", "null"]},
                "sig": {"type": "string"},
                "client_id": {"type": ["string", "null"]},
            },
            "required": ["session_id", "sig"],
            "additionalProperties": False,
        },
    },
    {
        "name": "pty_list",
        "description": "List all sessions.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "tag": {"type": ["string", "null"]},
                "owner": {"type": ["string", "null"]},
            },
            "additionalProperties": False,
        },
    },
    {
        "name": "set_default_cwd",
        "description": "Set default working directory for future spawns.",
        "inputSchema": {
            "type": "object",
            "properties": {"cwd": {"type": ["string", "null"]}},
            "additionalProperties": False,
        },
    },
    {
        "name": "get_default_cwd",
        "description": "Get default working directory for future spawns.",
        "inputSchema": {
            "type": "object",
            "properties": {},
            "additionalProperties": False,
        },
    },
    {
        "name": "set_limits",
        "description": "Update runtime limits.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "max_output_bytes": {"type": ["integer", "string", "null"]},
                "spawn_timeout_sec": {"type": ["integer", "string", "null"]},
                "idle_timeout_sec": {"type": ["integer", "string", "null"]},
                "read_timeout_ms": {"type": ["integer", "string", "null"]},
                "rate_limit_bps": {"type": ["integer", "string", "null"]},
                "input_rate_bps": {"type": ["integer", "string", "null"]},
            },
            "additionalProperties": False,
        },
    },
    {
        "name": "get_limits",
        "description": "Get runtime limits.",
        "inputSchema": {
            "type": "object",
            "properties": {},
            "additionalProperties": False,
        },
    },
    {
        "name": "pty_metrics",
        "description": "Get aggregate metrics.",
        "inputSchema": {
            "type": "object",
            "properties": {},
            "additionalProperties": False,
        },
    },
    {
        "name": "pty_health",
        "description": "Get health status.",
        "inputSchema": {
            "type": "object",
            "properties": {},
            "additionalProperties": False,
        },
    },
]


def _tool_visible(name: str) -> bool:
    if name in (
        "pty_close_all",
        "set_limits",
        "set_default_cwd",
        "get_limits",
        "get_default_cwd",
        "pty_metrics",
        "pty_health",
    ):
        return ENABLE_ADMIN_TOOLS
    if name in ("pty_signal",):
        return ENABLE_CONTROL_TOOLS
    return True


TOOLS = [tool for tool in TOOLS if _tool_visible(tool["name"])]


def handle_tools_call(name, args):
    args = args or {}
    session = _get_session_for_args(args)
    if _strish(args.get("session_id"), "").strip() and session is None:
        raise RuntimeError("unknown session_id")
    _authorize_tool_call(name, args, session=session)
    if name == "pty_spawn":
        cols = _intish(args.get("cols"), 120)
        rows = _intish(args.get("rows"), 30)
        sid = pty_spawn(
            args.get("command"),
            cwd=args.get("cwd"),
            cols=cols,
            rows=rows,
            separate_streams=args.get("separate_streams", False),
            name=args.get("name"),
            tag=args.get("tag"),
            owner=args.get("owner"),
            scope=args.get("scope"),
            max_output_bytes=args.get("max_output_bytes"),
            spawn_timeout_sec=args.get("spawn_timeout_sec"),
            rate_limit_bps=args.get("rate_limit_bps"),
            input_rate_bps=args.get("input_rate_bps"),
            read_timeout_ms=args.get("read_timeout_ms"),
            env_override=args.get("env"),
            backend=args.get("backend"),
            tmux_workspace=args.get("tmux_workspace"),
            dangerous_confirmed=args.get("dangerous_confirmed", False),
            dangerous_justification=args.get("dangerous_justification"),
            dangerous_confirm_token=args.get("dangerous_confirm_token"),
        )
        if _SPAWN_META:
            s = sessions.get(sid)
            meta = {
                "session_id": sid,
                "backend": s.get("backend") if s else None,
                "pid": s["pid"] if s else None,
                "pgid": s["pgid"] if s else None,
                "cwd": s["cwd"] if s else None,
                "cols": s["cols"] if s else None,
                "rows": s["rows"] if s else None,
                "name": s.get("name") if s else None,
                "tag": s.get("tag") if s else None,
                "owner": s.get("owner") if s else None,
                "tmux_workspace": s.get("tmux_workspace") if s else None,
                "attached": bool(_normalize_client_id(s.get("attached_client_id")))
                if s
                else False,
                "attached_client_id": _normalize_client_id(s.get("attached_client_id"))
                if s
                else None,
                "attached_at": s.get("attached_at") if s else None,
                "detached_at": s.get("detached_at") if s else None,
                "last_lease_at": s.get("last_lease_at") if s else None,
                "last_lease_event": s.get("last_lease_event") if s else None,
                "last_lease_from_client_id": s.get("last_lease_from_client_id")
                if s
                else None,
                "last_lease_to_client_id": s.get("last_lease_to_client_id")
                if s
                else None,
                "last_lease_reason": s.get("last_lease_reason") if s else None,
                "recovered": bool(s.get("recovered", False)) if s else False,
                "recovered_at": s.get("recovered_at") if s else None,
                "separate_streams": s.get("separate_streams") if s else None,
                "max_output_bytes": s.get("max_output_bytes") if s else None,
                "max_runtime_sec": s.get("max_runtime_sec") if s else None,
                "rate_limit_bps": s.get("rate_limit_bps") if s else None,
                "created_at": s["created_at"] if s else None,
            }
            text = json.dumps(meta, ensure_ascii=False)
        else:
            text = sid
        return {"content": [{"type": "text", "text": text}]}
    if name == "pty_resume":
        return {
            "content": [
                {
                    "type": "text",
                    "text": json.dumps(
                        pty_resume(
                            _strish(args.get("name"), ""),
                            owner=_strish(args.get("owner"), ""),
                        ),
                        ensure_ascii=False,
                    ),
                }
            ]
        }
    if name == "pty_recover":
        return {
            "content": [
                {
                    "type": "text",
                    "text": json.dumps(
                        pty_recover(
                            _strish(args.get("name"), ""),
                            owner=_strish(args.get("owner"), ""),
                        ),
                        ensure_ascii=False,
                    ),
                }
            ]
        }
    if name == "pty_attach":
        return {
            "content": [
                {
                    "type": "text",
                    "text": json.dumps(
                        pty_attach(
                            args.get("session_id"),
                            _strish(args.get("client_id"), ""),
                        ),
                        ensure_ascii=False,
                    ),
                }
            ]
        }
    if name == "pty_detach":
        return {
            "content": [
                {
                    "type": "text",
                    "text": json.dumps(
                        pty_detach(
                            args.get("session_id"),
                            _strish(args.get("client_id"), ""),
                        ),
                        ensure_ascii=False,
                    ),
                }
            ]
        }
    if name == "pty_handoff":
        return {
            "content": [
                {
                    "type": "text",
                    "text": json.dumps(
                        pty_handoff(
                            args.get("session_id"),
                            _strish(args.get("client_id"), ""),
                            _strish(args.get("target_client_id"), ""),
                            _strish(args.get("reason"), ""),
                        ),
                        ensure_ascii=False,
                    ),
                }
            ]
        }
    if name == "pty_takeover":
        return {
            "content": [
                {
                    "type": "text",
                    "text": json.dumps(
                        pty_takeover(
                            args.get("session_id"),
                            _strish(args.get("client_id"), ""),
                            _strish(args.get("from_client_id"), ""),
                            _strish(args.get("reason"), ""),
                        ),
                        ensure_ascii=False,
                    ),
                }
            ]
        }
    if name == "pty_read":
        return {
            "content": [
                {
                    "type": "text",
                    "text": pty_read(
                        args.get("session_id"),
                        max_chars=args.get("max_chars", DEFAULT_MAX_CHARS),
                        timeout_ms=args.get("timeout_ms"),
                        format=args.get("format"),
                        client_id=args.get("client_id"),
                    ),
                }
            ]
        }
    if name == "pty_read_stdout":
        return {
            "content": [
                {
                    "type": "text",
                    "text": pty_read_stdout(
                        args.get("session_id"),
                        max_chars=args.get("max_chars", DEFAULT_MAX_CHARS),
                        timeout_ms=args.get("timeout_ms"),
                        format=args.get("format"),
                        client_id=args.get("client_id"),
                    ),
                }
            ]
        }
    if name == "pty_read_stderr":
        return {
            "content": [
                {
                    "type": "text",
                    "text": pty_read_stderr(
                        args.get("session_id"),
                        max_chars=args.get("max_chars", DEFAULT_MAX_CHARS),
                        timeout_ms=args.get("timeout_ms"),
                        format=args.get("format"),
                        client_id=args.get("client_id"),
                    ),
                }
            ]
        }
    if name == "pty_read_at":
        return {
            "content": [
                {
                    "type": "text",
                    "text": json.dumps(
                        pty_read_at(
                            args.get("session_id"),
                            offset=args.get("offset", 0),
                            max_chars=args.get("max_chars", DEFAULT_MAX_CHARS),
                            stream=args.get("stream"),
                        ),
                        ensure_ascii=False,
                    ),
                }
            ]
        }
    if name == "pty_read_until":
        pattern = _strish(args.get("pattern"), "")
        return {
            "content": [
                {
                    "type": "text",
                    "text": json.dumps(
                        pty_read_until(
                            args.get("session_id"),
                            pattern=pattern,
                            regex=args.get("regex", False),
                            timeout_ms=args.get("timeout_ms", 10000),
                            max_chars=args.get("max_chars", DEFAULT_MAX_CHARS),
                            client_id=args.get("client_id"),
                        ),
                        ensure_ascii=False,
                    ),
                }
            ]
        }
    if name == "pty_read_until_any":
        return {
            "content": [
                {
                    "type": "text",
                    "text": json.dumps(
                        pty_read_until_any(
                            args.get("session_id"),
                            patterns=args.get("patterns"),
                            regex=args.get("regex", False),
                            timeout_ms=args.get("timeout_ms", 10000),
                            max_chars=args.get("max_chars", DEFAULT_MAX_CHARS),
                            client_id=args.get("client_id"),
                        ),
                        ensure_ascii=False,
                    ),
                }
            ]
        }
    if name == "pty_read_quiescent":
        return {
            "content": [
                {
                    "type": "text",
                    "text": pty_read_quiescent(
                        args.get("session_id"),
                        quiescence_ms=args.get("quiescence_ms"),
                        timeout_ms=args.get("timeout_ms", 10000),
                        max_chars=args.get("max_chars", DEFAULT_MAX_CHARS),
                        format=args.get("format"),
                        client_id=args.get("client_id"),
                    ),
                }
            ]
        }
    if name == "pty_prompt":
        return {
            "content": [
                {
                    "type": "text",
                    "text": json.dumps(
                        pty_prompt(
                            args.get("session_id"),
                            _strish(args.get("data"), ""),
                            patterns=args.get("patterns"),
                            regex=args.get("regex", False),
                            timeout_ms=args.get("timeout_ms", 10000),
                            max_chars=args.get("max_chars", DEFAULT_MAX_CHARS),
                            client_id=args.get("client_id"),
                        ),
                        ensure_ascii=False,
                    ),
                }
            ]
        }
    if name == "confirm_dangerous_command":
        return {
            "content": [
                {
                    "type": "text",
                    "text": json.dumps(
                        confirm_dangerous_command(
                            _strish(args.get("command"), ""),
                            _strish(args.get("justification"), ""),
                            owner=_strish(args.get("owner"), ""),
                            scope=_strish(args.get("scope"), ""),
                        ),
                        ensure_ascii=False,
                    ),
                }
            ]
        }
    if name == "pty_write":
        pty_write(
            args.get("session_id"),
            _strish(args.get("data"), ""),
            client_id=args.get("client_id"),
        )
        return {"content": [{"type": "text", "text": "ok"}]}
    if name == "pty_resize":
        cols = _intish(args.get("cols"), 120)
        rows = _intish(args.get("rows"), 30)
        pty_resize(
            args.get("session_id"),
            cols,
            rows,
            client_id=args.get("client_id"),
        )
        return {"content": [{"type": "text", "text": "ok"}]}
    if name == "pty_close":
        pty_close(args.get("session_id"), client_id=args.get("client_id"))
        return {"content": [{"type": "text", "text": "ok"}]}
    if name == "pty_close_all":
        pty_close_all()
        return {"content": [{"type": "text", "text": "ok"}]}
    if name == "pty_status":
        return {
            "content": [
                {
                    "type": "text",
                    "text": json.dumps(
                        pty_status(args.get("session_id")), ensure_ascii=False
                    ),
                }
            ]
        }
    if name == "pty_wait":
        return {
            "content": [
                {
                    "type": "text",
                    "text": json.dumps(
                        pty_wait(args.get("session_id"), args.get("timeout_ms", 10000)),
                        ensure_ascii=False,
                    ),
                }
            ]
        }
    if name == "pty_signal":
        return {
            "content": [
                {
                    "type": "text",
                    "text": json.dumps(
                        pty_signal(
                            args.get("session_id"),
                            _strish(args.get("sig"), "SIGTERM"),
                            client_id=args.get("client_id"),
                        ),
                        ensure_ascii=False,
                    ),
                }
            ]
        }
    if name == "pty_list":
        return {
            "content": [
                {
                    "type": "text",
                    "text": json.dumps(
                        pty_list(tag=args.get("tag"), owner=args.get("owner")),
                        ensure_ascii=False,
                    ),
                }
            ]
        }
    if name == "set_default_cwd":
        return {
            "content": [
                {
                    "type": "text",
                    "text": json.dumps(
                        set_default_cwd(args.get("cwd")), ensure_ascii=False
                    ),
                }
            ]
        }
    if name == "get_default_cwd":
        return {
            "content": [
                {
                    "type": "text",
                    "text": json.dumps(get_default_cwd(), ensure_ascii=False),
                }
            ]
        }
    if name == "set_limits":
        return {
            "content": [
                {
                    "type": "text",
                    "text": json.dumps(
                        set_limits(
                            max_output_bytes=args.get("max_output_bytes"),
                            spawn_timeout_sec=args.get("spawn_timeout_sec"),
                            idle_timeout_sec=args.get("idle_timeout_sec"),
                            read_timeout_ms=args.get("read_timeout_ms"),
                            rate_limit_bps=args.get("rate_limit_bps"),
                            input_rate_bps=args.get("input_rate_bps"),
                        ),
                        ensure_ascii=False,
                    ),
                }
            ]
        }
    if name == "get_limits":
        return {
            "content": [
                {"type": "text", "text": json.dumps(get_limits(), ensure_ascii=False)}
            ]
        }
    if name == "pty_metrics":
        return {
            "content": [
                {"type": "text", "text": json.dumps(pty_metrics(), ensure_ascii=False)}
            ]
        }
    if name == "pty_health":
        return {
            "content": [
                {"type": "text", "text": json.dumps(pty_health(), ensure_ascii=False)}
            ]
        }
    raise RuntimeError("unknown tool")


def shutdown():
    try:
        pty_close_all()
    except Exception:
        pass


def _sig_handler(signum, _frame):
    shutdown()
    sys.exit(0)


def main():
    _log("pty-runner(py) boot")
    signal.signal(signal.SIGTERM, _sig_handler)
    signal.signal(signal.SIGINT, _sig_handler)
    try:
        for msg in read_messages():
            try:
                method, id_, params = (
                    msg.get("method"),
                    msg.get("id"),
                    msg.get("params") or {},
                )
                if method in ("initialized",):
                    continue
                if method == "initialize":
                    ok(
                        id_,
                        {
                            "protocolVersion": params.get("protocolVersion")
                            or "2024-11-05",
                            "capabilities": {"tools": {}},
                            "serverInfo": {
                                "name": "pty-mcp",
                                "version": _server_version(),
                            },
                        },
                    )
                    continue
                if method == "ping":
                    ok(id_, {})
                    continue
                if method == "tools/list":
                    ok(id_, {"tools": TOOLS})
                    continue
                if method == "tools/call":
                    tool_name, args = params.get("name"), params.get("arguments") or {}
                    if not tool_name:
                        err(id_, -32602, "Missing tool name")
                        continue
                    ok(id_, handle_tools_call(tool_name, args))
                    continue
                if id_ is not None:
                    err(id_, -32601, f"Method not found: {method}")
            except Exception as e:
                if msg.get("id") is not None:
                    err(
                        msg["id"],
                        -32000,
                        str(e),
                        data={"trace": traceback.format_exc()},
                    )
                else:
                    _log_limited(
                        "notif_err",
                        "notification error:",
                        traceback.format_exc(),
                        interval_sec=2.0,
                    )
    finally:
        shutdown()


if __name__ == "__main__":
    main()
