#!/usr/bin/env python3 # retoor import argparse import asyncio import base64 import codecs import collections import contextlib import difflib import getpass import glob import hashlib import importlib.util import hmac import html import http.client import http.server import io import itertools import json import math import operator import os import queue import random import re import secrets import shutil import shlex import signal import socket import sqlite3 import ssl import struct import subprocess import sys import tempfile import textwrap import threading import time import urllib.error import urllib.parse import urllib.request import uuid import webbrowser import weakref import zipfile from datetime import datetime, timedelta, timezone try: import readline except ImportError: readline = None try: import fcntl except ImportError: fcntl = None try: import pty import select import termios import tty except ImportError: pty = select = termios = tty = None # Python 3.6 compatibility notes: # - subprocess 'text' and 'capture_output' kwargs were added in 3.7, so this # module only uses 'universal_newlines' plus explicit PIPEs (works on 3.6+). # - datetime.fromisoformat was added in 3.7, so all parsing goes through # _fromisoformat below (native when available, strptime fallback on 3.6). def _fromisoformat(text): """datetime.fromisoformat backport for Python 3.6 (handles Z and +HH:MM).""" native = getattr(datetime, "fromisoformat", None) if native is not None: raw = str(text or "").strip() if raw[-1:] in ("Z", "z"): raw = raw[:-1] + "+00:00" return native(raw) raw = str(text or "").strip() if not raw: raise ValueError("empty datetime") if raw[-1:] in ("Z", "z"): raw = raw[:-1] + "+00:00" tzinfo = None core = raw match = re.match(r"^(\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?)?)([+-]\d{2}:?\d{2}(?::\d{2})?|[+-]\d{4}|[+-]\d{2})$", raw) if match is not None and match.group(2): core, tzpart = match.group(1), match.group(2) sign = 1 if tzpart[0] == "+" else -1 digits = tzpart[1:].replace(":", "") hours = int(digits[:2]) minutes = int(digits[2:4]) if len(digits) >= 4 else 0 tzinfo = timezone(sign * timedelta(hours=hours, minutes=minutes)) for fmt in ("%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"): try: parsed = datetime.strptime(core, fmt) except ValueError: continue return parsed.replace(tzinfo=tzinfo) raise ValueError("invalid datetime, use ISO like 2026-10-08T09:00") VERSION = "1.44.1" WORKER_STEPS = 12 CREATE_SKILL_STEPS = 40 FORK_MAX_DEPTH = 2 AGENTS_MAX_PARALLEL = 8 AGENTS_MAX_RECORDS = 64 SCHEDULER_MAX_FIRES = 4 SCHEDULER_LEASE_MARGIN = 30 AGENTS = {} AGENTS_LOCK = threading.Lock() AGENTS_NEXT = [1] MUSE_LABEL = "muse" MUSE_CHAT_URL = "muse://exec" MUSE_MODELS_URL = "muse://models" GROK_LABEL = "grok" GROK_CHAT_URL = "grok://exec" GROK_MODELS_URL = "grok://models" CLAUDE_LABEL = "claude" CLAUDE_CHAT_URL = "claude://exec" CLAUDE_MODELS_URL = "claude://models" CODEX_LABEL = "codex" CODEX_CHAT_URL = "codex://exec" CODEX_MODELS_URL = "codex://models" GEMINI_LABEL = "gemini" GEMINI_CHAT_URL = "gemini://exec" GEMINI_MODELS_URL = "gemini://models" OLLAMA_LABEL = "ollama" OLLAMA_BASE = "http://localhost:11434/v1" OLLAMA_LIST_TTL = 600 CLI_CHAT_URLS = (MUSE_CHAT_URL, GROK_CHAT_URL, CLAUDE_CHAT_URL, CODEX_CHAT_URL, GEMINI_CHAT_URL) MUSE_MAX_STEPS = 1 MUSE_SESSIONS_FILE = "muse_sessions.json" MUSE_LOCKS_DIR = "muse_locks" MUSE_WORKER_TTL = 86400 MUSE_REAP_GRACE = 30 OPENROUTER_LABEL = "openrouter" OPENROUTER_BASE = "https://openrouter.ai/api/v1" OPENROUTER_MODEL = "deepseek/deepseek-v4.1-flash" OPENCODE_LABEL = "opencode" OPENCODE_CHAT_URL = "opencode://run" OPENCODE_MODELS_URL = "opencode://models" OPENCODE_AGENT = "build" OPENCODE_PARSE_MAX_SCANS = 200 DEVPLACE_LABEL = "devplace" DEVPLACE_BASE = "https://devplace.net/openai/v1" DEVPLACE_FREE_ROUTES = ("free", "devii", "molodetz") PREFERRED_MODEL = "molodetz" ZEN_LABEL = "zen" ZEN_BASE = "https://opencode.ai/zen/v1" ZEN_FREE_MODELS = ("mimo-v2.6-flash-free", "nemotron-3.5-lightning-free", "muse-spark-1.3-contributor-free", "muse-spark-1.2-contributor-free", "jev-1.13-free", "step-5-preview-free", "space-bunny-free", "longcat-2.5-preview-free", "ling-3.1-flash-free") POLLINATIONS_LABEL = "pollinations" POLLINATIONS_BASE = "https://text.pollinations.ai/openai/v1" POLLINATIONS_MODELS = ("openai-fast",) POLLINATIONS_LIST_URL = "https://text.pollinations.ai/models" PROVIDER_LABELS = (MUSE_LABEL, GROK_LABEL, CLAUDE_LABEL, CODEX_LABEL, GEMINI_LABEL, OPENCODE_LABEL, OLLAMA_LABEL, OPENROUTER_LABEL, POLLINATIONS_LABEL, ZEN_LABEL, DEVPLACE_LABEL) MENTION_AUTO = "auto" MENTION_ROUTES = {"molodetz": (DEVPLACE_LABEL, PREFERRED_MODEL)} DIR_DEFAULTS_CAP = 512 OPENCODE_USER_AGENT = "opencode/1.18.29 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.15" OPENCODE_CLIENT_NAME = "cli" MODEL_HEALTH_FILE = "model_health.json" MODEL_NEUTRAL_REWARD = 0.5 MODEL_WEIGHT_PRIOR = 2.0 MODEL_SPEED_REF_TPS = 25.0 MODEL_LATENCY_REF_MS = 8000.0 MODEL_CIRCUIT_THRESHOLD = 3 MODEL_CIRCUIT_COOLDOWN = 300.0 RETRY_BACKOFF = (2, 5, 10, 20, 30, 60) MODEL_LIST_TIMEOUT = 8 OPENROUTER_FREE_CACHE_TTL = 3600 RSEARCH_BASE = "https://rsearch.app.molodetz.nl" FETCH_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS") CURL_HINT = "hint: use the web_fetch tool for HTTP(S) (methods, headers, bodies, status) instead of curl/wget via shell" def curl_hint(command): if re.search(r"\bcurl\b|\bwget\b", command or "") and "http" in (command or "").lower(): return "\n" + CURL_HINT return "" DEFAULT_MODEL = "muse" DEFAULT_VOICE = "en-US-EmmaMultilingualNeural" DEFAULT_PASSPHRASE = "tai-default-insecure-change-me" BOX_IMAGE = "tai-box:latest" BOX_NAME = "tai-box" BOX_MEMORY = "2g" BOX_CPUS = "2.0" BOX_PIDS_LIMIT = "256" BOX_RUN_ARGS = ( "--memory", BOX_MEMORY, "--cpus", BOX_CPUS, "--pids-limit", BOX_PIDS_LIMIT, ) BOX_UPDATE_ARGS = ("--memory", BOX_MEMORY, "--cpus", BOX_CPUS) _BOX_CAPS_STAMP = {"done": False} TELEGRAM_API = "https://api.telegram.org/bot" TELEGRAM_FILE_API = "https://api.telegram.org/file/bot" SKILL_NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$") BASHRC_MARK_BEGIN = "# tai command-not-found hook - start" BASHRC_MARK_END = "# tai command-not-found hook - end" BASHRC_BLOCK = ( BASHRC_MARK_BEGIN + "\n" "alias tai=\"$HOME/.local/bin/tai.py\"\n" "command_not_found_handle() {\n" " \"$HOME/.local/bin/tai.py\" \"$@\"\n" " return $?\n" "}\n" + BASHRC_MARK_END + "\n" ) ZSH_NOTFOUND_BLOCK = ( BASHRC_MARK_BEGIN + "\n" "alias tai=\"$HOME/.local/bin/tai.py\"\n" "command_not_found_handler() {\n" " \"$HOME/.local/bin/tai.py\" \"$@\"\n" " return $?\n" "}\n" + BASHRC_MARK_END + "\n" ) FISH_NOTFOUND_BLOCK = ( BASHRC_MARK_BEGIN + "\n" "function tai\n" " \"$HOME/.local/bin/tai.py\" $argv\n" "end\n" "function fish_command_not_found\n" " \"$HOME/.local/bin/tai.py\" $argv\n" "end\n" + BASHRC_MARK_END + "\n" ) BOX_CONTAINERFILE = """FROM python:3.12-slim ENV DEBIAN_FRONTEND=noninteractive PIP_NO_CACHE_DIR=1 RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg espeak-ng python3-venv && rm -rf /var/lib/apt/lists/* RUN python -m venv /box/venv && /box/venv/bin/pip install --no-cache-dir faster-whisper edge-tts RUN /box/venv/bin/python -c "from faster_whisper import WhisperModel; WhisperModel('tiny', device='cpu', compute_type='int8')" COPY stt.py /box/stt.py COPY tts.py /box/tts.py CMD ["sleep", "infinity"] """ BOX_PYTHON = "/box/venv/bin/python" _BOX_PYTHON_CACHE = {} BOX_STT = """import sys from faster_whisper import WhisperModel data = sys.stdin.buffer.read() with open("/tmp/in.audio", "wb") as handle: handle.write(data) model = WhisperModel("tiny", device="cpu", compute_type="int8") segments, _info = model.transcribe("/tmp/in.audio") print(" ".join(segment.text for segment in segments).strip(), flush=True) """ BOX_TTS = """import asyncio import sys import edge_tts async def speak(text, voice): talk = edge_tts.Communicate(text, voice or "en-US-EmmaMultilingualNeural") await talk.save("/tmp/out.mp3") text = sys.stdin.read().strip() voice = sys.argv[1] if len(sys.argv) > 1 else "" asyncio.run(speak(text, voice)) sys.stdout.buffer.write(open("/tmp/out.mp3", "rb").read()) """ TELEGRAM_UNIT = """[Unit] Description=tai telegram bot After=network-online.target Wants=network-online.target [Service] Type=simple WorkingDirectory=%%h ExecStart=%s --telegram EnvironmentFile=%s Restart=on-failure RestartSec=10 [Install] WantedBy=default.target """ SCHEDULER_UNIT = """[Unit] Description=tai scheduler After=network-online.target Wants=network-online.target [Service] Type=simple WorkingDirectory=%%h ExecStart=%s --scheduler Restart=on-failure RestartSec=10 [Install] WantedBy=default.target """ INSTALL_TARGETS = ("binary", "bash-hook", "zsh-hook", "fish-hook", "plexer-hook", "zsh-plexer-hook", "fish-plexer-hook", "venv", "scheduler-service", "telegram-service", "container", "skills") CONTEXT_CAP = 32000 COMPACT_RATIO = 0.8 KEEP_TURNS = 6 MAX_STEPS = 25 class Consts: """Live-patched tunables: TOTAL_STEP_CAP bounds Agent.run_turn, SYSINFO_TIMEOUT bounds sysinfo probes.""" TOTAL_STEP_CAP = 500 SYSINFO_TIMEOUT = 10 LOOP_NUDGE_AT = 3 LOOP_STOP_AT = 5 SYSTEM_MAX_CHARS = 12000 SHELL_TIMEOUT = 120 SCHEDULER_INTERVAL = 30 HTTP_TIMEOUT = 60 STREAM_TIMEOUT = 300 APPROVAL_MAX_RETRIES = 3 VERIFY_MAX_ROUNDS = 2 VERIFY_TIMEOUT = 120 VERIFY_STEPS = 6 VERIFY_MAX_CHARS = 6000 VERIFY_MAX_TRIED = 12 VERIFY_MAX_LINE = 400 VERIFY_MAX_FAILURES = 8 VERIFY_MAX_GOAL = 2000 VERIFY_MAX_ITEM = 200 VERIFY_MAX_REASON = 500 VERIFY_MAX_PROPOSAL = 500 VERIFY_MAX_COMMENTARY = 1000 FTS_TITLE_WEIGHT = 3.0 FTS_BODY_WEIGHT = 1.0 EVAL_K = 5 EVAL_FETCH = 10 EVAL_MAX_RECORDS = 50 EVAL_MAX_GOLDENS = 200 EVAL_MAX_PAIRS = 200 EVAL_MIN_TOKEN = 6 EVAL_PREFIX_MIN = 4 EDGE_HOST = "speech.platform.bing.com" EDGE_WS_PATH = "/consumer/speech/synthesize/readaloud/edge/v1" EDGE_TRUSTED_TOKEN = "6A5AA1D4EAFF4E9FB37E23D68491D6F4" EDGE_CHROMIUM = "143.0.3650.75" PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$") BOT_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$") BOT_MENTION_RE = re.compile(r"^@([A-Za-z0-9][A-Za-z0-9_-]{0,31})\s+(.*)$", re.DOTALL) LEADING_TAG_RE = re.compile(r"@(\S+)\s*") FILE_MENTION_RE = re.compile(r"(?= cap: break return found SECRET_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$") LEGACY_PASSWORD_NOTE = "especially passwords (collect and keep every password the user shares), preferences, or standing behavior changes" VAULT_MEMORY_NOTE = "preferences, or standing behavior changes. Store passwords, tokens, and secrets with store_secret and reference them by name; never write secret values into memory" SAFE_COMMANDS = ("ls", "pwd", "echo", "cat", "head", "tail", "grep", "find", "wc", "sort", "uniq", "diff", "file", "stat", "date", "whoami", "uname", "lsb_release") RECORDERS = (("arecord", ("arecord", "-q", "-d", "{seconds}", "-f", "cd", "-t", "wav", "{path}")), ("rec", ("rec", "-q", "{path}", "trim", "0", "{seconds}")), ("ffmpeg", ("ffmpeg", "-y", "-v", "quiet", "-f", "alsa", "-i", "default", "-t", "{seconds}", "{path}"))) TRANSCRIBERS = ("whisper-cpp", "whisper-cli", "whisper", "faster-whisper") SYSINFO_BINARIES = ("tmux", "git", "ffmpeg", "arecord", "sox", "whisper-cpp", "whisper", "systemctl", "podman", "docker", "muse", "grok", "opencode", "claude", "codex", "gemini", "ollama") DEFAULT_SYSTEM = ( "You are tai, a professional autonomous assistant. You act with tools, verify results, and report concisely. " "Rules: prefer the smallest change that solves the task; never silently ignore errors; call ask_user when requirements are ambiguous (never end your turn with open questions in prose: one-shot runs exit and prose questions are never answered); " "keep answers short and factual. Tools: shell and file tools for the local machine, web_search and web_fetch for the internet, " "web_fetch handles every HTTP method with headers and bodies so never curl or wget through shell, " "speak for voice output, recall to search past session memory. Memory: call remember whenever you learn durable facts, " "preferences, or standing behavior changes. Store passwords, tokens, and secrets with store_secret and reference them by name; " "never write secret values into memory. Name and value suffice for secrets; never pester the user for more. Call remember with a forget instruction to drop outdated knowledge. Delegation: do quick, interactive, or memory-changing work yourself; fork background subagents for independent, long, or context-heavy work and collect them with poll; schedule future work instead of waiting on it. " "Records: save big or durable findings with record_save and tag generously; large tool outputs spill to mem: ids automatically, page them with record_read, connect related nodes with graph_link. " "Tags: strict rules, lowercase singular only, shortest common word for the subject, one canonical tag per subject, at most 5 per item, always reuse existing tags from the tags tool instead of inventing synonyms. Content words matching known tags attach automatically, and new records link to recent same-tag records. " "Lazy tools: only everyday tools load by default; name any other tool or topic in your reply text and it becomes callable on your next step. " "Bots: one identity holds many bots; a bot is only a system message plus its own history, all vault data is shared. @name routes one turn to another bot and files the exchange in both histories; /bot switches, create_bot makes bots. " "Terminal: the user's question is very likely about what is visible in their terminal, especially right after a terminal capture or when launched with a single question straight from a shell; resolve vague references (this, that, it, the error) against that content, capture it with get_current_terminal_content instead of asking, and answer from what you see. " "Retrieval: use search first for anything stored: one ranked full-text query across records, events, and audit plus graph expansion beats paging and guessing. Chain tool calls until the task is done. " "Never narrate a planned action in text without emitting its tool call: text without tool calls ends your turn and the action will not happen." ) MERGE_SYSTEM = ( "You maintain an AI assistant system prompt. You receive the current system prompt and one memory instruction. " "Rewrite the system prompt to incorporate the instruction: add new facts, update changed behavior, or remove forgotten items. " "Never persist passwords, tokens, or other secret values; if the instruction holds one, note only that a secret exists and must be stored with store_secret. " "Preserve everything unrelated. Keep it organized with short sections. Output ONLY the rewritten system prompt, no explanation, no code fences." ) COMPACT_SYSTEM = ( "You compress assistant session history into a dense handoff summary. Cover: goal, completed work with outcomes, current state, " "key decisions, files touched, errors and fixes, pending next steps. Output only the summary, no preamble." ) AUTONOMOUS_NOTE = ( "Autonomous mode: never ask the user anything this session (the ask_user tool refuses, do not call it). When requirements are unclear, blocked, or need input, " "research deeply with web_search, web_fetch, shell, and subagents, then decide and proceed. State assumptions briefly in your reply." ) HELP_TEXT = ( "commands:\n" " /profile [name] show current profile or switch to it\n" " /profiles list all profiles, current marked with *\n" " /bots list bots, current marked with *\n" " /bot switch bot, history resumes\n" " /env [home|sandbox] show or switch execution environment\n" " /ctx show shell session tree and recent sessions\n" " /skills list loaded skills with provenance\n" " /skills install ... install an existing skill (copy or symlink)\n" " /secret manage sealed secrets (set|list|delete)\n" " /sysinfo show host environment checks\n" " /models show model roster with speed health\n" " /schedule at|every schedule a prompt for later\n" " /schedules list scheduled prompts\n" " /unschedule delete a scheduled prompt\n" " /records [query] search saved records\n" " /record read one record page\n" " /graph show node neighborhood\n" " /tags [prefix] list tags with usage counts\n" " /tools [name] show core and lazy tools\n" " /install ... install status, install, upgrade, reinstall\n" " /search ranked search over everything\n" " /audit [path] show file audit trail\n" " /restore restore a file from an audit row\n" " /release part msg bump version, back up, log message\n" " /fork spawn a background subagent, REPL stays free\n" " /agents list background subagents\n" " /agent show one result, or purge finished agents\n" " /compact compress history into a summary\n" " /clear drop history, keep system message\n" " /demo fullscreen TUI framework showcase\n" " /help this text\n" " /quit exit\n" " @bot @provider @model route this turn and save dir defaults (@auto clears pins, @path references a file)\n" "anything else is sent to the agent." ) class Ansi: RESET = "\033[0m" BOLD = "\033[1m" DIM = "\033[2m" UNDERLINE = "\033[4m" STRIKE = "\033[9m" RED = "\033[31m" GREEN = "\033[32m" YELLOW = "\033[33m" BLUE = "\033[34m" MAGENTA = "\033[35m" CYAN = "\033[36m" GRAY = "\033[90m" def paint(text, *codes): if os.environ.get("NO_COLOR") or not sys.stdout.isatty(): return text return "".join(codes) + text + Ansi.RESET def now_iso(): return datetime.now(timezone.utc).isoformat(timespec="seconds") def short_error(exc): text = str(exc) or type(exc).__name__ return text[:300] def truncate(text, head=1500, tail=500): if len(text) <= head + tail + 50: return text skipped = len(text) - head - tail return text[:head] + "\n...[%d chars truncated]...\n" % skipped + text[-tail:] SPILL_LIMIT = 6000 RECORD_KINDS = ("note", "output", "file", "research", "transcript", "termlog") AUDIT_MAX_CHARS = 20000 FILE_RECORD_MAX = 50000 SHELL_SNAPSHOT_MAX_FILES = 25 BACKUP_KEEP = 10 SHELL_HISTORY_LINES = 50 SHELL_HISTORY_CHARS = 4000 SHELL_HISTORY_READ_BYTES = 32768 TERMINAL_LIST_MAX_PANES = 50 TAIL_TERMINALS_DEFAULT_LINES = 3 TAIL_TERMINALS_MAX_LINES = 10 TAIL_TERMINALS_MAX_PANES = 20 TAIL_PER_PANE_CHARS = 800 _UNSET = object() def capped_text(text): if text is None: return None, None if len(text) <= AUDIT_MAX_CHARS: return text, len(text.encode("utf-8")) return text[:AUDIT_MAX_CHARS] + "\n...[truncated, full size %d bytes]..." % len(text.encode("utf-8")), len(text.encode("utf-8")) def trunc_marker(true_bytes): return "\n...[truncated, full size %d bytes]..." % true_bytes def read_capped(path, cap=AUDIT_MAX_CHARS): try: size = os.path.getsize(path) except OSError: return None, None try: with open(path, "r", encoding="utf-8", errors="replace") as handle: text = handle.read(cap + 1) except OSError: return None, None if len(text) > cap: return text[:cap], size return text, None SHELL_SEGMENT = (";", "&&", "||", "|", "&", "(", ")") SHELL_WRAPPERS = ("sudo", "command", "env", "nice", "nohup", "setsid") def shell_target_paths(command, workdir): spaced = re.sub(r"([;&|()<>])", r" \1 ", command) try: tokens = shlex.split(spaced, posix=True) except ValueError: return [] found = [] segment = [] def flush(): found.extend(segment_targets(segment)) for token in tokens: if token in SHELL_SEGMENT: flush() segment = [] else: segment.append(token) flush() resolved = [] for item in found: if len(resolved) >= SHELL_SNAPSHOT_MAX_FILES: break base = item if os.path.isabs(item) else os.path.join(workdir, item) if any(mark in item for mark in ("*", "?", "[")): try: hits = sorted(glob.glob(base, recursive=True)) except (OSError, ValueError): continue else: hits = [base] for hit in hits: if len(resolved) >= SHELL_SNAPSHOT_MAX_FILES: break if os.path.isfile(hit): resolved.append(os.path.abspath(hit)) elif os.path.isdir(hit): for root, _dirs, files in os.walk(hit): for entry in sorted(files): if len(resolved) >= SHELL_SNAPSHOT_MAX_FILES: break full = os.path.join(root, entry) if os.path.isfile(full): resolved.append(os.path.abspath(full)) if len(resolved) >= SHELL_SNAPSHOT_MAX_FILES: break seen = [] for item in resolved: if item not in seen: seen.append(item) return seen def segment_targets(tokens): targets = [] pos = 0 while pos < len(tokens): token = tokens[pos] if "=" in token and not token.startswith((">", "<", "-", "/")): pos += 1 elif token in SHELL_WRAPPERS: pos += 1 elif token == "timeout" and pos + 1 < len(tokens) and re.fullmatch(r"[0-9.]+[smhd]?", tokens[pos + 1] or ""): pos += 2 else: break rest = tokens[pos:] for index, token in enumerate(rest): if token in (">", ">>") and index + 1 < len(rest): cand = rest[index + 1] if cand and not cand.startswith("&") and not cand.startswith("/dev/"): targets.append(cand) if not rest: return targets name = os.path.basename(rest[0]) operands = [item for item in rest[1:] if not item.startswith("-") or item in ("-", "--")] operands = [item for item in operands if item not in (">", ">>", "<", "--")] if name == "rm": targets.extend(operands) elif name in ("mv", "cp") and operands: targets.append(operands[-1]) elif name == "tee": targets.extend(operands) elif name == "truncate": for item in operands: if re.fullmatch(r"[0-9]+[KMGTPE]?[iB]?", item or ""): continue targets.append(item) elif name == "dd": for item in operands: if item.startswith("of="): targets.append(item[3:]) elif name == "shred": targets.extend(operands) return [item for item in targets if item and item != "-"] def spill_output(store, kind, title, text, tags=(), profile=None): profile = profile if profile is not None else store.profile record_id = store.add_record(kind, title, text, tags, profile) store.add_chunks("record", record_id, chunk_lines(text), profile) return record_id def spilled_view(text, record_id, size, head=1500, tail=500): return text[:head] + "\n...[%d chars spilled to %s, record_read pages the rest]...\n" % (size, record_id) + text[-tail:] def spill_or_truncate(store, kind, title, text, tags=(), profile=None): if store is None or len(text) <= SPILL_LIMIT: return truncate(text) record_id = spill_output(store, kind, title, text, tags, profile) return spilled_view(text, record_id, len(text)) CHUNK_MAX_LINES = 60 CHUNK_OVERLAP_LINES = 3 CHUNK_MAX_CHARS = 4000 def chunk_lines(text, max_lines=CHUNK_MAX_LINES, overlap_lines=CHUNK_OVERLAP_LINES, max_chars=CHUNK_MAX_CHARS): """Generic RAG chunker: bounded windows of whole lines with small overlap, never splitting mid-line. Used for any content that gets indexed for retrieval (terminal captures, long records, ...), so there is one chunking rule in the codebase instead of one per content type. """ lines = text.splitlines() n = len(lines) if n == 0: return [] chunks = [] start = 0 while start < n: end = start size = 0 count = 0 while end < n and count < max_lines and size < max_chars: size += len(lines[end]) + 1 count += 1 end += 1 piece = "\n".join(lines[start:end]).strip() if piece: chunks.append(piece) if end >= n: break start = max(start + 1, end - overlap_lines) return chunks def collapse_repeated_lines(text, threshold=3): """Collapses runs of 3+ identical lines (progress bars, retry spam) to one line + a count note.""" lines = text.splitlines() out = [] i = 0 n = len(lines) while i < n: j = i while j < n and lines[j] == lines[i]: j += 1 run = j - i if run >= threshold: out.append(lines[i]) out.append("... (repeated %d more times) ..." % (run - 1)) else: out.extend(lines[i:j]) i = j return "\n".join(out) def tmux_pane_header(): try: info = subprocess.run(["tmux", "display-message", "-p", "#{session_name}:#{window_index}.#{pane_index} #{pane_current_command}"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=10) except (OSError, subprocess.SubprocessError): return "" if info.returncode == 0 and info.stdout.strip(): return info.stdout.strip() return "" ZSH_HISTORY_RE = re.compile(r"^: \d+:\d+;(.*)$") def shell_history_paths(): """Shell history files for terminal context, most specific first.""" paths = [] histfile = os.environ.get("HISTFILE") if histfile: paths.append(histfile) home = os.path.expanduser("~") for cand in (os.path.join(home, ".bash_history"), os.path.join(home, ".zsh_history"), os.path.join(home, ".local/share/fish/fish_history")): if cand not in paths: paths.append(cand) return paths def read_history_file(path): """Tail one shell history file, oldest command first. Never raises.""" try: size = os.path.getsize(path) except OSError: return [] try: with open(path, "rb") as handle: if size > SHELL_HISTORY_READ_BYTES: handle.seek(size - SHELL_HISTORY_READ_BYTES) handle.readline() raw = handle.read() except OSError: return [] fish = os.path.basename(path) == "fish_history" entries = [] for line in raw.decode("utf-8", errors="replace").splitlines(): stripped = line.strip() if not stripped: continue if fish: if not stripped.startswith("- cmd: "): continue stripped = stripped[len("- cmd: "):].strip() if stripped: entries.append(stripped) continue match = ZSH_HISTORY_RE.match(stripped) if match: stripped = match.group(1).strip() if not stripped: continue entries.append(stripped) return entries def shell_history_section(): """Capped recent-commands block for terminal context, or '' when none.""" merged = [] for path in shell_history_paths(): merged.extend(read_history_file(path)) entries = [] for line in merged: if not entries or line != entries[-1]: entries.append(line) entries = entries[-SHELL_HISTORY_LINES:] if SHELL_HISTORY_LINES > 0 else [] text = "\n".join(entries) if len(text) > SHELL_HISTORY_CHARS: text = text[-SHELL_HISTORY_CHARS:] if "\n" in text: text = text.split("\n", 1)[1] if not text.strip(): return "" return "\n\n## Recent shell history\n(oldest first, newest last)\n```\n%s\n```" % text def tmux_panes(): """Every pane in every tmux session. Returns (panes, error).""" if not shutil.which("tmux"): return [], "tmux not available" fmt = "#{session_name}\t#{window_index}\t#{pane_index}\t#{pane_id}\t#{pane_active}\t#{pane_current_command}\t#{pane_current_path}" try: done = subprocess.run(["tmux", "list-panes", "-a", "-F", fmt], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=10) except (OSError, subprocess.SubprocessError) as exc: return [], "terminal list failed: " + short_error(exc) if done.returncode != 0: err = (done.stderr or "").strip()[:200] if "no server running" in err: return [], "no open terminals (tmux server not running)" return [], "terminal list failed: " + err panes = [] for line in done.stdout.splitlines(): parts = line.split("\t") if len(parts) != 7: continue session, window, index, pane_id, active, command, path = parts panes.append({"ref": "%s:%s.%s" % (session, window, index), "id": pane_id, "active": active == "1", "command": command or "?", "path": path or "?"}) return panes, "" VERSION_RE = re.compile(r"^VERSION = \"(\d+)\.(\d+)\.(\d+)\"$", re.MULTILINE) def next_version(current, part): major, minor, patch = (int(item) for item in current.split(".")) if part == "major": return "%d.0.0" % (major + 1) if part == "minor": return "%d.%d.0" % (major, minor + 1) if part == "patch": return "%d.%d.%d" % (major, minor, patch + 1) raise ValueError("part must be major, minor, or patch") def atomic_write(path, content): tmp = "%s.tmp-%d" % (path, os.getpid()) with open(tmp, "w", encoding="utf-8") as handle: handle.write(content) handle.flush() os.fsync(handle.fileno()) os.replace(tmp, path) def sealed_write(path, content): atomic_write(path, content) try: os.chmod(path, 0o600) except OSError: pass def quarantine(path): try: if os.path.exists(path): os.replace(path, "%s.corrupt-%d" % (path, int(time.time()))) except OSError: pass def release_bump(script_file, part): with open(script_file, "r", encoding="utf-8") as handle: text = handle.read() match = VERSION_RE.search(text) if match is None: raise ValueError("no VERSION line in %s" % script_file) old = ".".join(match.groups()) new = next_version(old, part) atomic_write(script_file, text[:match.start()] + "VERSION = \"%s\"" % new + text[match.end():]) return old, new def backups_dir(home): return os.path.join(home, "backups") def prune_backups(folder): try: names = sorted((entry for entry in os.listdir(folder) if entry.startswith("tai-") and entry.endswith(".py")), key=lambda entry: os.path.getmtime(os.path.join(folder, entry))) except OSError: return 0 pruned = 0 while len(names) > BACKUP_KEEP: try: os.remove(os.path.join(folder, names.pop(0))) pruned += 1 except OSError: break return pruned def snapshot_self(script_file, folder, version): with open(script_file, "rb") as handle: data = handle.read() digest = hashlib.sha256(data).hexdigest()[:8] stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") name = "tai-%s-%s-%s.py" % (version, stamp, digest) os.makedirs(folder, exist_ok=True) dest = os.path.join(folder, name) with open(dest, "wb") as handle: handle.write(data) prune_backups(folder) return dest, digest def backup_has_digest(folder, digest): try: return any(entry.endswith("-%s.py" % digest) for entry in os.listdir(folder)) except OSError: return False def ensure_self_backup(config, store, script_file=None, actor="main"): script_file = script_file or os.path.abspath(__file__) folder = backups_dir(config.home) with open(script_file, "rb") as handle: digest = hashlib.sha256(handle.read()).hexdigest()[:8] if backup_has_digest(folder, digest): return None dest, _digest = snapshot_self(script_file, folder, VERSION) try: store.audit_event(actor, "snapshot", script_file, "backed up as %s" % os.path.basename(dest), tags=["snapshot", "v" + VERSION]) text, override = read_capped(script_file, FILE_RECORD_MAX) if text is not None: store.upsert_file_record(script_file, text, override) except (sqlite3.Error, OSError): pass return dest def do_release(store, script_file, folder, part, message, actor="main"): if not message or not message.strip(): raise ValueError("release message is required") old, new = release_bump(script_file, part) dest, _digest = snapshot_self(script_file, folder, new) row_id = store.audit_event(actor, "release", script_file, message.strip()[:500], old=old, new=new, tags=["release", "v" + new]) return old, new, dest, row_id def estimate_tokens(text): return max(1, len(text or "") // 4) def as_int(value, default=0): try: return int(value) except (TypeError, ValueError): return default def tool_name_clean(raw_name): # Some backends leak harmony control tokens into the name ("shell<|channel|>commentary"). return str(raw_name or "").split("<|", 1)[0].strip() TOOL_NAME_RE = re.compile(r"^[a-zA-Z0-9_.-]{1,64}$") PERMANENT_STATUSES = (400, 401, 402, 403, 404, 405, 410, 413, 422) def clean_history(messages): """Copy of messages that strict backends accept: tool calls with an invalid name are dropped together with their results, and an assistant turn left empty goes.""" dropped = set() kept = [] for message in messages: calls = message.get("tool_calls") if message.get("role") == "assistant" else None if calls: good = [call for call in calls if TOOL_NAME_RE.match(str((call.get("function") or {}).get("name") or ""))] dropped.update(call.get("id") for call in calls if call not in good) if len(good) != len(calls): message = dict(message, tool_calls=good or None) if not good and not message.get("content"): continue fixed = [dict(call, function=dict(call["function"], arguments=tool_args_valid(call["function"].get("arguments")))) if isinstance(call.get("function"), dict) and tool_args_valid(call["function"].get("arguments")) != call["function"].get("arguments") else call for call in (message.get("tool_calls") or [])] if fixed: message = dict(message, tool_calls=fixed) kept.append(message) return [message for message in kept if not (message.get("role") == "tool" and message.get("tool_call_id") in dropped)] def transient_failure(exc): """True when asking again later can succeed: timeouts, rate limits, 5xx, network.""" if getattr(exc, "status", 0) in PERMANENT_STATUSES: return False return "CLI not found" not in str(exc) def tool_args_valid(arguments): """Arguments text strict backends accept: empty or non-JSON becomes an empty object.""" try: json.loads(arguments) except (TypeError, ValueError): return "{}" return arguments def tool_args_text(raw_arguments): if not raw_arguments: return "{}" if isinstance(raw_arguments, str): return raw_arguments try: return json.dumps(raw_arguments, default=str) except (TypeError, ValueError): return str(raw_arguments) def action_signature(name, raw_arguments): try: flat = json.dumps(json.loads(tool_args_text(raw_arguments)), sort_keys=True, default=str) except ValueError: flat = str(raw_arguments or "") return "%s %s" % (name, flat[:500]) def result_digest(result): return hashlib.sha1((result or "").encode("utf-8", "replace")).hexdigest() class Spinner: FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") def __init__(self, label): self.label = label self.stop_flag = threading.Event() self.worker = None def start(self): if not sys.stderr.isatty(): return self.worker = threading.Thread(target=self.spin, daemon=True) self.worker.start() def spin(self): pos = 0 started = time.time() glow = Glow.stderr_enabled() widest = len(self.label) + 2 while not self.stop_flag.is_set(): frame = self.FRAMES[pos % len(self.FRAMES)] if glow: clock = "· %.1fs" % (time.time() - started) sys.stderr.write("\r%s%s%s %s %s%s%s" % (Ansi.BOLD, Ansi.CYAN, frame, Glow.shimmer(self.label, pos), Ansi.DIM, clock, Ansi.RESET)) widest = max(widest, len(self.label) + len(clock) + 3) else: sys.stderr.write("\r%s %s" % (frame, self.label)) sys.stderr.flush() pos += 1 time.sleep(0.08) sys.stderr.write("\r%s\r" % (" " * widest)) sys.stderr.flush() def stop(self): self.stop_flag.set() if self.worker is not None: self.worker.join(timeout=1) STREAM_HEIGHT = 4 RUN_OUTPUT_CAP = 4000000 RUN_PROMPT_IDLE = 1.0 RUN_MAX_ASKS = 20 RUN_ANSWER_CAP = 1000 RUN_ROWS = 24 RUN_COLS = 120 RUN_CTRL_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") RUN_PROMPT_RE = re.compile(r"(?:[:?>\]\)$#%]|\(y/n\)|\[y/n\])\s*$", re.I) def human_size(num): num = max(0, int(num)) if num < 1024: return "%d B" % num if num < 1048576: return "%.1f KB" % (num / 1024) return "%.1f MB" % (num / 1048576) def stream_status(code, output, elapsed, timed_out): detail = "%d lines · %s" % (len(output.splitlines()), human_size(len(output.encode("utf-8", "replace")))) if timed_out: return "timed out after %ds (killed) · %s" % (SHELL_TIMEOUT, detail) return "exit %d · %.1fs · %s" % (code, elapsed, detail) def truncate_ansi(text, width): plain = ANSI_RE.sub("", text) if len(plain) <= width: return text out = [] count = 0 pos = 0 for match in ANSI_RE.finditer(text): chunk = text[pos:match.start()] take = min(len(chunk), width - count) out.append(chunk[:take]) count += take if count >= width: pos = match.start() break out.append(match.group(0)) pos = match.end() if count < width: out.append(ANSI_RE.sub("", text[pos:])[:width - count]) return "".join(out) + Ansi.RESET class LiveStream: def __init__(self, height=STREAM_HEIGHT, width=0, file=None, enabled=True): self.height = max(1, height) self.width = width or shutil.get_terminal_size().columns self.file = file if file is not None else sys.stdout self.enabled = enabled self.lines = collections.deque(maxlen=self.height) self.shown = 0 def feed(self, line): if not self.enabled: return cleaned = truncate_ansi(line.split("\r")[-1].rstrip("\n").expandtabs(8), max(8, self.width - 2)) self.lines.append(cleaned) if self.shown < self.height: self.file.write("│ %s\n" % cleaned) self.shown += 1 else: self.file.write("\033[%dA" % self.height) for kept in self.lines: self.file.write("\r\033[K│ %s\n" % kept) self.file.flush() def pause(self): """The terminal is about to be used for a question: leave the window where it is and start a fresh one below whatever gets printed.""" self.lines.clear() self.shown = 0 def close(self, status): if not self.enabled: return if self.shown == 0: self.file.write("│ (no output)\n") self.file.write("│ %s\n" % truncate_ansi(status, max(8, self.width - 2))) self.file.flush() class PtyProc: """A child on its own pty with the pty as controlling terminal, so ssh, sudo and friends can prompt exactly as in a real terminal. One reader (select), one writer; the caller owns the loop.""" @staticmethod def available(): return pty is not None and termios is not None and fcntl is not None and select is not None def __init__(self, argv, shell, cwd, env, rows=None, cols=None): master, slave = pty.openpty() try: fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", rows or RUN_ROWS, cols or RUN_COLS, 0, 0)) def adopt(): os.setsid() fcntl.ioctl(0, termios.TIOCSCTTY, 0) self.proc = subprocess.Popen(argv, shell=shell, cwd=cwd, env=env, stdin=slave, stdout=slave, stderr=slave, preexec_fn=adopt, close_fds=True) except BaseException: os.close(master) raise finally: os.close(slave) self.master = master def read(self, timeout): """bytes, b"" when nothing arrived in time, None at end of output.""" if not select.select([self.master], [], [], max(0.0, timeout))[0]: return b"" try: return os.read(self.master, 65536) or None except OSError: return None def write(self, text): self.write_bytes(text.encode("utf-8", "replace")) def write_bytes(self, data): while data: sent = os.write(self.master, data) data = data[sent:] def resize(self, rows, cols): fcntl.ioctl(self.master, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) def echo_off(self): try: return not termios.tcgetattr(self.master)[3] & termios.ECHO except termios.error: return False def kill(self): try: os.killpg(self.proc.pid, signal.SIGKILL) except (OSError, ProcessLookupError): self.proc.kill() def close(self): try: os.close(self.master) except OSError: pass def run_live_pty(argv, timeout, shell, cwd, env, stream, ask): """run_live on a pty. When the command sits on a prompt (partial last line that looks like one, or echo switched off for a password) ask(text, secret) gets the question; its answer is typed into the command. None declines: the command gets end-of-file. Time spent waiting for the user does not count against the timeout.""" started = time.time() env = dict(env if env is not None else os.environ) env.setdefault("PAGER", "cat") env.setdefault("GIT_PAGER", "cat") term = PtyProc(argv, shell, cwd, env) decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") chunks = [] size = 0 partial = "" asked = "" asks = 0 declined = False timed_out = False deadline = started + timeout last_output = time.time() def emit(raw): nonlocal size line = RUN_CTRL_RE.sub("", raw.rstrip("\r").split("\r")[-1]) if size < RUN_OUTPUT_CAP: chunks.append(line + "\n") size += len(line) + 1 if stream is not None: stream.feed(line + "\n") def take(data): nonlocal partial partial += decoder.decode(data) while "\n" in partial: line, partial = partial.split("\n", 1) emit(line) try: while True: remaining = deadline - time.time() if remaining <= 0: timed_out = True break data = term.read(min(0.05, remaining)) if data is None: break if data: take(data) last_output = time.time() continue if term.proc.poll() is not None: while True: data = term.read(0) if not data: break take(data) break text = RUN_CTRL_RE.sub("", partial).strip() if declined or asks >= RUN_MAX_ASKS or not text or text == asked or time.time() - last_output < RUN_PROMPT_IDLE: continue secret = term.echo_off() if not secret and (RUN_PROMPT_RE.search(text) is None or "\r" in partial): continue asked = text asks += 1 if stream is not None and hasattr(stream, "pause"): stream.pause() waited = time.time() answer = ask(text, secret) deadline += time.time() - waited last_output = time.time() if answer is None: declined = True term.write("\x04") note = "» %s (no answer)" % text else: answer = answer.replace("\r", "").split("\n")[0][:RUN_ANSWER_CAP] term.write(answer + "\n") note = "» %s %s" % (text, "(hidden)" if secret else answer) if stream is not None: stream.feed(note + "\n") except BaseException: term.kill() term.proc.wait() raise finally: if timed_out: term.kill() try: term.proc.wait(timeout=10) except subprocess.TimeoutExpired: term.kill() term.proc.wait() term.close() if partial.strip(): emit(partial) return term.proc.returncode, "".join(chunks), time.time() - started, timed_out def run_live(argv, timeout=SHELL_TIMEOUT, shell=False, cwd=None, env=None, stream=None, ask=None): if ask is not None and PtyProc.available(): return run_live_pty(argv, timeout, shell, cwd, env, stream, ask) started = time.time() proc = subprocess.Popen(argv, shell=shell, cwd=cwd, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True, errors="replace", bufsize=1) chunks = [] pending = queue.Queue() def pump(): try: for line in proc.stdout: chunks.append(line) pending.put(line) finally: pending.put(None) reader = threading.Thread(target=pump, daemon=True) reader.start() timed_out = False try: deadline = started + timeout while True: remaining = deadline - time.time() if remaining <= 0: timed_out = True break try: item = pending.get(timeout=min(0.05, remaining)) except queue.Empty: continue if item is None: break if stream is not None: stream.feed(item) except BaseException: proc.kill() proc.wait() raise finally: if timed_out: proc.kill() try: proc.wait(timeout=10) except subprocess.TimeoutExpired: proc.kill() proc.wait() reader.join(timeout=10) if not timed_out: while not pending.empty(): item = pending.get_nowait() if item is None: break if stream is not None: stream.feed(item) return proc.returncode, "".join(chunks), time.time() - started, timed_out class LaneBoard: """Live lane block for poll waits: ticking elapsed, pulsing dots. Prints K lines once, then rewrites them in place at FPS until stop() leaves a steady final frame. Rows only flip running to finished mid-wait; if the row count ever changes the board degrades to fresh static lines instead of mis-drawing over scrollback. """ FPS = 3 PULSE = (Ansi.DIM, "", Ansi.BOLD, "") def __init__(self, profile, width=0, cap=6, file=None): self.profile = profile self.width = width or Glow.width() self.cap = cap self.file = file self.stop_flag = threading.Event() self.worker = None self.painted = 0 @staticmethod def available(app): return not getattr(app, "quiet", False) and Glow.enabled() def rows(self, pulse=""): return Glow.lanes(list_agents(self.profile), self.cap, self.width, pulse) def _paint(self, pulse): out = self.file if self.file is not None else sys.stdout rows = self.rows(pulse) if not rows: return if self.painted and self.painted != len(rows): for row in rows: out.write("%s\n" % row) out.flush() self.painted = len(rows) return if self.painted: out.write("\033[%dA" % self.painted) for row in rows: out.write("\r\033[K%s\n" % row) out.flush() self.painted = len(rows) def _tick(self): frame = 1 while not self.stop_flag.is_set(): time.sleep(1.0 / self.FPS) if self.stop_flag.is_set(): break try: self._paint(self.PULSE[frame % len(self.PULSE)]) except Exception: break frame += 1 def start(self): if self.worker is not None: return self._paint("") self.worker = threading.Thread(target=self._tick, daemon=True) self.worker.start() def stop(self): self.stop_flag.set() if self.worker is not None: self.worker.join(timeout=2) self.worker = None self._paint("") DIFF_CONTEXT = 3 DIFF_MAX_LINES = 120 BG_ADD = "\033[48;5;236m" BG_DEL = "\033[48;5;236m" FG_ADD = "\033[38;5;222m" FG_DEL = "\033[38;5;203m" FG_FILE = "\033[38;5;111m" FG_BRIGHT = "\033[97m" FG_YOLO = "\033[38;5;211m" FG_ORANGE = "\033[38;5;214m" FG_RUN = "\033[38;5;80m" FG_FORK = "\033[38;5;135m" PY_KEYWORDS = ("False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del", "elif", "else", "except", "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while", "with", "yield", "match", "case") PY_TOKEN_RE = re.compile(r"(?P\"\"\"(?:\\.|[^\\])*?\"\"\"|'''(?:\\.|[^\\])*?'''|\"(?:\\.|[^\"\\\n])*\"|'(?:\\.|[^'\\\n])*')|(?P#[^\n]*)|(?P\b\d[\d._]*(?:[eE][+-]?\d+)?[jJ]?\b)|(?P\b(?:%s)\b)|(?P@[A-Za-z_][\w.]*)|(?P(?<=def )[A-Za-z_]\w*|(?<=class )[A-Za-z_]\w*)" % "|".join(PY_KEYWORDS)) def color_enabled(): return not os.environ.get("NO_COLOR") and sys.stdout.isatty() def highlight_python(line): parts = [] pos = 0 for match in PY_TOKEN_RE.finditer(line): parts.append(line[pos:match.start()]) kind = match.lastgroup text = match.group(0) if kind == "string": parts.append(Ansi.YELLOW + text + Ansi.RESET) elif kind == "comment": parts.append(Ansi.GRAY + text + Ansi.RESET) elif kind == "number": parts.append(FG_ORANGE + text + Ansi.RESET) elif kind == "keyword": parts.append(Ansi.MAGENTA + text + Ansi.RESET) else: parts.append(Ansi.CYAN + text + Ansi.RESET) pos = match.end() parts.append(line[pos:]) return "".join(parts) def split_diff_lines(text): if not text: return [] lines = text.split("\n") if lines and lines[-1] == "": lines.pop() return lines def render_diff(path, old, new, width=0, colors=True): if new is not None and (old or "") == new: return "" old_lines = split_diff_lines(old or "") new_lines = split_diff_lines(new) if new is not None else [] if not width: try: width = shutil.get_terminal_size().columns except OSError: width = 80 matcher = difflib.SequenceMatcher(None, old_lines, new_lines, autojunk=False) rows = [] for group in matcher.get_grouped_opcodes(DIFF_CONTEXT): if rows: rows.append(("gap", 0, 0, "")) for tag, i1, i2, j1, j2 in group: if tag == "equal": for offset in range(i2 - i1): rows.append(("ctx", i1 + offset + 1, j1 + offset + 1, old_lines[i1 + offset])) elif tag == "delete": for offset in range(i2 - i1): rows.append(("del", i1 + offset + 1, 0, old_lines[i1 + offset])) elif tag == "insert": for offset in range(j2 - j1): rows.append(("add", 0, j1 + offset + 1, new_lines[j1 + offset])) else: for offset in range(i2 - i1): rows.append(("del", i1 + offset + 1, 0, old_lines[i1 + offset])) for offset in range(j2 - j1): rows.append(("add", 0, j1 + offset + 1, new_lines[j1 + offset])) added = sum(1 for kind, _a, _b, _t in rows if kind == "add") removed = sum(1 for kind, _a, _b, _t in rows if kind == "del") if new is None: verb, meta = "Deleted", "(deleted, %d lines)" % len(old_lines) elif not old_lines: verb, meta = "Created", "(new file, %d lines)" % len(new_lines) else: verb, meta = "Edited", "(+%d -%d)" % (added, removed) python = str(path).lower().endswith(".py") num_width = max(4, len(str(max(len(old_lines), len(new_lines), 1)))) code_width = max(20, width - num_width - 4) fence = None out = [] if colors: out.append(Glow.event_row("◆", verb, str(path), meta, width)) else: out.append("│ ── %s %s" % (path, meta)) for kind, old_no, new_no, text in rows[:DIFF_MAX_LINES]: if kind == "gap": out.append(Ansi.DIM + "···" + Ansi.RESET if colors else "···") continue code = strip_ansi(text).expandtabs(8) if fence is not None: if python and colors: code = Ansi.YELLOW + code + Ansi.RESET if fence in text: fence = None elif python and colors: for mark in ('"""', "'''"): if text.count(mark) % 2 == 1: fence = mark code = Ansi.YELLOW + code + Ansi.RESET break else: code = highlight_python(code) number = old_no if kind in ("ctx", "del") else new_no gutter = str(number).rjust(num_width) if not colors: sign = {"ctx": " ", "del": "-", "add": "+"}[kind] out.append("%s %s %s" % (gutter, sign, code[:code_width])) continue dimmed = Ansi.DIM + gutter + Ansi.RESET if kind == "ctx": out.append("%s %s" % (dimmed, truncate_ansi(code, code_width))) elif kind == "del": row = "%s %s %s" % (dimmed, Ansi.RED + Ansi.BOLD + "-" + Ansi.RESET, truncate_ansi(code, code_width)) out.append(pad_tinted(style_span(row, BG_DEL, FG_DEL), width)) else: row = "%s %s %s" % (dimmed, FG_ADD + Ansi.BOLD + "+" + Ansi.RESET, truncate_ansi(code, code_width)) out.append(pad_tinted(style_span(row, BG_ADD, FG_ADD), width)) if len(rows) > DIFF_MAX_LINES: note = "··· %d more lines hidden" % (len(rows) - DIFF_MAX_LINES) out.append(Ansi.DIM + note + Ansi.RESET if colors else note) return "\n".join(out) def pad_tinted(line, width): plain = len(ANSI_RE.sub("", line)) if plain < width: fill = " " * (width - plain) if line.endswith(Ansi.RESET): return line[:-len(Ansi.RESET)] + fill + Ansi.RESET return line + fill return line ANSI_RE = re.compile(r"\033\[[0-9;]*m") MD_BULLETS = ("•", "◦", "▪") def strip_ansi(text): return ANSI_RE.sub("", text) def style_text(text, *codes): return "".join(codes) + text + Ansi.RESET def style_span(text, *codes): prefix = "".join(codes) return prefix + text.replace(Ansi.RESET, Ansi.RESET + prefix) + Ansi.RESET def wrap_ansi(text, width, first_prefix="", next_prefix=""): parts = re.findall(r"\033\[[0-9;]*m|[^\s\033]+|\s+", text) lines = [] current = first_prefix length = len(strip_ansi(first_prefix)) base = length active = "" for part in parts: if not part: continue if part.startswith("\033"): current += part if part == Ansi.RESET: active = "" else: active += part continue if part.isspace(): if length < width and length > base: current += " " length += 1 continue if length + len(part) > width and length > base: if active: current += Ansi.RESET lines.append(current.rstrip()) current = next_prefix + active length = len(strip_ansi(next_prefix)) base = length current += part length += len(part) if active: current += Ansi.RESET if current.strip(): lines.append(current.rstrip()) return lines def md_inline(text): stashed = [] def stash(value): stashed.append(value) return "\x00%d\x00" % (len(stashed) - 1) text = re.sub(r"`([^`\n]+)`", lambda match: stash(style_text(match.group(1), Ansi.CYAN)), text) text = re.sub(r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])", lambda match: stash(match.group(1)), text) text = re.sub(r"!\[([^\]\n]*)\]\(([^)\s\n]+)(?:\s+\"[^\"]*\")?\)", lambda match: stash("[image: %s]" % (match.group(1) or match.group(2))), text) text = re.sub(r"\[([^\]\n]+)\]\(([^)\s\n]+)(?:\s+\"[^\"]*\")?\)", lambda match: style_span(md_inline(match.group(1)), Ansi.BLUE, Ansi.UNDERLINE) + style_text(" (%s)" % match.group(2), Ansi.DIM), text) text = re.sub(r"\*\*([^*\n]+)\*\*", lambda match: style_text(match.group(1), Ansi.BOLD), text) text = re.sub(r"(?", " \n", text, flags=re.IGNORECASE) raw = text.split("\n") out = [] pos = 0 total = len(raw) while pos < total: line = raw[pos] stripped = line.strip() if not stripped: pos += 1 continue fence = re.match(r"^\s*(`{3,}|~{3,})\s*\S*\s*$", line) if fence: char = fence.group(1)[0] pos += 1 while pos < total: closer = raw[pos].strip() if closer and set(closer) == {char} and len(closer) >= 3: pos += 1 break out.append(" " + style_text(raw[pos], Ansi.DIM) if raw[pos].strip() else "") pos += 1 out.append("") continue if "|" in line and pos + 1 < total and md_is_separator(raw[pos + 1]): header = md_split_row(line) aligns = [] for cell in md_split_row(raw[pos + 1]): if cell.startswith(":") and cell.endswith(":") and len(cell) > 2: aligns.append("center") elif cell.endswith(":"): aligns.append("right") else: aligns.append("left") pos += 2 rows = [] while pos < total and "|" in raw[pos] and raw[pos].strip(): rows.append(md_split_row(raw[pos])) pos += 1 while len(aligns) < max([len(header)] + [len(row) for row in rows]): aligns.append("left") out.extend(md_table_block(header, aligns, rows)) out.append("") continue heading = re.match(r"^(#{1,6})\s+(.*\S)\s*$", stripped) if heading: level = len(heading.group(1)) body = md_inline(heading.group(2)) if level == 1: out.append(style_span(body, Ansi.BOLD, Ansi.CYAN)) elif level == 2: out.append(style_span(body, Ansi.BOLD)) else: out.append(style_span(body, Ansi.BOLD, Ansi.DIM)) out.append("") pos += 1 continue if re.fullmatch(r"\*{3,}|-{3,}|_{3,}", stripped.replace(" ", "")): out.append(style_text("─" * width, Ansi.DIM)) out.append("") pos += 1 continue if stripped.startswith(">"): paras = [] current = [] while pos < total and raw[pos].strip().startswith(">"): inner = re.sub(r"^\s*(?:>\s?)+", "", raw[pos]) if inner.strip(): current.append(inner.strip()) elif current: paras.append(current) current = [] pos += 1 if current: paras.append(current) for index, para in enumerate(paras): if index: out.append(style_text("│", Ansi.DIM)) prefix = style_text("│ ", Ansi.DIM) for wrapped in wrap_ansi(md_inline(" ".join(para)), width, prefix, prefix): out.append(wrapped) out.append("") continue bullet = re.match(r"^(\s*)[-*+]\s+(.*\S)\s*$", line) numbered = re.match(r"^(\s*)\d+[.)]\s+(.*\S)\s*$", line) if bullet or numbered: counters = {} while pos < total: again_bullet = re.match(r"^(\s*)[-*+]\s+(.*\S)\s*$", raw[pos]) again_numbered = re.match(r"^(\s*)\d+[.)]\s+(.*\S)\s*$", raw[pos]) if not again_bullet and not again_numbered: break if again_bullet: level = len(again_bullet.group(1)) // 2 marker = MD_BULLETS[level % len(MD_BULLETS)] body = again_bullet.group(2) check = re.match(r"^\[([ xX])\]\s+(.*\S)\s*$", body) if check: marker = "☑" if check.group(1).lower() == "x" else "☐" body = check.group(2) else: level = len(again_numbered.group(1)) // 2 counters[level] = counters.get(level, 0) + 1 for deeper in [key for key in counters if key > level]: del counters[deeper] marker = "%d." % counters[level] body = again_numbered.group(2) pad = " " * level for wrapped in wrap_ansi(md_inline(body), width, pad + marker + " ", pad + " " * (len(marker) + 1)): out.append(wrapped) pos += 1 out.append("") continue para = [] while pos < total and raw[pos].strip(): para.append(raw[pos]) pos += 1 segments = [] current = [] for chunk in para: current.append(chunk.strip()) if chunk.endswith(" "): segments.append(" ".join(current)) current = [] if current: segments.append(" ".join(current)) for segment in segments: for wrapped in wrap_ansi(md_inline(segment), width): out.append(wrapped) out.append("") while out and not out[-1]: out.pop() return "\n".join(out) class Glow: """Screenshot-style CLI event renderer: bold verbs, blue targets, dim meta. Every styling method always emits codes; call sites gate on enabled() so pipes, NO_COLOR, and tests keep the plain format byte for byte. """ @staticmethod def enabled(): return color_enabled() @staticmethod def stderr_enabled(): return not os.environ.get("NO_COLOR") and sys.stderr.isatty() @staticmethod def width(fallback=80): try: return shutil.get_terminal_size().columns except OSError: return fallback @staticmethod def accent_for(name): """Kind color for the card bar and open icon. Small surface on purpose.""" if name == "shell": return FG_RUN if name == "fork": return FG_FORK if name == "poll": return FG_FILE if name in ("write_file", "edit_file", "delete_file"): return Ansi.GREEN if name == "ask_user": return FG_ORANGE if name in ("store_secret", "list_secrets", "delete_secret"): return FG_YOLO return Ansi.DIM @staticmethod def bar(name): return "%s▌%s " % (Glow.accent_for(name), Ansi.RESET) @staticmethod def event_row(icon, verb, target, meta="", width=0, icon_color=""): row = "%s%s%s%s %s%s%s" % (Ansi.BOLD, icon_color, icon, Ansi.RESET, Ansi.BOLD, verb, Ansi.RESET) if target: row += " %s%s%s" % (FG_FILE, target, Ansi.RESET) if meta: row += " %s%s%s" % (Ansi.DIM, meta, Ansi.RESET) return truncate_ansi(row, width) if width else row @staticmethod def preview_row(text, width=0): row = "%s│ %s%s" % (Ansi.DIM, text, Ansi.RESET) return truncate_ansi(row, width) if width else row @staticmethod def icon_for(name): """Small open-row icon set: execute, spawn, poll, everything else.""" if name == "shell": return "▶" if name == "fork": return "✦" if name == "poll": return "⟳" return "◇" @staticmethod def ellipsis(text, cap=60, head=False): """Truncate long targets: middle cut by default, head cut for sentences whose discriminating words sit at the front (agent tasks).""" if len(text or "") <= cap or cap < 8: return text if head: return text[:cap - 1] + "…" keep = cap - 1 front = keep // 2 return text[:front] + "…" + text[len(text) - (keep - front):] @staticmethod def done_row(verb, target, chars, elapsed_ms, failed=False, width=0): elapsed = "%.1fs" % (elapsed_ms / 1000.0) if elapsed_ms >= 1000 else "%dms" % elapsed_ms mark = Ansi.RED + "✗" + Ansi.RESET if failed else Ansi.GREEN + "✓" + Ansi.RESET tail = "%s· %s · %d chars%s" % (Ansi.DIM, elapsed, chars, Ansi.RESET) left = "%s◆%s %s%s%s" % (Ansi.BOLD, Ansi.RESET, Ansi.BOLD, verb, Ansi.RESET) if target: left += " %s%s%s" % (FG_FILE, target, Ansi.RESET) if width: plain_left, plain_right = len(strip_ansi(left)), len(strip_ansi(mark + " " + tail)) if plain_left + 2 + plain_right <= width: return left + " " * (width - plain_left - plain_right) + mark + " " + tail return truncate_ansi(left + " " + Ansi.DIM + "·" + Ansi.RESET + " " + mark + " " + tail, width) return left + " " + Ansi.DIM + "·" + Ansi.RESET + " " + mark + " " + tail @staticmethod def hairline(width=0): width = width or Glow.width() return "%s%s%s" % (Ansi.DIM, "─" * max(1, width), Ansi.RESET) @staticmethod def shimmer(text, pos, span=3): """Traveling bright window over dim text: the live-line light effect.""" if not text: return "" cycle = len(text) + span * 2 center = (pos % cycle) - span out = [] for pos_, char in enumerate(text): dist = abs(pos_ - center) if dist == 0: out.append(Ansi.BOLD + FG_BRIGHT + char) elif dist <= span: out.append(Ansi.BOLD + char) else: out.append(Ansi.DIM + char) return "".join(out) + Ansi.RESET @staticmethod def describe_call(name, raw_arguments): """Map a tool call to (verb, target, extra, preview) for event rows.""" try: args = raw_arguments if isinstance(raw_arguments, dict) else json.loads(raw_arguments or "{}") parsed = True except (TypeError, ValueError): args = {} parsed = False if not isinstance(args, dict): args = {} parsed = False if name == "store_secret" and "value" in args: args = dict(args) args["value"] = "[hidden]" def head(key, cap=80): value = args.get(key) if value is None: return "" return " ".join(str(value).split())[:cap] def base(key): value = head(key) return os.path.basename(value.rstrip("/")) or value path = base("path") or base("file") command = head("command") query = head("query") url = head("url") prompt = head("prompt", 60) task = head("task", 60) who = head("name") ident = head("id") or head("target") or head("agent_id") when = head("at") or ("every %s" % head("every") if head("every") else "") spec = args.get("spec") if isinstance(args.get("spec"), dict) else {} flow = " ".join(str(spec.get("title") or "%d screens" % len(spec.get("screens") or [])).split())[:60] table = { "shell": ("Run", command, ""), "read_file": ("Read", path, ""), "write_file": ("Write", path, ""), "edit_file": ("Edit", path, ""), "delete_file": ("Delete", path, ""), "web_search": ("Search", query, ""), "web_fetch": ("Fetch", url, ""), "describe_image": ("See", path or head("image"), ""), "speak": ("Speak", head("text", 60), ""), "listen": ("Listen", head("seconds"), ""), "remember": ("Remember", head("instruction", 60), ""), "recall": ("Recall", query, ""), "load_skill": ("Skill", who, ""), "create_skill": ("Skill", who, ""), "install_skill": ("Install", who or head("source"), ""), "get_current_terminal_content": ("Capture", "terminal", ""), "list_terminals": ("Terminals", "", ""), "tail_terminals": ("Tail", "terminals", ""), "export_terminal_log": ("Export", "terminal log", ""), "plexer_send": ("Send", ident, ""), "plexer_read": ("Read", ident, ""), "plexer_type": ("Type", ident, ""), "plexer_status": ("Plexer", "", ""), "plexer_show": ("Show", flow, ""), "tui_screen": ("TUI", "screen", ""), "tui_keys": ("TUI", "keys", ""), "tui_type": ("TUI", "type", ""), "tui_click": ("TUI", "click", ""), "tui_goto": ("TUI", "goto", ""), "tui_result": ("TUI", "result", ""), "fork": ("Fork", task, ""), "poll": ("Poll", ident, ""), "ask_user": ("Ask", head("question", 60), ""), "sysinfo": ("Probe", "", ""), "store_secret": ("Seal", who, ""), "list_secrets": ("Seal", "list", ""), "delete_secret": ("Delete", who, ""), "schedule": ("Schedule", prompt, when), "unschedule": ("Unschedule", ident, ""), "schedules": ("Schedules", "", ""), "record_save": ("Save", head("title"), ""), "record_read": ("Read", ident, ""), "record_search": ("Search", query, ""), "record_delete": ("Delete", ident, ""), "graph_link": ("Link", "", ""), "graph_query": ("Graph", head("node"), ""), "audit": ("Audit", path, ""), "restore": ("Restore", ident, ""), "release": ("Release", head("part"), head("message", 60)), "tags": ("Tags", head("prefix"), ""), "install": ("Install", head("component"), ""), "create_bot": ("Bot", who, ""), "search": ("Search", query, ""), } primary = {"shell": "command", "read_file": "path", "write_file": "path", "edit_file": "path", "delete_file": "path", "web_search": "query", "web_fetch": "url", "describe_image": "path", "speak": "text", "remember": "instruction", "recall": "query", "load_skill": "name", "create_skill": "name", "install_skill": "source", "fork": "task", "ask_user": "question", "delete_secret": "name", "schedule": "prompt", "unschedule": "id", "record_save": "title", "record_read": "id", "record_search": "query", "record_delete": "id", "graph_query": "node", "audit": "path", "restore": "id", "release": "part", "tags": "prefix", "install": "component", "create_bot": "name", "search": "query"} if name not in table: return name, "", "", " ".join(tool_args_text(raw_arguments).split()) verb, target, extra = table[name] bits = ([extra] if extra else []) + [name] if name == "poll" and ident: preview = "agent %s" % ident elif name in primary: preview = head(primary[name], 200) else: preview = "" if not preview: preview = " ".join(tool_args_text(args if parsed else raw_arguments).split()) return verb, Glow.ellipsis(target, 60), " · ".join(bits), preview @staticmethod def statusbar(version, profile, directory, yolo=False, agents=0, width=0): home = os.path.expanduser("~") short = directory if directory == home: short = "~" elif home and directory.startswith(home + os.sep): short = "~" + directory[len(home):] segs = [ "%s%stai %s%s" % (Ansi.BOLD, FG_FILE, version, Ansi.RESET), "%s%s%s" % (FG_FILE, profile, Ansi.RESET), "%s%s%s" % (Ansi.DIM, short, Ansi.RESET), ] if yolo: segs.append("%s%sYOLO%s" % (Ansi.BOLD, FG_YOLO, Ansi.RESET)) if agents: segs.append("%s+%d%s" % (Ansi.YELLOW, agents, Ansi.RESET)) dot = "%s·%s" % (Ansi.DIM, Ansi.RESET) width = width or Glow.width() flow = dot.join(" %s " % seg for seg in segs).strip() if (yolo or agents) and len(segs) > 3: left = dot.join(" %s " % seg for seg in segs[:3]).strip() right = dot.join(" %s " % seg for seg in segs[3:]).strip() gap = width - len(strip_ansi(left)) - len(strip_ansi(right)) if gap >= 4: return left + " " * gap + right flow = dot.join(" %s " % seg for seg in [segs[0], segs[1]] + segs[3:]).strip() if len(strip_ansi(flow)) <= width: return flow core = truncate_ansi(flow, width) return re.sub(r"(?:\033\[[0-9;]*m|[ ·])+$", "", core) + Ansi.RESET @staticmethod def prompt_text(): return "%s%s› %s" % (Ansi.BOLD, FG_ORANGE, Ansi.RESET) @staticmethod def agent_row(record, width=0, pulse="", suffix=""): dots = {"running": Ansi.YELLOW, "done": Ansi.GREEN, "error": Ansi.RED, "timeout": FG_ORANGE} dot = dots.get(record.get("status"), Ansi.DIM) task = Glow.ellipsis(" ".join(str(record.get("task") or "").split()), 50, head=True) row = " #%d %s%s●%s %s %s· %s · %s%s" % (record.get("id"), pulse, dot, Ansi.RESET, task, Ansi.DIM, record.get("status"), format_delay(record.get("elapsed") or 0), Ansi.RESET) if record.get("tools") == 0 and record.get("status") != "running": row += " %s· 0 tools%s" % (FG_ORANGE, Ansi.RESET) if suffix: row += suffix return truncate_ansi(row, width) if width else row @staticmethod def lanes(records, cap=6, width=0, pulse=""): running = [record for record in records if record["status"] == "running"] pending = [record for record in records if record["status"] in ("done", "timeout", "error") and not record.get("collected")] view = running + pending head = "◇ lanes · %d running" % len(running) if pending: head += " · %d to collect" % len(pending) rows = [truncate_ansi("%s%s%s" % (Ansi.DIM, head, Ansi.RESET), width) if width else "%s%s%s" % (Ansi.DIM, head, Ansi.RESET)] for record in view[:cap]: suffix = " %s· to collect%s" % (Ansi.DIM, Ansi.RESET) if record in pending else "" rows.append(Glow.agent_row(record, width, pulse if record["status"] == "running" else "", suffix)) if len(view) > cap: rows.append("%s · +%d more%s" % (Ansi.DIM, len(view) - cap, Ansi.RESET)) return rows class SealError(Exception): pass class Seal: PREFIX = "tai1$" ROUNDS = 200000 def __init__(self, home, passphrase): self.enabled = bool(passphrase) self.default_key = False self.master = b"" self.enc_key = b"" self.mac_key = b"" if self.enabled: self.master = self.load_master(home, passphrase) self.enc_key = hmac.new(self.master, b"tai-enc-1", hashlib.sha256).digest() self.mac_key = hmac.new(self.master, b"tai-mac-1", hashlib.sha256).digest() def load_master(self, home, passphrase): path = os.path.join(home, ".seal") raw = "" if os.path.exists(path): with open(path, "r", encoding="utf-8") as handle: raw = handle.read() saved = {} if raw: try: saved = json.loads(raw) salt = base64.b64decode(saved["salt"]) except (ValueError, KeyError): raise SealError("seal file is corrupt") else: salt = os.urandom(16) master = hashlib.pbkdf2_hmac("sha256", passphrase.encode("utf-8"), salt, self.ROUNDS, 32) check = hmac.new(master, b"tai-check-1", hashlib.sha256).hexdigest() if raw: if not hmac.compare_digest(saved.get("check", ""), check): raise SealError("wrong TAI_PASSPHRASE") return master with open(path, "w", encoding="utf-8") as handle: handle.write(json.dumps({"salt": base64.b64encode(salt).decode("ascii"), "check": check})) try: os.chmod(path, 0o600) except OSError: pass return master def stream(self, nonce, length): out = bytearray() counter = 0 while len(out) < length: out += hmac.new(self.enc_key, nonce + struct.pack(">I", counter), hashlib.sha256).digest() counter += 1 return bytes(out[:length]) def lock(self, text): if not self.enabled or text.startswith(self.PREFIX): return text nonce = os.urandom(16) body = text.encode("utf-8") keystream = self.stream(nonce, len(body)) cipher = bytes(piece ^ keystream[pos] for pos, piece in enumerate(body)) mac = hmac.new(self.mac_key, nonce + cipher, hashlib.sha256).digest() parts = [base64.b64encode(blob).decode("ascii") for blob in (nonce, cipher, mac)] return self.PREFIX + "$".join(parts) def unlock(self, text): if not text.startswith(self.PREFIX): raise SealError("not sealed") try: _tag, nonce64, cipher64, mac64 = text.split("$", 3) nonce = base64.b64decode(nonce64) cipher = base64.b64decode(cipher64) mac = base64.b64decode(mac64) except ValueError: raise SealError("sealed value is corrupt") expect = hmac.new(self.mac_key, nonce + cipher, hashlib.sha256).digest() if not hmac.compare_digest(mac, expect): raise SealError("seal check failed") keystream = self.stream(nonce, len(cipher)) plain = bytes(piece ^ keystream[pos] for pos, piece in enumerate(cipher)) return plain.decode("utf-8") def safe_unlock(self, text): try: return self.unlock(text) except SealError: return "[sealed: decrypt failed]" def resolve_passphrase(): raw = os.environ.get("TAI_PASSPHRASE") if raw is None: return DEFAULT_PASSPHRASE, True return raw, False def rotate_seal(config, old_seal, new_passphrase): db = sqlite3.connect(config.db_path) rows = db.execute("SELECT id, text FROM events").fetchall() tables = [row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall()] secret_cols = [row[1] for row in db.execute("PRAGMA table_info(secrets)").fetchall()] if "secrets" in tables else [] has_profile = "profile" in secret_cols secret_rows = db.execute("SELECT profile, name, value FROM secrets").fetchall() if has_profile else [(None, name, value) for name, value in db.execute("SELECT name, value FROM secrets").fetchall()] if "secrets" in tables else [] meta_rows = db.execute("SELECT profile, name, meta FROM secrets WHERE meta IS NOT NULL").fetchall() if has_profile and "meta" in secret_cols else [(None, name, meta) for name, meta in db.execute("SELECT name, meta FROM secrets WHERE meta IS NOT NULL").fetchall()] if "meta" in secret_cols else [] planned_rows = db.execute("SELECT id, prompt FROM schedules").fetchall() if "schedules" in tables else [] setting_rows = db.execute("SELECT key, scope, value FROM settings").fetchall() if "settings" in tables else [] mcp_rows = db.execute("SELECT name, headers_json FROM mcp_servers").fetchall() if "mcp_servers" in tables else [] db.close() texts = {} for row_id, text in rows: texts[row_id] = old_seal.unlock(text) if text.startswith(Seal.PREFIX) else text secret_texts = {} for profile, name, value in secret_rows: secret_texts[(profile, name)] = old_seal.unlock(value) if value.startswith(Seal.PREFIX) else value secret_metas = {} for profile, name, meta in meta_rows: secret_metas[(profile, name)] = old_seal.unlock(meta) if meta.startswith(Seal.PREFIX) else meta planned_texts = {} for row_id, prompt in planned_rows: planned_texts[row_id] = old_seal.unlock(prompt) if prompt.startswith(Seal.PREFIX) else prompt setting_texts = {} for key, scope, value in setting_rows: if value and value.startswith(Seal.PREFIX): setting_texts[(key, scope)] = (old_seal.unlock(value), True) else: setting_texts[(key, scope)] = (value, False) mcp_texts = {} for name, headers in mcp_rows: if headers and headers.startswith(Seal.PREFIX): mcp_texts[name] = (old_seal.unlock(headers), True) else: mcp_texts[name] = (headers, False) files = {} for entry in os.listdir(config.profiles_dir): if not entry.endswith((".sys.md", ".session.json", ".bots.json")): continue path = os.path.join(config.profiles_dir, entry) with open(path, "r", encoding="utf-8") as handle: raw = handle.read() files[path] = old_seal.unlock(raw) if raw.startswith(Seal.PREFIX) else raw try: os.remove(os.path.join(config.home, ".seal")) except OSError: pass fresh = Seal(config.home, new_passphrase) db = sqlite3.connect(config.db_path) for row_id, plain in texts.items(): db.execute("UPDATE events SET text = ? WHERE id = ?", (fresh.lock(plain), row_id)) for (profile, name), plain in secret_texts.items(): if profile is None: db.execute("UPDATE secrets SET value = ? WHERE name = ?", (fresh.lock(plain), name)) else: db.execute("UPDATE secrets SET value = ? WHERE profile = ? AND name = ?", (fresh.lock(plain), profile, name)) for (profile, name), plain in secret_metas.items(): if profile is None: db.execute("UPDATE secrets SET meta = ? WHERE name = ?", (fresh.lock(plain), name)) else: db.execute("UPDATE secrets SET meta = ? WHERE profile = ? AND name = ?", (fresh.lock(plain), profile, name)) for row_id, plain in planned_texts.items(): db.execute("UPDATE schedules SET prompt = ? WHERE id = ?", (fresh.lock(plain), row_id)) for (key, scope), (plain, was_sealed) in setting_texts.items(): db.execute("UPDATE settings SET value = ? WHERE key = ? AND scope = ?", (fresh.lock(plain) if was_sealed else plain, key, scope)) for name, (plain, was_sealed) in mcp_texts.items(): db.execute("UPDATE mcp_servers SET headers_json = ? WHERE name = ?", (fresh.lock(plain) if was_sealed else plain, name)) db.commit() db.close() for path, plain in files.items(): with open(path, "w", encoding="utf-8") as handle: handle.write(fresh.lock(plain)) LOG.event("INFO", "vault", "seal_rotated", "vault seal rotated", meta={"home": config.home}) return fresh def parse_skill_front(lines): front = {} pos = 0 while pos < len(lines): line = lines[pos] if ":" in line and not line.startswith((" ", "\t")): key, value = line.split(":", 1) value = value.strip() if value in (">", "|"): joined = [] pos += 1 while pos < len(lines) and lines[pos].startswith((" ", "\t")): joined.append(lines[pos].strip()) pos += 1 front[key.strip()] = (" " if value == ">" else "\n").join(joined) continue front[key.strip()] = value.strip("'\"") pos += 1 return front def parse_skill_file(path): with open(path, "r", encoding="utf-8") as handle: raw = handle.read() if not raw.startswith("---"): return None end = raw.find("\n---", 3) if end < 0: return None front = parse_skill_front(raw[3:end].strip().splitlines()) name = front.get("name", "") description = front.get("description", "") if not name or not description or not SKILL_NAME_RE.match(name): return None return {"name": name, "description": description, "body": raw[end + 4:].lstrip("\n")} EXTERNAL_SKILL_DIRNAMES = (os.path.join(".claude", "skills"), os.path.join(".agents", "skills")) SKILL_INSTALL_META_DIRNAME = ".installs" def skill_scope_dir(scope, home_dir, project_dir, name=""): """Shared by create_skill and install_skill so both land skills in the same place.""" base = os.path.join(project_dir, ".tai", "skills") if scope == "project" else os.path.join(home_dir, "skills") return os.path.join(base, name) if name else base def default_skill_scope(project_dir): for marker in (".git", ".tai") + EXTERNAL_SKILL_DIRNAMES: if os.path.exists(os.path.join(project_dir, marker)): return "project" return "home" def skill_extra_files(root): extras = [] for sub in ("scripts", "references", "assets"): folder = os.path.join(root, sub) if os.path.isdir(folder): for entry in sorted(os.listdir(folder)): extras.append(os.path.join(root, sub, entry)) return extras def skill_install_meta_path(scope_dir, entry): return os.path.join(scope_dir, SKILL_INSTALL_META_DIRNAME, entry + ".json") def load_skill_install_meta(scope_dir, entry): try: with open(skill_install_meta_path(scope_dir, entry), "r", encoding="utf-8") as handle: return json.load(handle) except (OSError, ValueError): return None def save_skill_install_meta(scope_dir, entry, meta): path = skill_install_meta_path(scope_dir, entry) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as handle: json.dump(meta, handle) def is_external_skill_root(path): return os.path.dirname(os.path.normpath(path)).endswith(EXTERNAL_SKILL_DIRNAMES) def skill_provenance_note(skill): install = skill.get("install") if install: return "[installed %s, %s scope, from %s]" % (install.get("mode", "copy"), install.get("scope", "?"), install.get("source", "?")) if not skill.get("managed", True): return "[external, not managed by tai: %s]" % skill["root"] return "[native]" def discover_skills(home_dir, project_dir): found = {} tai_roots = (os.path.join(home_dir, "skills"), os.path.join(project_dir, ".tai", "skills")) external_roots = (os.path.join(os.path.expanduser("~"), ".claude", "skills"),) + tuple(os.path.join(project_dir, dirname) for dirname in EXTERNAL_SKILL_DIRNAMES) # External roots scan first so tai's own dirs always win on a name collision (dict assignment below overwrites). for base in external_roots + tai_roots: if not os.path.isdir(base): continue managed = base in tai_roots for entry in sorted(os.listdir(base)): path = os.path.join(base, entry, "SKILL.md") if not os.path.isfile(path): continue try: skill = parse_skill_file(path) except OSError: continue if skill is None: continue skill["root"] = os.path.join(base, entry) skill["managed"] = managed skill["install"] = load_skill_install_meta(base, entry) if managed else None found[skill["name"]] = skill return found SKILL_BLUEPRINTS = { "bot-creator": { "hint": "build a dedicated chat bot from a tai.py variant", "scope": "project", "brief": "Deep-research preferred chat-bot features first: transport (Telegram long-poll versus webhook), command routing, per-user allowlists, rate limits, and safe restarts. Also research safe techniques for scripting huge single-file programs: exact-match edits, atomic temp-plus-rename writes, and versioned backups before every reshape. Then copy tai.py to a variant path and reshape the copy into a dedicated bot, editing efficiently and keeping the audit trail intact.", }, "api-client": { "hint": "build a resilient REST/OpenAPI client", "scope": "project", "brief": "Deep-research resilient HTTP client design first: retries with backoff, timeouts, pagination, auth header handling, and error classification. Then write a skill that builds small stdlib-only API clients from a base URL and an endpoint list.", }, "web-researcher": { "hint": "deep research with triangulated sources", "scope": "project", "brief": "Deep-research professional open-source research methodology first: query expansion, source triangulation, recency checks, and claim grading. Then write a skill that turns a question into a sourced brief with confidence levels.", }, "pdf-forms": { "hint": "fill PDF forms and extract field data", "scope": "project", "brief": "Deep-research PDF form handling with freely available tooling first: AcroForm field discovery, filling, flattening, and text extraction. Then write a skill that fills forms and extracts field data from this machine's installed tools.", }, "data-wrangler": { "hint": "reshape CSV, JSON, and SQLite data", "scope": "project", "brief": "Deep-research tabular data reshaping first: CSV dialects, JSON normalization, SQLite import ergonomics, and streaming for large files. Then write a skill that reshapes CSV, JSON, and SQLite inputs using stdlib-only scripts.", }, "home-sysadmin": { "hint": "routine Linux host care and triage", "scope": "home", "brief": "Deep-research routine single-host Linux care first: disk, memory, service health, log triage, and backup verification. Then write a skill that runs a host checkup and reports findings with suggested fixes.", }, } # Default skills, shipped in the file so a bare copy installs them anywhere. # Provenance: deep-research plus seriousdevelopment v1.7.1 from the author's Muse # user skills; git, plan, requirements-clarification, taste from Muse's bundled # set. Adapted for tai where the host leaks through: load_skill (not read_skill), # ask_user (not request_user_input), fork/poll workers, web_fetch (never curl), # single-line SKILL.md descriptions (the frontmatter parser reads bare >/| only), # no em dashes. seriousdevelopment is verbatim upstream. Edit these copies # directly; they ARE the source. Raw strings keep backslashes byte-exact, so no # embedded file may contain a triple-double-quote or end in a backslash. BUNDLED_SKILLS = { "deep-research": { "SKILL.md": r"""--- name: deep-research description: "End-to-end deep research that turns a question into a cited, verifiable report. Use whenever the answer needs many external sources rather than a single lookup: technology/library selection, code and architecture questions, academic or medical literature, market and company facts, policy and standards, and 'what's the latest' or capability/benchmark questions. Routes each sub-question to the most authoritative sources for its domain (GitHub and official docs for code, arXiv/OpenAlex/Semantic Scholar for scholarship, PubMed/Europe PMC for medicine, SEC EDGAR for company facts, vendor and official pages for products), gathers evidence in parallel, triangulates claims, and writes a structured report with dated citations. Trigger on 调研/研究/调查/综述/对比一下 X、帮我查清楚 X、X 的最新进展/评测数据/方案有哪些, and any request for a research report, literature review, landscape survey, or source-backed answer - even when the user does not say the word 'research'." --- # Deep Research Turn a question into a report a reader can trust: every claim traceable to a source, sources chosen for authority rather than convenience, and uncertainty stated instead of hidden. This skill is **self-contained and host-agnostic**. It defines the *method*; it requires no custom agent, plugin, or MCP server. Where it says "spawn workers", use whatever parallel-subagent facility the host provides (see *Running workers*); if none exists, run the same steps yourself, sequentially. ## When to use Use it when the answer must be assembled from **multiple external sources** and needs to be current, comparative, or authoritative: - technical / library / architecture selection, migration paths, "X vs Y" - literature reviews, "what does the research say about X" - medical, clinical, or life-science questions - market, company, competitive, or due-diligence facts - policy, regulation, standards - "what's the latest / what changed recently" about a fast-moving area - any request for a research report, survey, landscape, or source-backed answer Do **not** use it for a single fact ("capital of France"), a quick definition, or when the user explicitly wants a short direct answer - just answer. ## Principles 1. **Primary sources first.** Prefer the thing itself - the paper, the official docs, the repository, the filing, the standard - over articles that describe it. Secondary sources orient and corroborate. 2. **Provenance on every non-obvious claim.** Each such claim carries a source URL and the date it was retrieved; quote the exact supporting text when wording matters. 3. **Triangulate.** A load-bearing claim needs ≥2 independent sources. If only one exists, say so and lower confidence. 4. **Freshness.** For "latest/current/recent" questions, constrain retrieval to a recent window and state the cutoff date. Version- or date-sensitive facts must name the version or date. 5. **Surface disagreement.** When credible sources conflict, present both and explain the difference; never silently pick one or average them. 6. **Separate fact from inference.** Label anything you derived; never present an inference as a fact. 7. **Uncertainty is data.** State what could not be verified and how that bounds the conclusion. 8. **Write in the user's language**, even though this skill is written in English. ## Workflow Before Phase 2 (Route), read `references/source-routing.md`. Before Phase 5, read `references/report-template.md`. Before Phase 6, run `references/quality-checklist.md`. When spawning workers, take their prompt from `references/subagent-briefs.md`. Emit a one-line progress note per phase so a long run stays visible, e.g. `[deep-research] gather (phase 3) - 4 workers, 21 sources`. ### 0 · Preflight Verify retrieval works before planning: in tai this means the `web_search` and `web_fetch` tools, plus `shell` for git and code search. Tai forbids curl and wget via shell: every JSON-API recipe in `references/source-routing.md` is a plain GET, so issue it through `web_fetch` with the same URL and headers. If `web_search` is unavailable or has no provider configured, use the domain APIs in `references/source-routing.md` as the search path rather than relying on scraping search-engine result pages, and say so in the report's method section. For questions about a local codebase, also use code-search and file-read tools. ### 1 · Frame Restate the question in one or two sentences, then: - Decompose it into 3–7 concrete sub-questions that together answer it. - State the deliverable (default: a Markdown report file) and pick a depth (below). - Define what "answered" means - which sub-questions must be covered. - Ask the user **only** if a missing detail would change the whole approach; otherwise state your assumptions and proceed. They asked for a finished report, not a questionnaire. ### 2 · Route Classify the domain(s) and list, per sub-question, the authoritative source families from `references/source-routing.md`. Prefer primary/official sources. Note paywalled sources up front so they do not block later phases. ### 3 · Gather (parallel) Spawn one worker per sub-question or source family (see *Running workers*). Give each worker: the sub-question, the assigned source families, the recency window, and the evidence format from `references/subagent-briefs.md`. Each worker returns an **evidence pack**: ``` - Claim: Source: - <URL> (retrieved YYYY-MM-DD) Quote: "<verbatim supporting text>" Tier: T1 primary | T2 authoritative secondary | T3 preprint/reputable community | T4 unvetted Confidence: high | medium | low ``` Workers return evidence, not prose. They must not pad or speculate. ### 4 · Consolidate Merge the packs into one evidence table; deduplicate. Mark each load-bearing claim **verified** (≥2 independent T1/T2), **single-source**, or **contradicted**. List gaps and send targeted follow-ups until every "answered" item is covered or explicitly marked unanswerable. ### 5 · Synthesize Write the report using `references/report-template.md`: - inline citation next to every non-obvious claim; - facts, analysis, and recommendations visibly separate; - a comparison table when comparing options; - a "Confidence, gaps & disagreements" section. ### 6 · Verify (before delivery) Run `references/quality-checklist.md`. For contested or high-stakes topics, re-fetch a sample of load-bearing citations to confirm they still support the claim, and look for counter-evidence. Fix or annotate anything that fails. Never ship a claim whose cited page does not support it. ### 7 · Deliver - Write the report to a file (default `docs/research/<slug>-<YYYY-MM-DD>.md` unless the user names a location). - Follow it with a 3–6 line summary: strongest conclusion, biggest uncertainty, most trusted sources. - List sources with tier and retrieval date. ## Running workers The method is host-agnostic; the spawning mechanics below are tai's (`fork`/`poll`). - **Spawning:** use the `fork` tool (one call per worker, task plus timeout up to one hour) and collect results with `poll` (each call waits up to two minutes, so poll in a loop until every worker finishes). Pass the worker prompt from `references/subagent-briefs.md` with the placeholders filled in. Tai workers share the machine and tools but get 12 steps, cannot ask the user, and stop nesting after 2 levels: keep briefs tight and self-contained. - **Bound concurrency to 2–3 workers per turn.** Some providers reject a turn containing several parallel tool calls (e.g. "an assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id'"). When there are more sub-questions than that, run them in batches. If a batch errors, relaunch only the failed workers in smaller batches, and if the error persists, do the work yourself sequentially. Tai caps live subagents at 8: never hold more than 8 unfinished `fork`s at once. - **Worker profile:** `fork` accepts a profile; use the current one unless the task needs another identity. - **If `fork` is unavailable**, run the same briefs yourself, sequentially, one sub-question at a time. The evidence format and routing do not change. - Keep workers independent: one worker's conclusions must not frame another's search. ## Depth levels | Depth | Use for | Shape | |---|---|---| | quick | simple, current facts | one gather pass, no independent verification, short report | | standard (default) | most requests | parallel gather + consolidate + inline verification + full report | | deep | contested, high-stakes, or "comprehensive" | multi-round, a dedicated verification worker, explicit disagreement analysis | ## Bounded runs - Announce phase, worker count, and recency window before a long gather. - If a source is rate-limited or paywalled, record it and move on - do not retry indefinitely. - If a sub-question is still unanswered after two rounds, mark it and proceed. ## Why this shape A single search returns undifferentiated snippets; a report needs provenance. The hard part is not fetching - it is knowing the authoritative source for each domain and proving the source actually supports the claim. The routing table and the verification pass exist for exactly that. """, "UPSTREAM.md": r"""# Upstream provenance - Source: https://github.com/wanqingying/debian-railway (path: opencode-config/opencode/skills/deep-research) - Retrieved: 2026-10-11 - License: none declared in upstream repo at retrieval time - Installed verbatim via `muse skills install --scope user`; this file is the only local addition. """, "references/quality-checklist.md": r"""# Quality Checklist Run this in Phase 6, before delivery. It exists because the most common failure of AI research is confident prose whose citations do not actually support it. Fix or annotate every failed item - do not ship silently. ## 1. Citation support - [ ] For every load-bearing claim, open the cited URL and confirm it **directly** supports the claim. - [ ] The claim is not stronger than the source (no "proves" where the source says "suggests"). - [ ] Quotes are verbatim and in context; no truncation that changes meaning. - [ ] Version- and date-sensitive facts name the version/date, and the source is not obsolete. ## 2. Coverage and balance - [ ] Every sub-question defined in Phase 1 is answered, or explicitly listed under "Not established". - [ ] Load-bearing claims have ≥2 independent sources, or are labelled single-source. - [ ] At least one attempt was made to find **counter-evidence** for contested claims. - [ ] No single source is cited for many unrelated claims (citation monopolizing). ## 3. Freshness - [ ] "Latest/current/recent" questions state the retrieval cutoff. - [ ] The freshest authoritative source was used, not the most-indexed older one. - [ ] Deprecated/retracted sources were replaced or flagged (check retraction notices, "superseded by" notes). ## 4. Honesty - [ ] Facts, inferences, and recommendations are visibly separated. - [ ] Uncertainties and gaps are stated where they matter, not buried. - [ ] Source disagreements are shown, not averaged away. - [ ] Tiers are assigned correctly (an article about a paper is not T1). - [ ] No claim rests solely on a T4 source. ## 5. Form - [ ] The report is in the user's language. - [ ] The sections from `report-template.md` are present (TL;DR, Method, Findings, Confidence, Sources). - [ ] Every source in the list has a tier and a retrieval date. - [ ] The file is written where the user expects, and the chat summary names the strongest conclusion and the biggest uncertainty. ## Failure handling - If a citation fails to support its claim: re-source it, downgrade the claim, or delete it - in that order. - If two sources conflict and you cannot resolve it: report both, explain the likely reason (different dates, definitions, populations, methods), and say what would settle it. - If a key sub-question is unanswerable within the time/source budget: say so plainly and recommend how to answer it (which source, dataset, or expert). """, "references/report-template.md": r"""# Report Template Use this structure for the deliverable. Keep it in the user's language. Omit a section only when it is genuinely empty, and say why. ```markdown # <Title - the question, answered in a phrase> **Question:** <the user's question, restated precisely> **Date:** <YYYY-MM-DD> **Depth:** quick | standard | deep **Sources:** <N> (<n1> primary) · **Cutoff:** <date range or "all dates"> ## TL;DR - <3–6 bullets: the conclusions a busy reader needs, each with a citation> - <state the single biggest uncertainty here, not at the end> ## Method - Sub-questions investigated: <list> - Source families used: <e.g. GitHub releases, arXiv, official docs, SEC EDGAR> - What was not available: <paywalls, missing data, rate limits - and how that limits the result> ## Findings ### 1. <Sub-question> <Prose. Every non-obvious claim gets an inline citation [S1]. Keep facts and your analysis visually distinct - mark derived statements clearly.> ### 2. <Sub-question> … ## Comparison <When options are compared. One row per option, one column per decision-relevant criterion. Every cell carries a citation or "unverified".> | Option | Criterion A | Criterion B | Notes | |---|---|---|---| | X | … [S2] | … [S3] | | ## Analysis <What the evidence means together: patterns, trade-offs, the reasoning behind the recommendation. Label this as interpretation, not fact.> ## Confidence, gaps & disagreements - **High confidence:** <claims backed by multiple independent T1/T2 sources> - **Single-source / tentative:** <claims with only one source> - **Contradicted:** <where credible sources disagree, both sides stated> - **Not established:** <what could not be answered, and what would answer it> ## Recommendations <Only if the question implies a decision. Concrete, tied to the criteria above, and honest about the conditions under which the advice would change.> ## Sources | ID | Source | Tier | Retrieved | |---|---|---|---| | S1 | <Title> - <URL> | T1 | <YYYY-MM-DD> | | S2 | … | T2 | … | ``` ## Rules - **Inline citations, next to claims.** A reader must reach a claim and its source together. A bare reference list at the end is not enough. - **Cite the primary source.** If a secondary source points at a paper/dataset/filing, cite that too. - **Dates everywhere.** Report date, source retrieval dates, and the recency cutoff for "latest" questions. - **No orphan claims.** Anything non-obvious without a citation is either cited or moved to Analysis as a labelled inference. - **Length follows the question**, not a page target. Density beats volume; never pad. - **File output**: write to `docs/research/<slug>-<YYYY-MM-DD>.md` unless the user asked for another location or format. """, "references/source-routing.md": r"""# Source Routing Pick sources by **authority for the domain**, not by what a search engine ranks first. This file maps domains to primary sources and gives ready-to-run access recipes. Read it before Phase 2 (Route) and hand the relevant rows to each worker. ## Source tiers | Tier | What it is | Examples | |---|---|---| | **T1 - primary** | The artifact itself; official first-party | source paper, official docs, the repository, the spec, an SEC filing, a vendor's own pricing page, a government site | | **T2 - authoritative secondary** | Peer-reviewed or editorially accountable, but not the artifact | systematic reviews, standards bodies, reputable journalism, textbooks | | **T3 - preprint / reputable community** | Useful but not yet vetted | arXiv/bioRxiv preprints, well-maintained community docs, conference talks | | **T4 - unvetted** | SEO aggregators, unsourced blogs, forum posts, LLM-generated summaries | content farms, most "top 10" listicles, unsourced wiki mirrors | Rules of thumb: - A claim is **verified** when ≥2 independent T1/T2 sources agree. Record the tier on every source. - Use T3/T4 for leads and orientation, then chase the primary source they point to. - Never cite a T4 source as the sole support for a load-bearing claim. ## Domain → where to look | Domain | Go first to | Access | |---|---|---| | Code / libraries / infra | the project's official repo (README, releases, commits, issues/PRs), official docs site, changelog, migration guide, language/stdlib reference | GitHub REST API + ``git``/code search; `web_fetch` for docs | | Package facts | npm, PyPI, crates.io, Maven Central, Hugging Face Hub | registry JSON APIs | | AI/ML papers & methods | arXiv, OpenAlex, Semantic Scholar, Crossref, ACL/NeurIPS/ICML proceedings | APIs below | | Models & capability data | vendor model cards and release notes, Hugging Face model cards, Papers with Code, official benchmark repos (SWE-bench, GAIA, τ-bench, BrowseComp, LiveCodeBench…), leaderboards (LMArena, Artificial Analysis, OpenRouter rankings) | official pages + arXiv tech reports | | Medicine / clinical | PubMed & PMC, Europe PMC, Cochrane reviews, WHO, FDA/EMA labels, ClinicalTrials.gov | E-utilities / Europe PMC APIs | | Life sciences | bioRxiv, medRxiv, UniProt, Ensembl, NCBI | APIs + `web_fetch` | | General scholarship | OpenAlex, Crossref, Semantic Scholar, DOAJ, journal pages | APIs below | | Company / finance | SEC EDGAR (10-K/10-Q/8-K, S-1), company IR and annual reports, exchange filings | EDGAR full-text search + submissions JSON | | Markets & macro | World Bank, IMF, OECD, national statistics offices | open data APIs | | Policy / law / standards | government sites and official gazettes, IETF RFCs, W3C recommendations, ISO/IEC pages | `web_search` → `web_fetch` official source | | Products / pricing | the vendor's own docs, pricing page, and changelog | `web_fetch` | | Security advisories | NVD, GitHub Security Advisories, vendor security bulletins, OSV | APIs + `web_fetch` | | Sentiment / practice | Hacker News, Stack Overflow, Reddit, maintainer blogs | treat as T3/T4; corroborate | **China-specific topics:** prefer official Chinese sources (gov.cn, 行业协会, 公司官网/公告, 招股书) over community reposts. Treat CSDN, 简书, 微信公众号, 未注明出处的知乎回答 as T4 unless they link a primary source. ## Access recipes Use `web_fetch` for HTML and for every JSON recipe below (tai forbids curl and wget via shell). Each recipe is a plain GET: pass the URL to `web_fetch`, headers via its `headers` argument, and read status plus body from the result. Quote URLs; many query values need encoding. **Always check the HTTP status.** `web_fetch` returns the HTTP status alongside the body: treat 4xx/5xx as errors to record, never as "no results". On `403`, add a `User-Agent` header; on `406`/`429`/`503`, wait and retry with backoff (arXiv, Semantic Scholar, and OpenAlex return these intermittently, and anonymous scholarly APIs are best-effort without a key). **GitHub** (a token raises the rate limit far above anonymous; store one with `store_secret` and pass it via the `web_fetch` `auth_secret` argument, or pass an `Authorization: Bearer ...` header): ``` GET https://api.github.com/search/repositories?q=QUERY&sort=stars&order=desc&per_page=20 header Accept: application/vnd.github+json GET https://api.github.com/repos/OWNER/REPO/releases?per_page=10 GET https://api.github.com/repos/OWNER/REPO/commits?per_page=10 ``` Stars, issues, and commit dates are signals, not proof of quality - read the README and release notes. **arXiv** (no key; use the `https` URL as shown). arXiv throttles bursts with `406`, so send a `User-Agent` header and space requests out: ``` GET https://export.arxiv.org/api/query?search_query=all:QUERY&start=0&max_results=20&sortBy=submittedDate&sortOrder=descending header User-Agent: research/1.0 (mailto:you@example.com) ``` **Crossref** (no key): ``` GET https://api.crossref.org/works?query=QUERY&rows=20&select=title,DOI,URL,issued,container-title,author ``` **OpenAlex** (add `&mailto=you@example.com`; anonymous search is often rate-limited, so retry with backoff, and use a free key for volume): ``` GET https://api.openalex.org/works?search=QUERY&per-page=25&sort=publication_date:desc&mailto=you@example.com ``` **Semantic Scholar** (a free key is effectively required from shared/cloud IPs; anonymous calls frequently 429): ``` GET https://api.semanticscholar.org/graph/v1/paper/search?query=QUERY&limit=20&fields=title,year,abstract,url,externalIds,citationCount,venue ``` **PubMed E-utilities** (no key): search IDs, then fetch summaries. ``` GET https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi?db=pubmed&term=QUERY&retmax=20&retmode=json GET https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi?db=pubmed&id=ID1,ID2&retmode=json ``` **Europe PMC** (no key): ``` GET https://www.ebi.ac.uk/europepmc/webservices/rest/search?query=QUERY&format=json&pageSize=20 ``` **ClinicalTrials.gov** (no key; restrict `fields` - full records are large): ``` GET https://clinicaltrials.gov/api/v2/studies?query.term=QUERY&pageSize=10&fields=NCTId,BriefTitle,OverallStatus,StartDate,Phase ``` **Hugging Face Hub** (no key): ``` GET https://huggingface.co/api/models?search=QUERY&sort=downloads&direction=-1&limit=20 ``` **Package registries**: ``` GET https://registry.npmjs.org/-/v1/search?text=QUERY&size=20 GET https://pypi.org/pypi/PACKAGE/json # crates.io returns 403 without a User-Agent header GET https://crates.io/api/v1/crates?q=QUERY&per_page=20 header User-Agent: research (mailto:you@example.com) ``` **SEC EDGAR** (no key; requires a descriptive `User-Agent`): ``` # full-text search across filings GET https://efts.sec.gov/LATEST/search-index?q=%22QUERY%22&forms=10-K header User-Agent: research you@example.com # company submission history (CIK zero-padded to 10 digits) GET https://data.sec.gov/submissions/CIK0000320193.json header User-Agent: research you@example.com ``` **World Bank** (no key): ``` GET https://api.worldbank.org/v2/country/CN/indicator/NY.GDP.MKTP.CD?format=json&per_page=10 ``` **No `web_search`?** Every endpoint above is a plain `GET` served by `web_fetch` (JSON stays raw); use arXiv/Crossref/OpenAlex as a keyword-search fallback and parse the returned body. ## Choosing within a domain - **Comparisons**: gather the same fields for every option - from each option's own primary source, then reconcile. - **"Latest"**: sort by date and record the exact cutoff; a top-ranked page without a date is a red flag. - **Contested topics**: deliberately search for the strongest opposing view (terms like "criticism", "limitations", "replication failure", "retracted"), not just confirming sources. - **Numbers**: tie every figure to its definition, unit, population, and date; note when two sources measure different things. ## Anti-patterns - Citing an article *about* a paper instead of the paper. - One search engine's top results as the whole evidence base. - Undated web pages for time-sensitive claims. - Treating GitHub stars, download counts, or upvotes as quality evidence. - Quoting an abstract as if it were the finding. """, "references/subagent-briefs.md": r"""# Subagent Briefs Copy-paste prompts for the workers described in Phase 3. Fill the `<…>` placeholders and pass the whole block as the subagent's task/prompt. The briefs are written so they work on any host - the text matters, not which agent runs it. Tai workers share the machine and tools: one `fork` per brief serves for retrieval and for shell/API calls alike. Keep each brief self-contained (12-step budget, no follow-up questions). > **Before spawning:** a worker has a fresh context and usually cannot read this skill's directory. So (a) replace `<SOURCE FAMILIES/RECIPES>` with the **actual URLs** from `references/source-routing.md`, not just the family name; and (b) keep the **SOURCE-TIER RUBRIC** line below in every brief so `Tier:` is unambiguous. ## Worker brief - evidence gathering ``` You are one worker in a multi-worker deep-research run. Your only job is to gather and return evidence for ONE sub-question. Do not write a report, do not speculate, do not pad. SUB-QUESTION: <sub-question> SOURCE FAMILIES / RECIPES: <the actual URLs from source-routing.md for this sub-question, with any needed headers> RECENCY WINDOW: <e.g. "published after 2025-01-01" or "any date"> TODAY: <YYYY-MM-DD> SOURCE-TIER RUBRIC: T1 = the artifact itself (paper, official docs, repo, filing, standard); T2 = peer-reviewed or editorially accountable secondary; T3 = preprint / reputable community; T4 = unvetted (SEO, unsourced blogs, forums). Method 1. Start from the most authoritative source listed; go to the primary source, not articles about it. 2. For each useful source, record the exact supporting text, not a paraphrase. 3. Stop when the sub-question is covered by at least two independent sources, or when you have exhausted the assigned sources. Prefer 4–8 high-quality sources over many weak ones. Keep the whole pack under ~60 lines. 4. If sources disagree, keep both and say so - do not resolve it yourself. 5. If a source is paywalled, rate-limited, or returns an error, note it and move on. On 406/429/503, retry once with a short backoff; on 403, retry with a User-Agent header. Return ONLY this structure, one block per source: - Claim: <one sentence this source supports> Source: <title> - <URL> (retrieved <YYYY-MM-DD>) Quote: "<verbatim supporting text>" Tier: T1 primary | T2 authoritative secondary | T3 preprint/reputable community | T4 unvetted Confidence: high | medium | low Then: ## Coverage - Answered: <which parts of the sub-question are covered> - Unanswered / gaps: <what you could not establish, and why> - Disagreements: <conflicting claims, if any> ``` ## Worker brief - verification (use in `deep` runs, Phase 6) ``` You are an independent verifier. You did not gather this evidence and must not trust it. You are given a list of load-bearing claims with their cited URLs. For each: 1. Fetch the cited URL. 2. Decide: does the page actually support the claim as stated? Watch for claims stronger than the source (overreach), outdated versions, and quotes taken out of context. 3. Search for the strongest credible counter-evidence or a newer source that supersedes it. Return, per claim: - Claim: <as written> Verdict: SUPPORTED | PARTIALLY SUPPORTED | NOT SUPPORTED | SUPERSEDED | UNVERIFIABLE Evidence: <what the page actually says, with the relevant quote> Counter-evidence: <if any, with URL> Notes: <overreach, missing context, version/date problems> If a URL is dead, paywalled, or returns an error, use UNVERIFIABLE and record the error; you may check the claim against the abstract or another copy of the same work, and say which. Never mark something SUPPORTED on the basis of a page you could not read. ``` ## Consolidation checklist (main agent, Phase 4) - [ ] Every evidence block has a URL and a retrieval date. - [ ] Load-bearing claims are marked verified / single-source / contradicted. - [ ] Contradictions between workers are surfaced, not averaged away. - [ ] Gaps map to a follow-up worker or an explicit "unanswered" item. - [ ] No T4 source is the sole support for a load-bearing claim. - [ ] Source tiers look right (an article about a paper is T2/T3, not T1). """, }, "git": { "SKILL.md": r"""--- name: git description: Source-control safety for Git. Two rules apply whether or not you read the body. First, never commit, amend, push, tag, rebase, cherry-pick, revert, or reset --hard unless the user asked for that exact write in this session. An explicit request to create a new commit counts anywhere in the user's own task text, but it is not a request to amend, push, or tag. Finishing a task without that request is not authorization, so leave your work uncommitted for review. Second, when a Git lock file or corrupt index blocks you, never kill the holding process or bulldoze through with deletions - wait, retry, use the holder's own stop mechanism, or stop and report. Load the body only before writing Git history or recovering Git state. user-invocable: false --- # Git source-control safety The working tree belongs to the user. Make the change, leave it visible, and let them decide what becomes history. ## Never without an explicit ask The user must have asked for that exact write in this session, in their own words. An explicit request to create a new commit counts anywhere in the user's own task text, but it is not a request to amend, push, or tag. Finishing a task without that request does not authorize a history write. - `commit`, `commit --amend`, `push`, `tag`, `rebase`, `cherry-pick`, `revert`, `merge`, branch deletion, `reset --hard`, path restore, and `clean -f` all require an explicit ask. - Never publish merely because publication would be useful. ### Authorization in tai In tai, the session's approval setting and standing rules are the explicit ask: when the user configured automatic commits, tags, or pushes (standing rules, `--yes`/`--auto`), those writes are pre-authorized and this skill does not veto them. It still governs everything else on this page: save a recovery snapshot before an authorized discard, never bulldoze locks, name files explicitly, never broaden the authorized operation. ## Before an authorized discard Recovery is insurance, not authorization. A save never expands what the user allowed you to delete or overwrite. When the user explicitly authorized an operation that may discard or broadly overwrite workspace changes, and the workspace is an ordinary Git checkout: 1. After `load_skill` lists this skill's bundled files, set `<recovery>` to the absolute `scripts/workspace-recovery.sh` path from that list and run: ```bash bash <recovery> save before-discard ``` 2. Keep the printed `refs/tbh/recovery/...` value in your handoff, then perform only the exact authorized operation. 3. If the save fails or the workspace is unsupported, stop before destruction and ask the user. To recover that saved tree later, run: ```bash bash <recovery> restore <recovery-ref> ``` Restore first saves the current workspace, leaves HEAD, branch, and the real index in place, and restores the selected snapshot into the worktree. The ref also retains distinct staged bytes when the same path had later worktree edits. Read those staged bytes with `git show '<recovery-ref>^2:<path>'`; the recovery commit's second parent is the saved index tree. Ignored files are not saved; restore refuses an ignored-path collision rather than overwrite data it could not save. Use only the exact printed ref, not a revision expression. ## Always fine Read-only inspection such as `status`, `log`, `show`, `diff`, `blame`, `branch -v`, `show-ref`, `rev-parse`, `for-each-ref`, and `merge-base` is fine. Staging named files to inspect `diff --cached`, and a temporary stash around a tool that requires a clean tree, are also fine when the original state is put back afterward. ## The rest - Do not use a broad add in a tree you did not clean; name the files you changed. - Do not initialize a repository inside vendored, build, data, or existing source-control trees. - Do not create or edit `.gitignore` or `.git/info/exclude` to hide the user's files, and do not stash the user's files (including `stash push -u`) to make `git status` look clean. "Leave the working tree clean", "tidy up", and similar phrasing do not authorize committing, pushing, ignoring, excluding, stashing, or deleting the user's files; clean up only scratch you created and say the user's files remain. - Do not hand-edit generated files; change their source of truth. - Scratch repositories under a temporary directory are yours to experiment in. ## Locks and corrupt state A Git lock usually means another process is writing. Never kill the holder on your own initiative. Wait, inspect the holder read-only, use that tool's normal stop mechanism, or report the block. Never delete a lock without proving no live process owns it, and never respond to index corruption by deleting state and committing anyway. Never commit or push while status reports changes you did not make and cannot explain. ## If you already made an unwanted write Say so plainly and stop. Do not attempt more history surgery without direction. """, "scripts/workspace-recovery.sh": r"""#!/usr/bin/env bash set -euo pipefail unset GIT_INDEX_FILE usage() { echo "usage: workspace-recovery.sh save [label] | restore <recovery-ref>" >&2 exit 2 } die() { echo "workspace recovery: $*" >&2 exit 1 } command -v git >/dev/null 2>&1 || die "git is unavailable" root=$(git rev-parse --show-toplevel 2>/dev/null) || die "not an ordinary Git workspace" head=$(git -C "$root" rev-parse --verify HEAD 2>/dev/null) || die "unborn HEAD is unsupported" [ -z "$(git -C "$root" ls-files -u)" ] || die "an unmerged index is unsupported" saved_ref= write_recovery_commit() { local message=$1 tree=$2 shift 2 printf '%s\n' "$message" | GIT_AUTHOR_NAME="TBH Recovery" \ GIT_AUTHOR_EMAIL="recovery@localhost" \ GIT_COMMITTER_NAME="TBH Recovery" \ GIT_COMMITTER_EMAIL="recovery@localhost" \ git -C "$root" commit-tree "$tree" "$@" } save_snapshot() { local label=${1:-checkpoint} local alternate_index index_entries real_index index_tree index_commit tree commit timestamp local entry mode path head_entry head_mode case "$label" in ''|*[!A-Za-z0-9._-]*) die "label must use only letters, digits, dot, underscore, or dash" ;; esac timestamp=$(date -u +%Y%m%dT%H%M%SZ) saved_ref="refs/tbh/recovery/$label/$timestamp-$$" git check-ref-format "$saved_ref" >/dev/null 2>&1 || die "label does not form a valid recovery ref" alternate_index=$(mktemp "${TMPDIR:-/tmp}/tbh-recovery-index.XXXXXX") index_entries=$(mktemp "${TMPDIR:-/tmp}/tbh-recovery-entries.XXXXXX") rm -f "$alternate_index" trap 'rm -f "$alternate_index" "$index_entries"' EXIT trap 'exit 130' HUP INT TERM real_index=$(git -C "$root" rev-parse --git-path index) case "$real_index" in /*) ;; *) real_index="$root/$real_index" ;; esac cp "$real_index" "$alternate_index" index_tree=$(GIT_INDEX_FILE="$alternate_index" git -C "$root" write-tree) index_commit=$( write_recovery_commit "TBH recovery index: $label" "$index_tree" -p "$head" ) rm -f "$alternate_index" GIT_INDEX_FILE="$alternate_index" git -C "$root" read-tree "$head" GIT_INDEX_FILE="$alternate_index" git -C "$root" add -A -- . GIT_INDEX_FILE="$alternate_index" git -C "$root" ls-files --stage -z >"$index_entries" while IFS= read -r -d '' entry; do mode=${entry%% *} [ "$mode" = 160000 ] || continue case "$entry" in *$'\t'*) path=${entry#*$'\t'} ;; *) die "cannot inspect saved Git links" ;; esac head_entry=$(GIT_LITERAL_PATHSPECS=1 git -C "$root" ls-tree "$head" -- "$path") head_mode=${head_entry%% *} [ "$head_mode" = 160000 ] || die "cannot save untracked embedded Git repository: $path" done <"$index_entries" tree=$(GIT_INDEX_FILE="$alternate_index" git -C "$root" write-tree) commit=$( write_recovery_commit "TBH recovery worktree: $label" "$tree" -p "$head" -p "$index_commit" ) git -C "$root" update-ref "$saved_ref" "$commit" "" rm -f "$alternate_index" rm -f "$index_entries" trap - EXIT HUP INT TERM } collect_one_restore_collision() { local path=$1 leaf=$2 candidates=$3 directory_candidates=$4 if [ ! -e "$root/$path" ] && [ ! -L "$root/$path" ]; then return fi if [ "$leaf" = false ] && [ -d "$root/$path" ] && [ ! -L "$root/$path" ]; then return fi if [ "$leaf" = true ] && [ -d "$root/$path" ] && [ ! -L "$root/$path" ]; then printf '%s\0' "$path" >>"$directory_candidates" fi printf '%s\0' "$path" >>"$candidates" } refuse_unsafe_restore_collisions() { local recovery_commit=$1 collision_dir path_list index_paths candidates directory_candidates local ignore_candidates ignore_matches path ancestor previous_path index_path index_eof local directory_path directory_eof tracked_exact leaf_directory ignore_status ignored_path local first_candidate LC_ALL=C collision_dir=$(mktemp -d "${TMPDIR:-/tmp}/tbh-recovery-collisions.XXXXXX") path_list="$collision_dir/recovery-paths" index_paths="$collision_dir/index-paths" candidates="$collision_dir/collision-candidates" directory_candidates="$collision_dir/directory-candidates" ignore_candidates="$collision_dir/ignore-candidates" ignore_matches="$collision_dir/ignore-matches" : >"$candidates" : >"$directory_candidates" : >"$ignore_candidates" trap 'rm -rf "$collision_dir"' EXIT trap 'exit 130' HUP INT TERM git -C "$root" ls-tree -r --name-only -z "$recovery_commit" >"$path_list" git -C "$root" ls-files -z >"$index_paths" previous_path= while IFS= read -r -d '' path; do ancestor=$path while [[ "$ancestor" == */* ]]; do ancestor=${ancestor%/*} case "$previous_path" in "$ancestor"/*) continue ;; esac collect_one_restore_collision "$ancestor" false "$candidates" "$directory_candidates" done collect_one_restore_collision "$path" true "$candidates" "$directory_candidates" previous_path=$path done <"$path_list" LC_ALL=C sort -zu "$candidates" -o "$candidates" LC_ALL=C sort -zu "$directory_candidates" -o "$directory_candidates" LC_ALL=C sort -zu "$index_paths" -o "$index_paths" index_path= index_eof=false exec 9<"$index_paths" if ! IFS= read -r -d '' index_path <&9; then index_eof=true fi directory_path= directory_eof=false exec 8<"$directory_candidates" if ! IFS= read -r -d '' directory_path <&8; then directory_eof=true fi while IFS= read -r -d '' path; do while [ "$index_eof" = false ] && [[ "$index_path" < "$path" ]]; do if ! IFS= read -r -d '' index_path <&9; then index_eof=true index_path= fi done tracked_exact=false if [ "$index_eof" = false ] && [ "$index_path" = "$path" ]; then tracked_exact=true fi while [ "$directory_eof" = false ] && [[ "$directory_path" < "$path" ]]; do if ! IFS= read -r -d '' directory_path <&8; then directory_eof=true directory_path= fi done leaf_directory=false if [ "$directory_eof" = false ] && [ "$directory_path" = "$path" ]; then leaf_directory=true fi [ "$tracked_exact" = true ] && continue [ "$leaf_directory" = false ] || die "restore would replace an untracked directory: $path" printf '%s\0' "$path" >>"$ignore_candidates" done <"$candidates" exec 9<&- exec 8<&- if [ -s "$ignore_candidates" ]; then if git -C "$root" check-ignore -z --stdin --no-index \ <"$ignore_candidates" >"$ignore_matches"; then if IFS= read -r -d '' ignored_path <"$ignore_matches"; then die "restore would overwrite an ignored path: $ignored_path" fi die "cannot verify ignore rules for restore paths" else ignore_status=$? if [ "$ignore_status" -ne 1 ]; then if ! IFS= read -r -d '' first_candidate <"$ignore_candidates"; then first_candidate="restore paths" fi die "cannot verify ignore rules for: $first_candidate" fi fi fi rm -rf "$collision_dir" trap - EXIT HUP INT TERM } case ${1:-} in save) [ "$#" -le 2 ] || usage save_snapshot "${2:-checkpoint}" printf 'saved %s\n' "$saved_ref" ;; restore) [ "$#" -eq 2 ] || usage recovery_ref=$2 case "$recovery_ref" in refs/tbh/recovery/*) ;; *) die "restore requires a refs/tbh/recovery/... ref" ;; esac git check-ref-format "$recovery_ref" >/dev/null 2>&1 || die "restore requires an exact recovery ref" recovery_oid=$(git -C "$root" show-ref --verify --hash "$recovery_ref" 2>/dev/null) || die "recovery ref does not name a commit" recovery_commit=$(git -C "$root" rev-parse --verify "$recovery_oid^{commit}" 2>/dev/null) || die "recovery ref does not name a commit" refuse_unsafe_restore_collisions "$recovery_commit" save_snapshot before-restore printf 'saved current workspace to %s\n' "$saved_ref" git -C "$root" restore --source="$recovery_commit" --worktree -- . printf 'restored %s\n' "$recovery_ref" ;; *) usage ;; esac """, }, "plan": { "SKILL.md": r"""--- name: plan description: On an explicit planning request, always load this skill (load_skill) before answering. Research first, then return one concise inline plan; do not create a plan file unless the user explicitly asks or a governing workflow requires one. Start and end the reply by saying this is not a special mode and `go` executes the plan; do not implement in the planning turn. For ordinary implement, build, fix, debug, or refactor requests, work directly. When an explicit plan divides work into separate diffs, commits, or PRs and the user asks to publish, preserve that split and tell the user before deviating. --- # Plan Create a grounded, decision-complete plan before complex work. ## Scope - Use this skill ONLY when the user explicitly asks to plan - they request a plan, design, approach, rollout or migration strategy, or PR breakdown, or invoke the plan skill directly (load_skill). - Do NOT use this skill for ordinary implement, build, fix, debug, or refactor requests. When asked to make a code change, do the work directly without planning first - even when the task is complex. Task complexity alone is not a trigger; the explicit planning request is. - Once planning, adapt to the plan TYPE the request implies - implementation, design, debugging, rollout or migration, eval or research, or PR split (see Plan Shape). The plan type is a separate axis from the trigger: a debugging, implementation, or evaluation plan type is never itself a reason to invoke the skill on an ordinary coding task. - An explicit invocation ("plan this") always ends in a plan and never in the change itself, however small the change: a user who asks for a plan for a one-line edit wants the plan, not the edit. Only when the skill was offered automatically, skip it for simple one-step edits, obvious bug fixes, formatting, copy changes, and direct questions that can be answered without planning. - Treat this as planning guidance, not a host-enforced mode. Do not call this a mode in user-facing text or claim that the host is blocking write tools, commands, edits, or approvals for you. - Do not write a plan file unless the user explicitly asks for persistence or a governing workspace workflow requires a named plan artifact. You may write at most one such markdown file. - Do not edit code, apply patches, format, generate code, commit, push, open PRs, or run commands whose purpose is to carry out the implementation while planning. - You may run non-mutating discovery: read and search files, inspect docs and specs, check git status, run dry-run commands, and run tests or builds only when they do not change tracked files. - Resolve facts that can be discovered locally before asking the user. - Ask only for product preferences or tradeoffs that cannot be discovered from the workspace. ## Research Before Drafting For an explicit plan, follow these steps in order. Do not write plan prose or a plan file until the applicable research in steps 1-5 is complete: 1. **Map the decisions.** Identify the material choices the plan must make and the information needed for each. Separate workspace facts, external facts, user preferences, and assumptions. 2. **Research workspace facts.** Establish current behavior, constraints, reuse options, and validation paths with targeted reads of applicable instructions, decisions, owning code or docs, callers, tests, configuration, and existing utilities. Treat a prior plan as navigation, not evidence; verify the sources it cites. 3. **Ask only when necessary.** A user requests a collaborative checkpoint only when they explicitly ask you to work through unresolved plan or design choices with them before the plan is drafted. Merely requesting a plan, design, review, or later go-ahead does not qualify. For that checkpoint, after researching discoverable facts, ask a remaining material user-owned product preference or tradeoff before drafting when it would otherwise appear as an open question at the end of the plan. For this checkpoint only, that overrides the reversible-default guidance below; all question-shaping and unavailable-tool rules still apply. Otherwise follow the ordinary rule below. Before using a question tool, research any factual unknown that could eliminate the question; this may include bounded, preference-independent external research. Do not ask merely because scope, platform, or experience is unspecified. Ask only when a remaining user-owned preference cannot be resolved from the request, workspace, or research and its answer would materially change the research or recommendation, or avoid substantial rework. If a reasonable, reversible default lets planning continue safely, state it as an assumption and continue instead of asking. Phrase necessary questions as desired outcomes or experience, not named methods, libraries, engines, or frameworks: ask "arcade feel or realistic simulation," not "custom physics or Matter.js." Research and recommend the technical implementation yourself. When input is necessary, ask only one necessary question per turn. Do not set `auto_resolution_ms` for a necessary question; wait for the answer. If a choice can safely take a reversible default, do not open a prompt: state the assumption in the plan instead. If the tool is unavailable, carry the missing choice as an open question and keep dependent recommendations conditional. Ask through the `ask_user` tool (question plus options); it blocks until the user answers, so finish independent work first and ask last. `ask_user` is unavailable to background workers and in autonomous mode: there, state the assumption in the plan instead of asking. 4. **Research external facts when they matter.** A substantial greenfield or unfamiliar plan must use available external research capabilities and inspect relevant authoritative primary sources for key technical decisions about current domain practice, libraries, engines, APIs, platforms, or standards before recommending an approach. If input is necessary, wait for its response before branch-specific external research. Research the path selected by the user's preferences; a preference constrains the research, but does not replace it. Source discovery is not source inspection. Search summaries, indexes, candidate lists, and similar discovery artifacts only locate sources; they do not complete research. Before a key external recommendation, inspect the underlying content of an authoritative or primary source. Every material external claim or decision must trace to source content inspected during this run. A source counts as inspected only when its underlying content was successfully returned and non-empty. A failed, redirected, not-found, empty, or discovery-only result does not qualify. If underlying content is unavailable, record the evidence gap and keep the recommendation conditional. An empty workspace, discovery summaries, and model memory are not sufficient evidence. Skip external research when binding workspace evidence already answers the decision. Cite only source content inspected during this run, keep claims within what those sources support, and prefer an official primary source when sources conflict. Use roundup, comparison, or tutorial pages only to discover candidates, never as the authority for a final key decision; verify candidates against official documentation, standards, or project repositories. Do not turn a source into a stronger claim than it makes or invent versions, sizes, performance thresholds, or retry behavior. 5. **Close delegated research.** Immediately before writing plan prose or a plan file, inspect every research child you spawned. A `poll` that reports running is not a terminal child state. While any child remains pending, keep polling until it finishes. Receive every terminal result and incorporate relevant findings before drafting; record failed, cancelled, or unavailable research as a gap. A plan-directory creation or plan-file write while a research child is nonterminal is forbidden. If a child is abandoned, record its evidence gap before drafting. 6. **Synthesize, then draft.** Immediately before drafting, build a private decision-evidence map. Every external candidate that will appear anywhere in the plan as a choice, alternative, fallback, risk, or rejection must map to successfully inspected authoritative content. Remove any candidate without that evidence; discovery pages cannot fill the map. Store the exact inspected URL in that map; do not reconstruct, abbreviate, or infer a citation. Only then recommend the approach. Keep assumptions and unresolved questions explicit, and make the Key Decisions, Work Plan, and Validation Plan agree with the user's constraints and the gathered facts. Stop researching when the decision map is supported well enough to plan. Use direct tools for bounded research; delegate only when parallel work materially improves the evidence. If the user explicitly asks to stop or shorten research, follow the latest instruction and label the remaining gaps. ## Quality Bar A plan is ready only when it is: - **Grounded**: cite the user goal, current facts, docs, nearby code, tests, commands, logs, specs, issues, or PRs that shaped the plan when those sources exist. Mark guesses as assumptions. - **Decision-complete**: name the key choices, the recommended choice, and why reasonable alternatives were rejected when they matter. - **Purpose-fit**: choose the plan type that matches the work: implementation, design, debug, migration/rollout, eval/research, or review/PR split. - **Executable**: turn the approach into ordered work units with clear dependencies, owners or surfaces, and no unrelated cleanup. - **Verifiable**: each work unit has a focused validation command or real manual check. Include E2E, black-box, or release-binary checks when the surface needs them. - **Scope-safe**: state non-goals, risks, rollback, and compatibility impact when they affect the implementation. - **Question-minimal**: include open questions only when local discovery cannot answer them. - **Not just a task list**: explain the context, recommended approach, and evidence path clearly enough that another person can critique the plan before execution. ## Explicit File Output Deliver the plan inline by default. Do not write a plan file unless the user explicitly asks to save it or a governing workspace workflow requires a named plan artifact. Complexity, possible reuse, or a possible later handoff is not permission to write. When persistence is authorized: - If the user gives a path or the governing workflow names one, use that path. - If the workspace has an established durable plan location, follow it. Prefer, in order: an active `specs/<feature>/plan.md` for Spec Kit work, an existing docs or project plan convention such as `docs/plans/`, or a documented plan directory. - If saving and no stronger convention exists, save to `.agents/plans/YYYY-MM-DD-<slug>.md`. - Create parent directories as needed. - You may update an active `specs/<feature>/plan.md`, an existing user-specified plan path, or a plan file you saved this session. Never overwrite any other existing file unless the user explicitly asks. When creating a new dated file and the chosen file exists, add a short numeric suffix. - Do not use `/tmp` for the final plan. Temporary scratch is not a durable user artifact. ## User-Visible Delivery Start the final reply with this exact sentence on one line so the next action stays visible even when a long plan is collapsed: This is a plan, not a special mode; I haven’t started implementation. Reply `go` to execute this plan, or tell me what to change. Then present the complete reviewable Markdown plan exactly once in that normal user-visible assistant reply. Saving or rereading a plan file is not presenting it. Use one canonical Markdown plan body. Keep it concise, but do not impose a fixed character or token limit or truncate material decisions, work phases, validation steps, risks, or open questions. Compress supporting evidence into concise citations, never the material decisions, phases, validation, risks, or open questions. If you save a plan file, its content must be exactly the canonical body. Copy the canonical body verbatim into the normal assistant reply. Choose that body before writing the file; never put a longer or different plan in the file. Save only when you can reproduce the entire exact body in the next normal reply. If you cannot, skip the file and deliver the complete plan inline. Do not create two independently expanded versions of the plan. If you save the plan, put its path after the visible plan. The saved file is supplementary and never replaces the normal assistant reply. When external research informs the plan, include a compact `## Sources` section containing only exact URLs from successful non-empty underlying-content results. Each material external decision must cite one of those URLs. ## Conversational Handoff Before delivery: - Audit the saved plan through complete readback and correction until it is stable; do not present the plan during this audit loop. Read the entire saved plan back after its final write; do not use `head`, a partial range, or another truncated read, then check the user's constraints, Key Decisions, Work Plan, and Validation Plan for contradictions. - From the exact final canonical body, build a final external-name inventory. Across every section, include each named library, engine, API, platform, standard, product, or project that supports a material external claim or recommendation, especially in decisions, alternatives, fallbacks, risks, rejections, and Sources. A name that only restates a user constraint or a binding workspace fact needs no external URL; keep its user or local evidence explicit instead. Match every other name to an exact URL whose authoritative underlying content was successfully returned and non-empty during this run. Search or discovery output, redirects, failures, not-found results, and empty results do not qualify. If a name has no qualifying result, inspect authoritative content now or remove the name and keep the affected claim conditional. Do not deliver until every inventory entry is resolved. - Audit each material external decision against its matched source. Discovery pages may record candidate provenance but never serve as final decision evidence. Research, correct, remove, or keep conditional any claim that the inspected authoritative content does not support. - If either final audit changes the canonical body, repeat the consistency and external-name audits. When a file is saved, rewrite it and read the entire saved plan again first. Deliver only after one complete pass makes no changes. - Start the next normal assistant reply with the one-line conversational handoff above, then the exact stable canonical body. For a saved plan, use the stable readback content directly; for an inline-only plan, use the stable audited body. Do not regenerate, shorten, summarize, or omit any part. Put the saved path, when one exists, after the complete body. After delivery: - Do not use question tools for the final handoff. They remain reserved for necessary pre-draft user-owned choices in Research step 3. - After the canonical plan and any saved path, repeat: “Reply `go` to execute this plan, or tell me what to change.” - Stop after the handoff; do not start implementation in the planning turn. - On `go`, execute the preceding plan directly without regenerating it. On a change request, revise and present the complete plan again. ## Plan Shape Write the plan so the next person can act without guessing. Use the smallest subset of this shape that carries the decisions needed for execution: ```markdown ## Goal ## Success Criteria ## Approach ## Steps ## Validation Plan ## Risks / Open Questions (only when material) ``` Write `None` for open questions only after checking the repo for answers. Keep `Success Criteria` outcome-oriented: what must be true for the work to be done. Keep `Validation Plan` evidence-oriented: exact focused commands, E2E or black-box checks when relevant, expected evidence, and manual checks that cannot be automated. Adapt the sections to the plan type: - For implementation work, `Steps` should name the code surfaces, data flow, compatibility impact, existing utilities to reuse, and test strategy. Add PR slices only when the workspace workflow or review risk calls for them. - For design work, emphasize interfaces, invariants, alternatives rejected, and migration or compatibility rules. - For debugging work, list hypotheses, observations needed, instrumentation or logs to inspect, reproduction steps, and the evidence that will confirm or falsify each hypothesis. - For rollout or migration work, include phases, gates, rollback, data safety, monitoring, and user-visible impact. - For eval or research work, include the benchmark/question, corpus or sources, comparison arms, success metrics, confounders, and reproducibility evidence. Do not invent an issue, spec, PR, or file path just to fill a section. If the workspace rules require them, cite the real item or state the missing prerequisite as an open question or blocker. Do not repeat background the user already knows or add empty sections. Keep the plan decision-complete enough that an implementer does not need to invent scope, interfaces, or verification. ## Workflow 1. Classify whether planning is actually needed. If the task is simple AND the skill was offered automatically, say so and answer or implement under normal rules instead. On an explicit invocation, plan it regardless of size. 2. Complete Research Before Drafting when it applies. Ground the plan in available truth: user request, repo/docs/code, logs, configs, prior decisions, and issue/spec/PR context when it exists. 3. Identify the plan type and the smallest viable path that proves the approach. 4. Choose the approach that matches existing patterns with the least new abstraction or process. 5. Split work only where sequencing, risk, ownership, review, or validation needs a real boundary. 6. Map every work unit to validation evidence or a real manual check. 7. If the user or workflow requested a saved plan, briefly report its path. Do not announce the absence of a file for the normal inline case. 8. Highlight the highest-risk validation step. 9. Present the plan in the User-Visible Delivery form and stop for the user's ordinary next message before implementation. ## Exit - After generating the plan, present the conversational handoff, the complete canonical Markdown, and the repeated handoff in one normal assistant reply. - Stop for the user's ordinary next message. Do not treat planning text as permission to edit. - If the user says go, continue, implement, or otherwise starts execution, use the preceding plan directly: leave planning and do the work under the normal workspace rules, carrying the plan's structural commitments (see Executing The Plan). - Re-check the latest user message before editing so stale plan assumptions do not override a new instruction. ## Executing The Plan These rules bind during execution whether the plan came from this skill or was supplied by the user, and whether or not this skill was ever invoked. If you loaded this skill at publish time, only this section applies - the planning restrictions above do not. - A plan's structural commitments survive into execution: the diff/commit/PR split and the unit ordering stay binding while the work is carried out and published. - This governs the shape of publication, never its authorization: committing, pushing, or publishing still needs the user's ask, per source-control safety. A plan step that says "commit" does not by itself authorize a commit. - When the user has you publish work the plan divides into N separate diffs, commits, or PRs, publish exactly that split: commit each planned unit separately, in the planned order, even when one combined commit would satisfy the literal request ("commit this", "publish it"). - Work already finished in one mixed working tree still gets split at publish time: group the changed files by plan unit and commit unit by unit. If one file's changes span units, split at the hunk level (`git add -p`) or assign the file to the earliest unit and say so. - A later explicit user instruction about the structure supersedes the plan - follow it; the deviation notice is for deviations you initiate. - Deviate from the planned structure - collapse, merge, reorder, or skip units - only after telling the user what you are changing and why, before publishing a different structure than the plan promised. """, }, "requirements-clarification": { "SKILL.md": r"""--- name: requirements-clarification user-invocable: false description: Clarify a material user-owned product decision before substantive implementation commitments. Use only when request and named/governing evidence still leave a choice that would materially change scope, platform support, user-facing surface, data/API contract, domain values, project placement, or cross-system architecture; also use when a truncated request omits such a choice. On the initial turn, inspect only named or governing requirements evidence, then ask directly without loading: ask one blocking outcome question or at most three coupled questions; wait. After the answer, load the body before write or delegation. Do not use for implement/build/fix/debug/refactor work when expected behavior is settled, plan/design deliverables owned by Plan, complexity/greenfield/integration alone, agent-owned technical choices, locally discoverable facts, reversible defaults, permission to begin, or stop/no-tool turns. Never re-ask supplied facts or turn clarification into refusal. --- # Requirements Clarification Resolve only a remaining user-owned product decision. Do not turn normal implementation uncertainty into a question. 1. Read the request and any explicitly named or governing requirements source. Do not scan broadly for hypothetical ambiguity. If this evidence settles the outcome, proceed without asking. 2. Separate user-owned outcomes and authoritative inputs from agent-owned implementation choices. Research discoverable facts yourself. Never ask the user to choose a library, framework, or method the agent can determine. 3. If a narrow reversible default safely satisfies the request, state it and proceed. Complexity, greenfield status, file count, or integration work alone never requires clarification. 4. Otherwise ask before substantive commitment. Reads, notes, or one small branch-neutral probe are allowed; a scaffold, public contract, embedded domain values, or cross-system architecture is a commitment. 5. Ask one blocking outcome question; group at most three tightly coupled questions. Offer concrete outcome options and a recommendation. Never ask merely for permission to begin. Ask through the `ask_user` tool (question plus options); it blocks until the user answers. `ask_user` is unavailable to background workers and in autonomous mode: there, decide yourself and state the assumption instead of asking. 6. After the answer, do not ask again unless later evidence reveals a distinct material fork. Apply the answer and continue. `plan` owns standalone plan and design deliverables. This skill does not create an approval gate or stop a specified implementation merely because planning would be useful. Never re-ask facts already supplied by the request or governing evidence, and never turn clarification into a refusal. """, }, "seriousdevelopment": { "CHANGELOG.md": r"""<!-- doc: CHANGELOG.md | version: 1.7.1 | updated: 2026-10-11 --> # Changelog (seriousdevelopment skill) | Version | Date | Change | |---------|------|--------| | 1.0.0 | 2026-10-11 | Initial skill: modes A/B, method, 6 templates | | 1.1.0 | 2026-10-11 | Added CHANGELOG + AGENTS.md templates; scoped runs; Mode A self-grade loop; verification mechanics; skill changelog; clarified mode mixing | | 1.2.0 | 2026-10-11 | Added "Writing decent software" (shape, one-way, data, daily loop, testing, ops, versioning, agent docs distilled from project playbooks) | | 1.3.0 | 2026-10-11 | Added house stack defaults (FastAPI route tree, Jinja2, SQLite via dataset, plain ES modules, mirrored tests) | | 1.4.0 | 2026-10-11 | Added logging standards (debug-by-default, rotation caps) and backup procedures (3-2-1, restic default, tested restores) | | 1.5.0 | 2026-10-11 | Added secrets hands-off rule, dependency minimalism, DTAP coexistence, incident response, minimum CI, migration discipline | | 1.6.0 | 2026-10-11 | Enforced DRY (hoist on second use), consistency-first with existing code, object-oriented modeling | | 1.7.0 | 2026-10-11 | Required Podman/Debian container + compose, self-sufficient Makefile, traditional pyproject.toml with declared console binary | | 1.7.1 | 2026-10-11 | Added author comment to SKILL.md frontmatter | """, "SKILL.md": r"""--- # Author: retoor <retoor@molodetz.nl> name: seriousdevelopment description: Scaffold a new project or upgrade an existing one to seriousdevelopment quality (spec-first docs, ADRs, build track sheet, verification gates, living-docs discipline). Use when starting a greenfield project or when an existing project needs its engineering and documentation bar raised. --- # Serious Development One method for building software seriously, whether the project is new or already exists. The method has three pillars: a canonical spec with zero open gaps, a decision + tracking system that survives multi-month and multi-model builds, and mechanical verification after every change. Skill version: 1.7.1 (history in `CHANGELOG.md`). ## Modes Ask which mode applies if it is not obvious from the request. Do not run both modes in one run. (Mode B itself is audit-then-apply; that is one mode, not mixing.) For partial work, see Scoped runs. ### Mode A: new project Scaffold the project skeleton, then write the spec before any product code. Minimum skeleton: - `SPEC.md` (canonical spec, from `assets/templates/SPEC.md`) - `BUILD-TRACK.md` (from `assets/templates/BUILD-TRACK.md`) - `docs/adr/` with `0001` recording why this method was adopted - `docs/runbooks/` (empty, indexed in the spec runbook list) - `CHANGELOG.md` (spec + product changes, ascending versions) - `roadmap/` only once the spec passes 500 lines (milestone split, see "Roadmap split" below) - `AGENTS.md` (or the harness equivalent) in three layers: personal base rules, root project rules, nested subsystem files next to the code Fill the spec top-down: control + changelog, goals/non-goals, scope, requirements with normative IDs, architecture, data model, API, security, compliance, ops, estimates, risks, decisions. No product code until the spec has zero TBDs (see "No open gaps"). Then grade the spec A-F on: completeness, consistency, testability, estimation honesty, decision traceability. Fix everything found without deleting or degrading content, re-run the mechanical checks, and report before/after grades. Below A- overall, say so and name what is missing. ### Mode B: upgrade an existing project 1. **Audit, read-only.** Inventory: existing docs, rules files, stack, tests, CI, versioning, changelog habits. Write nothing yet. 2. **Gap report.** List what is missing against the checklist in "Upgrade checklist" below. Grade the project A-F on: spec completeness, decision traceability, dev tracking, verification, doc freshness, estimation honesty. 3. **Apply, smallest diffs first.** Adopt the missing pieces using the templates. Backfill the spec from the code (code wins on facts, spec wins on intent; record conflicts, see below). 4. **Upgrade report.** Write `UPGRADE-REPORT.md` (template provided) listing applied changes, grades before/after, and every conflict. ### Conflict rule (Mode B, binding) If the existing project has a rule that clashes with this method, DO NOT overwrite it. Keep their rule, mention the clash in the upgrade report (what clashed, why the method prefers its own, what was kept), and move on. The only exception is a rule that is illegal or demonstrably destructive: flag it loudly, propose the fix, and still wait for the human before changing it. ### Scoped runs Full-mode runs are the default, but partial application is allowed when the user asks for it: ADRs only, track sheet only, runbooks only. Rules for scoped runs: run the Mode B audit first (read-only, even for one piece), record the narrowed scope in the upgrade report, and list what was deliberately left out so a later run can continue. The conflict rule still applies. ## The method ### No open gaps Anything that would normally be "TBD" gets a decided default with rationale in the decisions section instead. Tracked assumptions are allowed only with: owner, verify-by date, and a fallback if the assumption fails. A spec with an untracked open ends the run as incomplete: say so, do not ship it quietly. ### Normative IDs Every requirement, rule, and surface gets a stable ID from a family prefix: FR (functional), SEC (security), BIL (billing), OPS (ops), NFR (non-functional), UX, PG (pages/surfaces), D (decisions), PIN (stack pins), A (assumptions), TRK/DOC (tracking/docs). IDs are never reused once retired. Cross-reference by ID, never by prose paraphrase. ### Caps and floors Everything unbounded gets a hard cap: rates, sizes, counts, intervals, memory. Every automated process gets a floor it may never cross on its own (retention windows, minimum intervals, protected paths); when a floor is hit, page a human instead of deleting. Prefer static ceilings to autotuning. ### Safety Destructive operations are dry-run by default, confirmed explicitly, and scoped as narrowly as possible. Loud skips beat silent corruption: reject unverifiable input with a clear reason and a counter. Verify before you write (checksums, integrity checks, pre-action snapshots). A delete that does not reclaim is not a delete. ### Proof Measure, do not theorize: numbers in docs come from runs on real machines. Test through the real path (real filesystem, real concurrency, failure injection), not just units around it. After behavior changes, run the suite. After deploys, verify live. ### Research first, then decide with an opinion Load-bearing decisions (stack, vendors, money flows, compliance) start with web research: log the queries, synthesize into the spec with normative defaults, keep the log in an appendix. Then decide: a clear rule with stated tradeoffs beats a configurable maybe. Revisit only with new evidence, filed as a new ADR (never by editing history). ### Decisions and stack pins Spec-level decisions live in the spec as D-IDs (one line each, with rationale). Repo-level decisions live as ADRs in `docs/adr/`, one file per decision (`NNNN-short-title.md`) with a MADR-lite body: Status, Date, Context, Decision, Consequences, Links. Accepted ADRs are immutable; change means a new ADR that supersedes. Every D-ID, PIN, or requirement change needs an ADR number in the PR. Agent prompts MUST cite ADR constraints, never silently invent new ones. Hedged "X or Y" stack clauses MUST be pinned to one choice with a PIN-ID before build. ### Estimates Person-weeks with drivers, ranges not theater: each line names what drives the cost. Recompute totals on every scope change and state the consequences honestly (date moves, cost moves). Freeze scope with a D-ID; new ideas go to a later release unless a launch gate forces them. If the owner overrules the freeze, re-baseline the freeze the same day and record the new date and cost. Never absorb scope silently. ### Changelog and versioning One changelog table: version, date, change text, strictly ascending. Every spec change gets a row. Patch = additions/clarifications, minor = scope or phasing changes, major = breaking contract changes. Every markdown doc carries a versioned header: `<!-- doc: file | version | updated -->`. Project prose rules hold everywhere (for this author: no em-dashes anywhere, code, docs, comments, commits, chat). ### Single source, synced mirrors One file is canonical. Split-offs (roadmap, runbooks, API docs) are mirrors, synced in the SAME commit as the canonical change. Never let a mirror drift: verify sync mechanically after every edit round. ### Roadmap split When the spec passes ~500 lines, split a build-order roadmap with a zero-loss proof: account for every source line mechanically (count them; declared duplicates counted exactly). Each part gets a header comment naming source file, source version, and covered sections. Keep a coverage map (every section, exactly one home) and a build table (order, file, milestone, weeks, exit criteria). ### Verification gates Define launch gates per phase (tests green, pentest criticals zero, restore drill pass, billing zero-diff over N days, docs synced, legal pack signed). All green or no launch. After every change round, run the mechanical checks: sync between canonical and mirrors, ID uniqueness, changelog order, forbidden-pattern scan, and the repo's own test suite for the touched area. A confident patch you never saw pass is not done. Sample mechanics (adapt paths to the project): ```sh # ID uniqueness: must print nothing (no duplicate requirement IDs) rg -o '[A-Z]{2,3}-[0-9]+' SPEC.md | sort | uniq -d # changelog order: versions must ascend top to bottom rg '^\| [0-9]+\.[0-9]+' SPEC.md # forbidden patterns: project rules, e.g. untracked TBDs, em-dashes (U+2014) rg -n 'TBD|\u2014' SPEC.md BUILD-TRACK.md docs/ ``` ### Build tracking (multi-month, multi-model safe) `BUILD-TRACK.md` at repo root is the single source of build status. One row per milestone and per gate drill: Milestone, Owner, Status, Updated, Evidence, Drift. Owner is a human name or `llm:model/version`. Status enum: NOT-STARTED, IN-PROGRESS, BLOCKED, IN-REVIEW, DONE. Update triggers, all same-day: any status change, end of every work session, every gate pass or fail, every merged PR. Staleness: 7 days without update marks the row STALE (CI fails); 14 days escalates to the owner as BLOCKED. Context passes ONLY through the track sheet, ADRs, and code, never through chat memory. LLM output is reviewed by a human or a second model before merge; billing, auth, and security merges need human sign-off. ### Living docs Maintain a register: doc, owner, update trigger, max staleness. Minimum register: canonical spec + mirrors (same commit, 0 days), track sheet (7 days), ADRs (before merge), runbooks (incident/drill + 30 days), API changelog (CI-generated, 0 days), cost book (vendor price change, same day), risks (monthly + incident), postmortems (48h after SEV-2+). Same-PR rule: a behavior/API/config/ops change updates its docs in the SAME PR; reviewers verify docs impact; maintainers block merges with stale docs on user-facing changes. Generated docs regenerate in CI; drift fails the build. Quarterly audit: re-verify the register, delete what stopped being true. ### Craft and ops Exact, minimal diffs: edit existing files, reuse helpers, one way of doing anything. Boring technology, dumb backends, smart edges. Docs state what the system does today, gaps listed next to the code that will close them. Commit, deploy, verify, in that order, same session. Schedules live inside the system on a loop (retention, cleanup, checks) with human-readable states, not silent hope. ### Writing decent software Shape: one language, one database, server-rendered first, no build step unless earned. One data root with every path derived in one config module. Layout mirrors URLs: if you cannot guess the file from the URL, the layout is wrong. House stack (defaults when nothing else is picked; override per project with an ADR, never by drifting): - Backend: Python + FastAPI, one typed framework. Routers mirror the URL tree: flat file per resource (`routers/feed.py`), package per sub-resource (`routers/projects/`), each leaf owning an APIRouter, the package `__init__` aggregating. One app factory, middleware in fixed order (timing outermost, then security headers, rate limit, maintenance gate, error handlers). - Templates: Jinja2 server-side, one shared instance, one `base.html`, page CSS/JS in standard blocks, shared partials for repeated UI. - Frontend: no framework, no build step. Plain ES modules, one class per file, small shared utilities. The server renders what exists at request time; the client only handles live content, sanitized fail-closed. - Database: SQLite via the `dataset` ORM, WAL mode, busy timeout, one embedded file under the data root for small to medium scale. Graduate to Postgres only on measured need (write concurrency, size, or managed backups), filed as an ADR. - Tests mirror the tree: `tests/unit`, `tests/api` (live server plus temp DB), `tests/e2e` (browser). One way of everything: one auth resolver, one response helper, one validation style, one outbound HTTP client (with SSRF guard), one queue pattern. A second implementation of an existing helper is a bug, not a choice. DRY, enforced: no logic in two places. If it appears twice, hoist it into the one helper, predicate, or constant both callers use. Single source per fact in code exactly as in docs; copy-paste is never the answer, not even "just this once". Consistency, enforced: new code matches the existing code first. Same helpers, same patterns, same naming, same file shapes; a reviewer should not be able to tell which lines are new. If the existing pattern is bad, either fix all usages in the same change or follow it and file the cleanup (never a third style alongside). Read before you write, always. Object-oriented, enforced: model the domain as objects with the behavior living next to the data it acts on. Small classes with one job, composition over inheritance, no god objects, no procedural blobs where an object fits. Python: dataclasses and Pydantic models for data, service objects for operations. Frontend: one class per file as the house stack already demands. Data: opaque IDs, soft delete everywhere with garbage collection doing the hard deletes, ownership checked by one helper, no N+1 (batch loaders are the only way to load related rows), idempotent schema init. Daily loop, eight steps in order, a failed step blocks the rest: understand (match an existing example or agree the new pattern first), data, server, view, safety, agent+docs (the most-forgotten step: tool action, endpoint doc, user doc), document, validate. Testing: three tiers (unit, HTTP integration on temp DB, browser E2E), serial runs, full suite green before done, cheapest-first triage, never narrow a run to pass. For money, state machines, and read-then-write races add: property checks, stateful fuzzing, real-process concurrency tests, static analysis on touched files. Ops: every repeated action is a named command (no tribal one-liners), DTAP pipeline with CI green before merge, destructive ops dry-run first and explicitly confirmed, retention CLIs per expirable domain, production data guarded by written confirmation, deploy then verify live. Runtime: every project ships a container, Podman preferred, Docker accepted. Debian-slim-based image, a compose file for the full stack (app, db, workers), one command to boot it. No "works on my machine": CI builds the image, prod runs the image. Makefile: required and self-sufficient. It assures its own environment: creates the venv (or pulls images) when missing, installs pinned deps, then runs every target inside it. Standard targets: `install`, `dev`, `prod`, `test`, `test-fast`, `lint`, `clean`, `up`, `down`, `logs`. No target may assume a hand-prepared machine. Python packaging: traditional `pyproject.toml` (setuptools or hatchling, src layout), version as the single source read live by the app, one console-script binary declared under `project.scripts` that is THE way to execute the software (no bare `python app.py` in docs or deploy). Deps pinned, dev extras separated. Versioning: one version source read live by the app, patch auto-bumped per commit, minor/major manual release commits, assets served immutable under versioned URLs, trunk plus short ticket branches plus a production promotion target, annotated tags and one changelog for releases. Every markdown doc versioned: header comment plus history table, bumped in the same commit as the content change. Agent docs: three layers (personal base, root project rules under ~500 lines, nested subsystem files next to the code). Every statement marked hard rule, convention, or guidance, each with a named enforcer (test path, hook, review gate). A rule without an enforcer is a wish: the quarterly audit deletes one. Docs update in the same change as the code, state today with no fiction, and die with the feature they describe. Logging: stdlib `logging` configured once via `dictConfig` in one module, `getLogger(__name__)` per module. Debug log by default: DEBUG goes to a rotating debug file, INFO+ to the console, levels tunable via env. Rotation is mandatory, never unbounded: RotatingFileHandler for the app log (10 MB x 5), timed rotation for debug/audit (midnight, keep 7-14), plus a total cap on the log directory (default 500 MB) enforced by a prune check. Format: UTC ISO timestamp, level, logger, message; JSONL only when a shipper consumes it. QueueHandler for threaded workers; lazy %-formatting and level guards in hot paths. Never log secrets or PII (token fingerprints, redaction as backstop); bare except without logging is forbidden. Files first; stdout only for container runtimes. Backups: 3-2-1 minimum (3 copies, 2 media, 1 off-site at a different provider/location, not the same DC). Anything holding money or PII gets 3-2-1-1-0 (one immutable copy, zero-error verified restores). Default tool restic (or Borg): encrypted, automated on schedule, calendar retention (7 daily, 4 weekly, 6 monthly), prune wired to the same tool. RPO/RTO stated per domain and measured, not assumed. Test restores monthly into an empty directory (quarterly minimum); an untested backup fails the ops gate, and a missing scheduled run pages like any other alarm. Named commands (`backup-run`, `backup-verify`, `backup-prune`), keys backed up separately from the data they unlock. Secrets are human-only. AI MUST never print, copy, transmit, modify, rotate, or delete secret values (passwords, tokens, keys, connection strings), and MUST never commit them. Referencing an env var NAME to wire config is fine; its VALUE stays unread and unmoved. Servers are closed from outside by default; secret storage and rotation are the owner's task. If a secret is missing, expired, or appears where it should not (logs, diffs, chat), stop and page the human. No workarounds, ever. Dependencies: fewest possible. Stdlib first, one well-chosen library per need, no new dependency without an ADR entry justifying it (what, why not stdlib, license, maintenance status). Pinned versions, automated audit on schedule, CVE fix-or-remove with a deadline. Each release, remove one dependency nobody can justify. Environments: DTAP, usually on one platform. Dev, test, accept, and prod coexist without colliding: separate database, data dir, ports, and env-prefixed resource names per environment; no shared state between test and prod, ever. Test data is marked and isolated; production data never flows into test. Accept mirrors prod (same images, same flags). Promotion is deliberate: merge, deploy, verify live. Incidents: every alert names an owner and a runbook; unowned alerts are forbidden. On-call rotation with a human reachable; agents hold and page instead of improvising past a floor. SEV-2+ gets a postmortem within 48h (template provided). Customer-visible incidents update the status page in the same hour; an incident is not closed until comms, fix, and action items all landed. Minimum CI: lint touched files, serial full suite, doc-header check (every `.md` carries a version line), generated-docs drift check, dependency audit. All green before merge, no exceptions. Migrations: every schema change ships with its rollback, reviewed together. Expand before contract (additive first, removal later); destructive migrations need a snapshot plus a rehearsed restore. Never edit a migration that already ran in prod; write a new one. ## Checklists ### New project - [ ] Skeleton files exist from templates - [ ] Spec: zero untracked opens, IDs on everything, changelog started - [ ] Stack pins: no "X or Y" clauses remain - [ ] Engineering shape chosen: layout, data conventions, test tiers, commands - [ ] Logging + backups: rotation caps set, 3-2-1 scheduled, restore drill dated - [ ] DTAP coexistence verified: separate state per env, no test/prod sharing - [ ] Incident loop defined: alert owners, on-call, status page, postmortem rule - [ ] Assumptions have owner + verify date + fallback - [ ] Estimates have drivers; scope freeze D-ID recorded - [ ] Gates defined per phase; first gate (spec review) passed - [ ] Spec graded A-F and fixed to A- or better, grades reported - [ ] BUILD-TRACK.md has one row per milestone, all NOT-STARTED - [ ] ADR 0001 records method adoption ### Upgrade existing project - [ ] Read-only audit complete, nothing written yet - [ ] Gap report with A-F grades on the six axes - [ ] Missing pieces applied, smallest diffs first - [ ] Conflicts kept-as-is and recorded (never overwritten) - [ ] UPGRADE-REPORT.md written with before/after grades - [ ] Mechanical checks pass (sync, IDs, changelog, tests) ## Red flags (stop and say so) - A change that cannot name its spec ID or ADR. - Scope absorbed without re-baselining date and cost. - A mirror edited without its canonical source (or vice versa). - Docs updated "later" instead of in the same PR. - A gate waved through red, or narrowed to pass. - An agent inventing a new queue, cache, or retry policy the ADRs never approved. - AI touching secret values (print, copy, change, delete), or working around a missing secret instead of paging the human. - Test and prod sharing state, or prod data flowing into test. - A migration with no rollback, or an edit to one that already ran. ## Templates In `assets/templates/`: `SPEC.md` (spec skeleton), `BUILD-TRACK.md`, `ADR.md`, `RUNBOOK.md`, `POSTMORTEM.md`, `UPGRADE-REPORT.md` (Mode B report with conflicts table), `CHANGELOG.md`, `AGENTS.md` (root rules file, three-layer header). Copy, never link: the target project owns its copies from day one. """, "assets/templates/ADR.md": r"""<!-- doc: docs/adr/NNNN-short-title.md | version: 1.0 | updated: YYYY-MM-DD --> # NNNN: <Imperative title, e.g. "Use Postgres RLS for tenancy"> - Status: proposed | accepted | superseded by NNNN - Date: YYYY-MM-DD - Decision makers: <names> - Spec IDs: <D-IDs, PINs, FRs touched> ## Context What forced this decision. Facts only, with measurements where the claim needs one. ## Decision The rule, stated so a stranger can follow it without asking. ## Consequences Good and bad. Include costs, caps/floors introduced, and what this rules out. ## Links PRs, issues, spec sections, superseded ADRs. """, "assets/templates/AGENTS.md": r"""<!-- doc: AGENTS.md | version: 0.1.0 | updated: YYYY-MM-DD --> # Agent instructions: <project> Three layers (separate files, never merged): 1. Personal base rules (operator-owned, every project): safety, proof, craft, ops habits. 2. This file (root project rules): applies to the whole repo. Global rules only; keep it short enough that agents actually read it. 3. Nested subsystem files (next to the code, loaded on touch): deep mechanics, helpers, pitfalls, worked examples. ## Root rules - Working directory: <repo root>. <scope constraints, e.g. work only here> - Canonical spec: <SPEC.md>; mirrors sync in the same commit. - Build status: <BUILD-TRACK.md>; update triggers per the spec tracking section. - Decisions: <docs/adr/>; cite ADR constraints in every agent prompt. - Prose rules: <e.g. no em-dashes anywhere: code, docs, comments, commits> - Verification: <mechanical checks after every change round> - Commit/deploy/verify order: <project policy> ## Subsystem files <list nested instruction files and what each owns> """, "assets/templates/BUILD-TRACK.md": r"""<!-- doc: BUILD-TRACK.md | version: 0.1.0 | updated: YYYY-MM-DD --> # Build track sheet Single source of build status. Status enum: NOT-STARTED, IN-PROGRESS, BLOCKED, IN-REVIEW, DONE. Update triggers (same-day): status change, session end, gate pass/fail, merged PR. 7 days stale fails docs CI; 14 days escalates to the owner as BLOCKED. | Milestone | Owner | Status | Updated | Evidence | Drift | |-----------|-------|--------|---------|----------|-------| | M0 | unassigned | NOT-STARTED | - | - | - | ## Session history One line per work session, newest first: - YYYY-MM-DD | human: <name> or llm: <model/version> | goal | outcome | rows touched """, "assets/templates/CHANGELOG.md": r"""<!-- doc: CHANGELOG.md | version: 0.1.0 | updated: YYYY-MM-DD --> # Changelog Strictly ascending. One row per change. Patch = additions and clarifications, minor = scope or phasing changes, major = breaking contract changes. | Version | Date | Change | |---------|------|--------| | 0.1.0 | YYYY-MM-DD | Initial changelog | """, "assets/templates/POSTMORTEM.md": r"""<!-- doc: docs/postmortems/YYYY-MM-DD-<name>.md | version: 1.0 | updated: YYYY-MM-DD --> # Postmortem: <incident title> - Date: YYYY-MM-DD - Severity: <SEV-1..4> - Owner: <name> - Status: draft | final ## Timeline (UTC) - HH:MM <event, with evidence link> ## Impact <who was affected, for how long, measured numbers> ## Root cause <the cause, not the symptom; five whys if useful> ## Action items | Action | Owner | Due | Done | |--------|-------|-----|------| | ... | ... | ... | ... | ## Lessons <what changes in code, runbooks, or docs so this class cannot recur> """, "assets/templates/RUNBOOK.md": r"""<!-- doc: docs/runbooks/<name>.md | version: 1.0 | updated: YYYY-MM-DD --> # Runbook: <name> - Trigger: <alert, symptom, or scheduled drill> - Severity: <SEV-1..4> - Owner: <role> ## Steps 1. <numbered, copy-pasteable, with expected output> ## Rollback <exact undo for every mutating step> ## Comms template <who gets told what, when; status page text if customer-visible> ## Postmortem owner <name or role; SEV-2+ needs a postmortem within 48h> ## Last drilled <date + result; empty means never, which fails the ops gate> """, "assets/templates/SPEC.md": r"""<!-- doc: SPEC.md | version: 0.1.0 | updated: YYYY-MM-DD --> # <Project> - Complete Implementation Document - Version: 0.1.0 - Status: Draft (zero TBDs required before build) - Date: YYYY-MM-DD - Owner: <name> ## 0. Document control ### 0.1 Conventions - Normative IDs: FR, SEC, BIL, OPS, NFR, UX, PG, D, PIN, A, TRK, DOC. IDs are never reused once retired. Cross-reference by ID. - No open gaps: anything that would be "TBD" gets a decided default with rationale in Decisions, or a tracked assumption (owner + verify-by + fallback) below. ### 0.2 Assumptions (tracked, all need fallback) | ID | Assumption | Owner | Verify by | Fallback | |----|-----------|-------|-----------|----------| | A-01 | ... | ... | ... | ... | ### 0.3 Hard global caps | Cap | Value | Enforced at | |-----|-------|-------------| | ... | ... | server-side | ### 0.4 Changelog (ascending, one row per change) | Version | Date | Change | |---------|------|--------| | 0.1.0 | YYYY-MM-DD | Initial spec skeleton | ## 1. Goals and non-goals - G-01..: measurable goals. - NG-01..: explicit non-goals (decided, not deferred). ## 2. Scope (personas, use cases) ## 3. Requirements (FR/SEC/BIL/OPS/NFR/UX with IDs) ## 4. Surfaces (PG inventory if user-facing) ## 5. Architecture (pins as PIN-IDs, no "X or Y") ## 6. Data model, API, security, compliance, ops ## 7. Estimates (person-weeks with drivers) | Area | PW | Driver | |------|----|--------| | ... | ... | ... | ## 8. Risks and mitigations ## 9. Decisions (D-IDs, one line + rationale each) - D-01: ... ## Appendix A. Research log (queries + dates + synthesis pointer) """, "assets/templates/UPGRADE-REPORT.md": r"""<!-- doc: UPGRADE-REPORT.md | version: 1.0 | updated: YYYY-MM-DD --> # Upgrade report: seriousdevelopment applied (Mode B) - Date: YYYY-MM-DD - Applied by: <human or llm:model/version> - Scope: <repo + commit range> ## Grades (A-F, before -> after) | Axis | Before | After | |------|--------|-------| | Spec completeness | | | | Decision traceability | | | | Dev tracking | | | | Verification | | | | Doc freshness | | | | Estimation honesty | | | ## Applied changes <list files created/changed, smallest diffs first, with one-line why> ## Conflicts (existing rule kept, method rule NOT applied) | # | Existing rule (kept) | Method preference | Why kept | Location | |---|---------------------|-------------------|----------|----------| | 1 | ... | ... | ... | ... | Rule: a clash never overwrites the project's own rule. Illegal or demonstrably destructive rules are flagged loudly and changed only with explicit human approval (record the approval here). ## Follow-ups <ordered list of what the project should do next, with owners> """, }, "taste": { "SKILL.md": r"""--- name: taste description: Mandatory preflight for web frontend visual design. If available, load the taste skill (load_skill) before the first frontend create or edit tool call whenever creating or visually styling a human-viewed web page, component, app, dashboard, or interactive report. Do not load it for non-visual frontend logic or machine-only HTML. If unavailable, continue without warning. Negative constraints only; user requests and other explicit styles still win. user-invocable: false --- # Anti-Slop: Frontend DON'Ts What NOT to do when designing a UI or page. This is a filter, not a style; it never prescribes a look. Stacking several of the below is what reads as AI-made on sight. Adaptive, so if the user or another skill asks for something specific, do that instead; these are defaults, not overrides. - purple / indigo / violet accent, or a purple-to-blue hero gradient (the top AI tell) - `bg-clip-text` gradient headline text - cream (`#faf8f4`) as the whole-page background - aurora blobs / glowing colored shadows, or an identical glow on every card - Inter everywhere, or Geist / Space Grotesk / Instrument Serif / Roboto / Arial as your only identity - gray-400 / gray-500 body text - one font weight and size throughout (no hierarchy) - tracked all-caps micro-kickers above every section - centered hero of eyebrow pill + headline + two CTAs - the identical 3-card feature grid with a lucide icon in a rounded square - an 8+ word, 48px+ headline that says nothing - bento grid for everything - the same eyebrow-headline-3cards section repeated back to back - `rounded-2xl`, or any single radius, on every element - glassmorphism blur + border + shadow on every surface - `hover:scale-105` lift on cards - emoji as feature icons (🚀 ⚡ ✨ 🎯 💡) - left-border accent cards, a colored border on a rounded element, or cards nested inside cards - fade-up / slide-up on everything as it scrolls in - motion that ignores `prefers-reduced-motion` - scribbly hand-drawn SVG mascots with no purpose """, }, } BUNDLED_SKILL_UPSTREAM = { "deep-research": "muse-user/deep-research, adapted (fork/poll, web_fetch, no em dashes)", "git": "muse-bundled/git, adapted (load_skill paths, tai authorization note)", "plan": "muse-bundled/plan, adapted (load_skill, ask_user, fork/poll)", "requirements-clarification": "muse-bundled/requirements-clarification, adapted (ask_user, single-line description)", "seriousdevelopment": "muse-user/seriousdevelopment v1.7.1, verbatim", "taste": "muse-bundled/taste, one-word adaptation (load_skill)", } def skill_catalog(skills): lines = [] if skills: lines += ["", "", "## Available skills"] for name in sorted(skills): lines.append("- %s: %s" % (name, skills[name]["description"][:300])) wanted = sorted(name for name in SKILL_BLUEPRINTS if name not in (skills or {})) if wanted: lines += ["", "", "## Buildable skill blueprints (load_skill builds one on demand)"] for name in wanted: lines.append("- %s: %s" % (name, SKILL_BLUEPRINTS[name]["hint"])) return "\n".join(lines) lines.append("Load full instructions with load_skill before using one.") return "\n".join(lines) class Sandbox: """Container engine probes (static; tests patch these live).""" @staticmethod def container_engine(): if shutil.which("podman"): return "podman" if shutil.which("docker"): return "docker" return "" @staticmethod def box_exec(engine, argv, extra=(), input_data=None, timeout=180): return subprocess.run([engine, "exec", *extra, "-i", BOX_NAME, *argv], input=input_data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout) def container_engine(): return Sandbox.container_engine() def box_state(engine): try: done = subprocess.run([engine, "inspect", "-f", "{{.State.Running}}", BOX_NAME], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=15) except (OSError, subprocess.SubprocessError): return "missing" if done.returncode != 0: return "missing" return "running" if done.stdout.strip() == "true" else "stopped" def update_box_caps(engine): """Extend the current BOX_* memory/cpu ceiling to a box that already exists (uncapped boxes predate the caps). Best-effort, once per process: rootless cgroup writes can fail, and update cannot set pids-limit at all, so failure points at reinstall instead of failing the turn. Returns True when the update ran clean, False when skipped or it failed.""" if _BOX_CAPS_STAMP["done"] or os.environ.get("TAI_BOX_CAPS") == "0": return False _BOX_CAPS_STAMP["done"] = True try: done = subprocess.run([engine, "update", *BOX_UPDATE_ARGS, BOX_NAME], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=60) except (OSError, subprocess.SubprocessError): return False if done.returncode != 0: print("box caps not applied (%s); run `%s rm -f %s` so next use recreates the box with limits" % ((done.stderr or "").strip()[:200], engine, BOX_NAME)) return False return True def ensure_box(): engine = container_engine() if not engine: raise BackendError("no container engine found, install podman") state = box_state(engine) if state == "running": update_box_caps(engine) return engine if state == "stopped": done = subprocess.run([engine, "start", BOX_NAME], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=60) if done.returncode != 0: raise BackendError("cannot start box: " + (done.stderr or "").strip()[:200]) update_box_caps(engine) return engine print("building sandbox image %s (one-time, takes minutes)..." % BOX_IMAGE) with tempfile.TemporaryDirectory() as build: for filename, content in (("Containerfile", BOX_CONTAINERFILE), ("stt.py", BOX_STT), ("tts.py", BOX_TTS)): with open(os.path.join(build, filename), "w", encoding="utf-8") as handle: handle.write(content) done = subprocess.run([engine, "build", "-t", BOX_IMAGE, build], timeout=1800) if done.returncode != 0: raise BackendError("box image build failed") done = subprocess.run([engine, "run", "-d", "--name", BOX_NAME, "--restart", "unless-stopped", *BOX_RUN_ARGS, BOX_IMAGE], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=120) if done.returncode != 0: raise BackendError("cannot start box: " + (done.stderr or "").strip()[:200]) return engine def box_exec(engine, argv, extra=(), input_data=None, timeout=180): return Sandbox.box_exec(engine, argv, extra, input_data, timeout) def box_python(engine): if engine not in _BOX_PYTHON_CACHE: try: probe = box_exec(engine, ["test", "-x", BOX_PYTHON], timeout=30) _BOX_PYTHON_CACHE[engine] = BOX_PYTHON if probe.returncode == 0 else "python3" except (OSError, subprocess.SubprocessError): _BOX_PYTHON_CACHE[engine] = "python3" return _BOX_PYTHON_CACHE[engine] def box_transcribe(engine, audio, timeout=300): done = box_exec(engine, [box_python(engine), "/box/stt.py"], input_data=audio, timeout=timeout) if done.returncode != 0: raise BackendError("box stt failed: " + done.stderr.decode("utf-8", "replace")[:300]) return done.stdout.decode("utf-8", "replace").strip() def box_speak(engine, text, voice=""): argv = [box_python(engine), "/box/tts.py"] + ([voice] if voice else []) done = box_exec(engine, argv, input_data=text.encode("utf-8"), timeout=180) if done.returncode != 0 or not done.stdout: raise BackendError("box tts failed: " + done.stderr.decode("utf-8", "replace")[:300]) return done.stdout BOX_OPENCODE_NODE_VERSION = "20.19.2" _BOX_OPENCODE_CACHE = {} # Installed on demand, not baked into BOX_CONTAINERFILE: the box exists for # STT/TTS today and most installs already have one running, so this only # pays Node's weight (~800MB, dominated by opencode-ai's own node_modules, # not the runtime itself) on the one machine, the one time, it's actually # needed as a last-resort chat backend (see ChatClient.all_candidates()). # The official nodejs.org tarball, not apt: Debian's nodejs/npm packages # drag in webpack/babel/jest as transitive deps (measured: 1.49GB vs 971MB # for the exact same opencode-ai install) and are not relevant here at all. BOX_OPENCODE_INSTALL_SCRIPT = ( "set -e; " "ARCH=\"$(dpkg --print-architecture)\"; " "case \"$ARCH\" in amd64) NODE_ARCH=x64;; arm64) NODE_ARCH=arm64;; armhf) NODE_ARCH=armv7l;; i386) NODE_ARCH=x86;; *) echo \"unsupported arch $ARCH\" >&2; exit 1;; esac; " "apt-get update -qq && apt-get install -y -qq --no-install-recommends curl xz-utils ca-certificates; " "curl -fsSL \"https://nodejs.org/dist/v%(ver)s/node-v%(ver)s-linux-$NODE_ARCH.tar.xz\" -o /tmp/node.tar.xz; " "tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1; " "rm /tmp/node.tar.xz; " "apt-get purge -y -qq curl xz-utils; apt-get autoremove -y -qq; rm -rf /var/lib/apt/lists/*; " "npm install -g opencode-ai --no-fund --no-audit" ) % {"ver": BOX_OPENCODE_NODE_VERSION} def box_opencode_binary(engine): """True once the opencode CLI is confirmed present inside the sandbox box, installing Node.js + `npm install -g opencode-ai` into the box first if it isn't there yet. Only ever called as a last resort (see ChatClient.all_candidates()) and only against a box that is ALREADY running -- this never creates the box itself; that stays the explicit, approval-gated action it already is (ensure_box(), via --env sandbox or the install tool). Cached per engine for the process lifetime so normal turns that succeed through a real backend never pay this cost twice.""" if engine in _BOX_OPENCODE_CACHE: return _BOX_OPENCODE_CACHE[engine] try: probe = box_exec(engine, ["which", "opencode"], timeout=10) except (OSError, subprocess.SubprocessError): _BOX_OPENCODE_CACHE[engine] = False return False if probe.returncode == 0: _BOX_OPENCODE_CACHE[engine] = True return True print(paint( "opencode: no local CLI on this machine; installing it into the existing sandbox " "box (container '%s') instead, as a last-resort backend. Nothing outside that " "container is touched. Streaming the install below:" % BOX_NAME, Ansi.DIM ), file=sys.stderr) try: # Deliberately not box_exec(): this one streams straight to the # real stdout/stderr instead of capturing, so the person watching # sees the actual apt/curl/npm output live, not a silent pause. done = subprocess.run([engine, "exec", BOX_NAME, "sh", "-c", BOX_OPENCODE_INSTALL_SCRIPT], timeout=600) except (OSError, subprocess.SubprocessError) as exc: print(paint("opencode box install failed: %s" % short_error(exc), Ansi.DIM), file=sys.stderr) _BOX_OPENCODE_CACHE[engine] = False return False ok = done.returncode == 0 print(paint("opencode box install %s" % ("succeeded" if ok else "failed (exit %d)" % done.returncode), Ansi.DIM), file=sys.stderr) _BOX_OPENCODE_CACHE[engine] = ok return ok def box_opencode_run(engine, model, prompt, timeout): argv = ["opencode", "run", "--pure", "--agent", OPENCODE_AGENT, "--format", "json", "-m", "opencode/" + model] try: done = box_exec(engine, argv, input_data=prompt.encode("utf-8"), timeout=timeout) except subprocess.TimeoutExpired: raise BackendError("opencode (box) run timed out after %ds" % timeout) except (OSError, subprocess.SubprocessError) as exc: raise BackendError("opencode (box) run failed: %s" % short_error(exc)) return opencode_parse_events(done.stdout.decode("utf-8", "replace"), done.returncode, done.stderr.decode("utf-8", "replace")) def split_models(spec): return [item.strip() for item in (spec or "").replace("\n", ",").split(",") if item.strip()] def parse_backends(spec): found = [] for entry in (spec or "").replace("\n", ";").split(";"): fields = [field.strip() for field in entry.split("|")] if len(fields) < 3: continue label, base, key = fields[0], fields[1].rstrip("/"), fields[2] if key.startswith("$"): key = os.environ.get(key[1:], "") model = fields[3].strip() if len(fields) > 3 else "" profile = fields[4].strip().lower() if len(fields) > 4 else "" if profile not in ("", "opencode"): profile = "" if not label or not base: continue found.append({"label": label, "base": base, "key": key, "model": model or "", "profile": profile}) return found class ShellSession: """One interactive shell is one conversation context. Spec: every bash/zsh/fish instance gets a stable logical id. All tai runs inside that shell share one conversation history; a new shell starts fresh (system message only), so the opening messages are identical on every start. Vault data (records, secrets, memory, audit) stays shared across shells; only the chat transcript and turn state are scoped per session. Id format (derived): YYYYMMDD-HHMMSS-p<PID>-b<BOOT8>: local date and time of the shell's start, the shell pid, and 8 hex chars of the kernel boot id. Deterministic per shell (no state file, no install hook), unique across reboots. Fallback with no shell ancestor or no /proc is ephemeral: YYYYMMDD-HHMMSS-e<PID>-<rand4>, fresh per process. Resolution (ShellSession.current): an explicit TAI_SESSION wins when it is a plain word (a pinned, deliberately shared context) or when its embedded -p<PID>- matches the nearest shell ancestor (the same shell coming back). An id that embeds a different pid means a nested shell that inherited the variable, so a fresh child id is derived and the inherited value becomes its parent link. boot() exports the resolved id back into os.environ, which makes TAI_SESSION the global var every child process (shell tool, nested tai) inherits. Set TAI_SESSION to one fixed word to share a single context across all shells; unset it for strict per-shell isolation. Parentage (for fun, queryable): register() upserts one shell_sessions row per boot plus stub rows for outer shell ancestors, whose ids derive from the same formula, so the whole nesting tree accretes over time. Dangling parents after pruning are fine; this table is a log, not a constraint. /ctx (ShellSession.report) renders the ancestor path and children of the current session from these links. Caps: sid charset [A-Za-z0-9_.-], SID_MAX chars; CHAIN_CAP process levels; FILES_CAP scoped transcript files per profile and bot (oldest pruned on save); ROWS_CAP shell_sessions rows (oldest pruned on register). """ ENV = "TAI_SESSION" SHELLS = frozenset(("bash", "zsh", "fish", "sh", "dash", "ksh", "tcsh", "nu")) SID_MAX = 64 PID_RE = re.compile(r"-p(\d+)-") CHAIN_CAP = 64 FILES_CAP = 20 ROWS_CAP = 1000 TREE_DEPTH = 8 TREE_KIDS = 10 TREE_LINES = 40 def __init__(self, sid, shell="", pid=0, started="", parent=None, chain=()): self.sid = sid self.shell = shell self.pid = pid self.started = started self.parent = parent self.chain = list(chain) @classmethod def sanitize(cls, value): return re.sub(r"[^A-Za-z0-9_.-]", "", str(value or ""))[:cls.SID_MAX] @classmethod def coerce(cls, value): if isinstance(value, ShellSession): return value.sid return cls.sanitize(value) @staticmethod def read_text(path): try: with open(path, "r", encoding="utf-8", errors="replace") as handle: return handle.read(4096) except OSError: return "" @classmethod def parse_stat(cls, text): head, _, tail = (text or "").rpartition(")") if not head or "(" not in head: return None comm = head.split("(", 1)[1] fields = tail.split() try: return comm, int(fields[1]), int(fields[19]) except (IndexError, ValueError): return None @classmethod def boot_time(cls, proc_root): try: with open(os.path.join(proc_root, "stat"), "r", encoding="utf-8") as handle: for line in handle: if line.startswith("btime "): return float(line.split()[1]) except (OSError, ValueError): return None return None @classmethod def clk_tick(cls): try: return int(os.sysconf("SC_CLK_TCK")) except (AttributeError, OSError, ValueError): return 100 @classmethod def boot_tag(cls, proc_root): digest = re.sub(r"[^0-9a-f]", "", cls.read_text(os.path.join(proc_root, "sys/kernel/random/boot_id")).lower())[:8] if digest: return digest btime = cls.boot_time(proc_root) if btime: return hashlib.md5(str(btime).encode("utf-8")).hexdigest()[:8] return "deadbeef" @classmethod def shell_name(cls, argv0, comm): for cand in (os.path.basename((argv0 or "").split("\0")[0]).lstrip("-").lower(), (comm or "").lstrip("-").lower()): if cand in cls.SHELLS: return cand return "" @classmethod def read_chain(cls, pid, proc_root, btime, tick, cap): chain = [] seen = set() current = pid while current and current not in seen and len(chain) < cap: seen.add(current) parsed = cls.parse_stat(cls.read_text(os.path.join(proc_root, str(current), "stat"))) if parsed is None: break comm, ppid, ticks = parsed argv0 = os.path.basename(cls.read_text(os.path.join(proc_root, str(current), "cmdline")).split("\0")[0])[:64] start = (btime + ticks / tick) if btime else 0.0 chain.append({"pid": current, "ppid": ppid, "comm": comm[:64], "argv0": argv0, "name": cls.shell_name(argv0, comm), "start": start, "start_iso": datetime.fromtimestamp(start, tz=timezone.utc).isoformat(timespec="seconds") if start else "", "sid": ""}) if ppid <= 0 or ppid == current: break current = ppid return chain @classmethod def shell_id(cls, start_ts, pid, boot): return "%s-p%d-%s" % (time.strftime("%Y%m%d-%H%M%S", time.localtime(start_ts)), pid, boot) @classmethod def ephemeral_id(cls, now_ts, pid): return "%s-e%d-%s" % (time.strftime("%Y%m%d-%H%M%S", time.localtime(now_ts)), pid, uuid.uuid4().hex[:4]) @classmethod def current(cls, env=None, pid=None, proc_root="/proc", now=None, clk_tck=None): source = os.environ if env is None else env raw = cls.sanitize(source.get(cls.ENV) or "") pid = os.getpid() if pid is None else pid now = time.time() if now is None else now btime = cls.boot_time(proc_root) tick = clk_tck or cls.clk_tick() boot = cls.boot_tag(proc_root) chain = cls.read_chain(pid, proc_root, btime, tick, cls.CHAIN_CAP) for entry in chain: if entry["name"] and entry["start"]: entry["sid"] = cls.shell_id(entry["start"], entry["pid"], boot) shells = [entry for entry in chain if entry["sid"]] inner = shells[0] if shells else None outer = shells[1] if len(shells) > 1 else None outer_sid = outer["sid"] if outer else None if raw: match = cls.PID_RE.search(raw) if match is None: return cls(raw, shell=inner["name"] if inner else "", pid=inner["pid"] if inner else pid, started=inner["start_iso"] if inner else "", chain=chain) if inner is not None and int(match.group(1)) == inner["pid"]: return cls(raw, shell=inner["name"], pid=inner["pid"], started=inner["start_iso"], parent=outer_sid, chain=chain) fresh = inner["sid"] if inner else cls.ephemeral_id(now, pid) started = inner["start_iso"] if inner else datetime.fromtimestamp(now, tz=timezone.utc).isoformat(timespec="seconds") return cls(fresh, shell=inner["name"] if inner else "", pid=inner["pid"] if inner else pid, started=started, parent=raw, chain=chain) if inner is not None: return cls(inner["sid"], shell=inner["name"], pid=inner["pid"], started=inner["start_iso"], parent=outer_sid, chain=chain) return cls(cls.ephemeral_id(now, pid), pid=pid, started=datetime.fromtimestamp(now, tz=timezone.utc).isoformat(timespec="seconds"), chain=chain) def export(self): os.environ[self.ENV] = self.sid def register(self, store): try: stamp = now_iso() shells = [entry for entry in self.chain if entry.get("sid")] for pos, entry in enumerate(shells): next_sid = shells[pos + 1]["sid"] if pos + 1 < len(shells) else None store.db.execute("INSERT OR IGNORE INTO shell_sessions (sid, parent, shell, pid, started) VALUES (?, ?, ?, ?, ?)", (entry["sid"], next_sid, entry["name"], entry["pid"], entry["start_iso"])) store.db.execute("INSERT OR IGNORE INTO shell_sessions (sid, parent, shell, pid, started, last_seen, cwd, chain) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", (self.sid, self.parent, self.shell, self.pid, self.started, stamp, dir_key(), json.dumps(self.chain))) store.db.execute("UPDATE shell_sessions SET last_seen = ?, cwd = ?, parent = COALESCE(parent, ?), chain = ? WHERE sid = ?", (stamp, dir_key(), self.parent, json.dumps(self.chain), self.sid)) store.db.execute("DELETE FROM shell_sessions WHERE sid NOT IN (SELECT sid FROM shell_sessions ORDER BY COALESCE(last_seen, started, '') DESC LIMIT ?)", (self.ROWS_CAP,)) store.db.commit() except (sqlite3.Error, OSError, ValueError): pass @classmethod def tree_lines(cls, store, sid): """Hierarchy around sid: ancestor path root-first, then the children below. Cycle-safe, capped by TREE_DEPTH/TREE_KIDS/TREE_LINES.""" try: rows = store.db.execute("SELECT sid, parent, shell, pid FROM shell_sessions").fetchall() except sqlite3.Error: return [] by_sid = {} kids = {} for item in rows: by_sid[item[0]] = item if item[1]: kids.setdefault(item[1], []).append(item[0]) for key in kids: kids[key].sort() if sid not in by_sid: return [] def label(key): item = by_sid.get(key) if item is None: return "%s (pruned)" % key return "%s %s%s" % (key, item[2] or "?", (" pid %s" % item[3]) if item[3] else "") upwards = [] seen = {sid} node = sid while len(upwards) < cls.TREE_DEPTH: parent = by_sid[node][1] if node in by_sid else None if not parent or parent in seen: break seen.add(parent) upwards.append(parent) node = parent lines = [" " * depth + label(key) for depth, key in enumerate(reversed(upwards))] lines.append(" " * len(upwards) + "* " + label(sid)) def below(key, depth, seen_here): out = [] if depth > cls.TREE_DEPTH or len(lines) + len(out) >= cls.TREE_LINES: return out for child in kids.get(key, [])[:cls.TREE_KIDS]: if child in seen_here or len(lines) + len(out) >= cls.TREE_LINES: continue out.append(" " * depth + label(child)) out.extend(below(child, depth + 1, seen_here | {child})) return out lines.extend(below(sid, len(upwards) + 1, seen)) return lines[:cls.TREE_LINES] @classmethod def report(cls, store, sid): try: row = store.db.execute("SELECT sid, parent, shell, pid, started, last_seen, cwd FROM shell_sessions WHERE sid = ?", (sid,)).fetchone() if sid else None except sqlite3.Error: return "ctx unavailable (vault error)" if row: lines = ["ctx: %s" % row[0], "shell: %s (pid %s, started %s)" % (row[2] or "?", row[3] or "?", row[4] or "?"), "parent: %s" % (row[1] or "(none)"), "cwd: %s" % (row[6] or "?")] elif sid: lines = ["ctx: %s (unregistered)" % sid] else: lines = ["ctx: (legacy, no session)"] tree = cls.tree_lines(store, sid) if tree: lines.append("tree:") lines.extend(tree) try: rows = store.db.execute("SELECT sid, shell, last_seen FROM shell_sessions ORDER BY COALESCE(last_seen, started, '') DESC LIMIT 8").fetchall() except sqlite3.Error: rows = [] if rows: lines.append("recent:") for item in rows: lines.append("%s %s %s %s" % ("*" if item[0] == sid else " ", item[0], item[1] or "?", item[2] or "")) return "\n".join(lines) class Config: def __init__(self, args): self.home = os.environ.get("TAI_HOME") or os.path.join(os.path.expanduser("~"), ".tai") self.profiles_dir = os.path.join(self.home, "profiles") self.audio_dir = os.path.join(self.home, "audio") self.db_path = os.path.join(self.home, "memory.db") self.history_path = os.path.join(self.home, "history") self.model = os.environ.get("TAI_MODEL") or DEFAULT_MODEL self.explicit_model = os.environ.get("TAI_MODEL") or "" self.backends = parse_backends(os.environ.get("TAI_BACKENDS")) self.openrouter_key = os.environ.get("OPENROUTER_API_KEY") or "" self.openrouter_models = split_models(os.environ.get("TAI_OPENROUTER_MODELS")) self.retry_seconds = float(os.environ.get("TAI_RETRY_SECONDS") or 0) if re.match(r"^\d+(\.\d+)?$", (os.environ.get("TAI_RETRY_SECONDS") or "0").strip()) else 0.0 self.opencode_enabled = (os.environ.get("TAI_OPENCODE") or "1").strip().lower() not in ("0", "false", "no", "off") self.no_opencode_cli = bool(getattr(args, "no_opencode", False)) if self.no_opencode_cli: self.opencode_enabled = False self.opencode_models = split_models(os.environ.get("TAI_OPENCODE_MODELS")) self.devplace_key = os.environ.get("DEVPLACE_API_KEY") or "" self.devplace_base = (os.environ.get("TAI_DEVPLACE_BASE") or DEVPLACE_BASE).rstrip("/") self.devplace_models = split_models(os.environ.get("TAI_DEVPLACE_MODELS")) self.openrouter_enabled = (os.environ.get("TAI_OPENROUTER") or "1").strip().lower() not in ("0", "false", "no", "off") self.pollinations_enabled = (os.environ.get("TAI_POLLINATIONS") or "1").strip().lower() not in ("0", "false", "no", "off") self.pollinations_models = split_models(os.environ.get("TAI_POLLINATIONS_MODELS")) self.zen_enabled = (os.environ.get("TAI_ZEN") or "").strip().lower() in ("1", "true", "yes", "on") self.zen_models = split_models(os.environ.get("TAI_ZEN_MODELS")) self.voice = os.environ.get("TAI_VOICE") or DEFAULT_VOICE self.auto_approve = bool(args.yes) self.profile = args.profile or "default" os.makedirs(self.profiles_dir, exist_ok=True) os.makedirs(self.audio_dir, exist_ok=True) def apply_store_settings(self, store): """Second boot pass: fill fields the environment left unset from the vault settings table. Env always wins; a CLI --no-opencode wins over the stored opencode flag too. Returns the applied key names.""" rows = dict((row["key"], row) for row in store.get_or_create_settings(scope="global")) applied = [] for attr, key, env_var, kind in CONFIG_SETTING_FIELDS: if os.environ.get(env_var): continue if key == "opencode_enabled" and self.no_opencode_cli: continue stored = rows[key]["stored"] if stored is None: continue if kind == "bool": setattr(self, attr, stored == "1") elif kind == "float": try: setattr(self, attr, float(stored)) except ValueError: continue elif kind == "models": setattr(self, attr, split_models(stored)) elif kind == "backends": setattr(self, attr, parse_backends(stored)) elif kind == "rstrip": setattr(self, attr, stored.rstrip("/")) else: setattr(self, attr, stored) if key == "model": self.explicit_model = stored applied.append(key) return applied SETTING_TRUE_WORDS = ("1", "true", "yes", "on") SETTING_FALSE_WORDS = ("0", "false", "no", "off") SETTING_FLOAT_RE = re.compile(r"^\d+(\.\d+)?$") SETTING_SCOPE_GLOBAL = "global" def setting_spec(key, env, kind, default, desc, required=False, nullable=False, options=None, preferred=None, encrypted=False, true_words=None, false_words=None): return {"key": key, "env": env, "type": kind, "default": default, "desc": desc, "required": required, "nullable": nullable, "options": options, "preferred": preferred, "encrypted": encrypted, "true_words": true_words, "false_words": false_words} SETTINGS_CATALOG = dict((spec["key"], spec) for spec in ( setting_spec("model", "TAI_MODEL", "string", DEFAULT_MODEL, "Default model id. A stored pin counts as explicit: per-directory model defaults do not override it.", required=True), setting_spec("backends", "TAI_BACKENDS", "string", None, "Extra OpenAI-compatible backends. Semicolon-separated label|base|key[|model][|profile] entries; a key starting with $ reads another env var. Holds secrets: encrypted at rest.", nullable=True, encrypted=True), setting_spec("openrouter_key", "OPENROUTER_API_KEY", "string", None, "OpenRouter API key. Most free models work without one; some require it. Encrypted at rest.", nullable=True, encrypted=True), setting_spec("openrouter_models", "TAI_OPENROUTER_MODELS", "string", None, "Comma-separated OpenRouter model ids to offer. Null means live discovery.", nullable=True), setting_spec("openrouter_enabled", "TAI_OPENROUTER", "bool", True, "Offer the OpenRouter backend."), setting_spec("opencode_enabled", "TAI_OPENCODE", "bool", True, "Offer the opencode backend. CLI --no-opencode always wins over this stored flag."), setting_spec("opencode_models", "TAI_OPENCODE_MODELS", "string", None, "Comma-separated opencode model ids to offer. Null means live discovery.", nullable=True), setting_spec("devplace_key", "DEVPLACE_API_KEY", "string", None, "DevPlace API key. Null disables the DevPlace fallback backend. Encrypted at rest.", nullable=True, encrypted=True), setting_spec("devplace_base", "TAI_DEVPLACE_BASE", "string", DEVPLACE_BASE, "DevPlace API base URL. Trailing slashes are stripped."), setting_spec("devplace_models", "TAI_DEVPLACE_MODELS", "string", None, "Comma-separated DevPlace model ids to offer. Null means live discovery.", nullable=True), setting_spec("pollinations_enabled", "TAI_POLLINATIONS", "bool", True, "Offer the Pollinations backend."), setting_spec("pollinations_models", "TAI_POLLINATIONS_MODELS", "string", None, "Comma-separated Pollinations model ids to offer. Null means live discovery.", nullable=True), setting_spec("zen_enabled", "TAI_ZEN", "bool", False, "Opt in to the opencode.ai zen backend."), setting_spec("zen_models", "TAI_ZEN_MODELS", "string", None, "Comma-separated zen model ids to offer. Null means live discovery.", nullable=True), setting_spec("retry_seconds", "TAI_RETRY_SECONDS", "float", 0.0, "Seconds to keep retrying all backends on total failure. 0 means forever. Plain decimal, no units."), setting_spec("voice", "TAI_VOICE", "string", DEFAULT_VOICE, "Edge TTS voice name for the speak tool."), setting_spec("theme", "TAI_THEME", "string", "dark", "TUI theme. Strict: must be one of the listed options. CLI --theme wins over env and vault.", options=["dark", "light", "nord", "gruvbox", "ansi"]), setting_spec("gemini_plan", "TAI_GEMINI_PLAN", "bool", False, "Use the Gemini CLI plan approval variant first.", true_words=SETTING_TRUE_WORDS + ("plan",)), setting_spec("codex_sandbox", "TAI_CODEX_SANDBOX", "bool", False, "Re-enable Codex CLI sandbox flags.", true_words=SETTING_TRUE_WORDS + ("read-only", "readonly")), setting_spec("box_caps", "TAI_BOX_CAPS", "bool", True, "Apply resource caps to pre-existing sandbox boxes. Escape hatch: false skips the live update. Env honors only 0 as false.", false_words=("0",)), setting_spec("muse_bin", "MUSE_BIN", "string", None, "Absolute muse CLI path override. Null means PATH lookup.", nullable=True), setting_spec("opencode_bin", "OPENCODE_BIN", "string", None, "Absolute opencode CLI path override. Null means PATH lookup.", nullable=True), setting_spec("grok_bin", "GROK_BIN", "string", None, "Absolute grok CLI path override (falls back to grokcli). Null means PATH lookup.", nullable=True), setting_spec("claude_bin", "CLAUDE_BIN", "string", None, "Absolute claude CLI path override. Null means PATH lookup.", nullable=True), setting_spec("codex_bin", "CODEX_BIN", "string", None, "Absolute codex CLI path override. Null means PATH lookup.", nullable=True), setting_spec("gemini_bin", "GEMINI_BIN", "string", None, "Absolute gemini CLI path override. Null means PATH lookup.", nullable=True), setting_spec("opencode_client_name", "OPENCODE_CLIENT_NAME", "string", None, "x-opencode-client header override for the zen backend. Null means the built-in.", nullable=True), setting_spec("opencode_client_ua", "OPENCODE_CLIENT_USER_AGENT", "string", None, "User-Agent header override for the zen backend. Null means the built-in.", nullable=True), )) CONFIG_SETTING_FIELDS = ( ("model", "model", "TAI_MODEL", "str"), ("backends", "backends", "TAI_BACKENDS", "backends"), ("openrouter_key", "openrouter_key", "OPENROUTER_API_KEY", "str"), ("openrouter_models", "openrouter_models", "TAI_OPENROUTER_MODELS", "models"), ("retry_seconds", "retry_seconds", "TAI_RETRY_SECONDS", "float"), ("opencode_enabled", "opencode_enabled", "TAI_OPENCODE", "bool"), ("opencode_models", "opencode_models", "TAI_OPENCODE_MODELS", "models"), ("devplace_key", "devplace_key", "DEVPLACE_API_KEY", "str"), ("devplace_base", "devplace_base", "TAI_DEVPLACE_BASE", "rstrip"), ("devplace_models", "devplace_models", "TAI_DEVPLACE_MODELS", "models"), ("openrouter_enabled", "openrouter_enabled", "TAI_OPENROUTER", "bool"), ("pollinations_enabled", "pollinations_enabled", "TAI_POLLINATIONS", "bool"), ("pollinations_models", "pollinations_models", "TAI_POLLINATIONS_MODELS", "models"), ("zen_enabled", "zen_enabled", "TAI_ZEN", "bool"), ("zen_models", "zen_models", "TAI_ZEN_MODELS", "models"), ("voice", "voice", "TAI_VOICE", "str"), ) SETTING_EXPORT_KEYS = ("theme", "muse_bin", "opencode_bin", "grok_bin", "claude_bin", "codex_bin", "gemini_bin", "gemini_plan", "codex_sandbox", "box_caps", "opencode_client_name", "opencode_client_ua") def validate_setting_scope(scope): if scope != SETTING_SCOPE_GLOBAL and not PROFILE_RE.match(scope or ""): raise ValueError("invalid settings scope %r" % (scope,)) return scope def normalize_setting_value(key, spec, value): """Validate an upsert value, return its canonical stored string (or None). Canonical forms: bool as 1/0, float as str(float), strings raw. Legacy env spellings (read-only, plan) stay readable from env but are never written.""" if value is None: if spec["nullable"]: return None raise ValueError("setting '%s' is not nullable" % key) kind = spec["type"] if kind == "bool": if value is True or value == 1 and not isinstance(value, str): return "1" if value is False or value == 0 and not isinstance(value, str): return "0" text = str(value).strip().lower() if isinstance(value, str) else "" if text in SETTING_TRUE_WORDS: return "1" if text in SETTING_FALSE_WORDS: return "0" raise ValueError("setting '%s' wants a bool (1/0, true/false, yes/no, on/off)" % key) if kind == "float": if isinstance(value, bool): raise ValueError("setting '%s' wants a float, not a bool" % key) try: number = float(value) except (TypeError, ValueError): raise ValueError("setting '%s' wants a float" % key) if not math.isfinite(number) or number < 0: raise ValueError("setting '%s' wants a finite float >= 0" % key) return str(number) if not isinstance(value, str): raise ValueError("setting '%s' wants a string" % key) if spec["required"] and not value: raise ValueError("setting '%s' is required and cannot be empty" % key) if spec["options"] is not None and value not in spec["options"]: raise ValueError("setting '%s' must be one of: %s" % (key, ", ".join(spec["options"]))) return value def parse_setting_env(spec, raw): """Parse a non-empty env value with the same semantics as the readers.""" default = spec["default"] if spec["type"] == "bool": text = raw.strip().lower() if text in (spec["true_words"] or SETTING_TRUE_WORDS): return True if text in (spec["false_words"] or SETTING_FALSE_WORDS): return False return default if spec["type"] == "float": if SETTING_FLOAT_RE.match(raw.strip()): return float(raw.strip()) return default if spec["options"] is not None and raw not in spec["options"]: raise ValueError("setting '%s' must be one of: %s (from %s)" % (spec["key"], ", ".join(spec["options"]), spec["env"])) return raw def parse_setting_stored(spec, stored): """Parse a canonical stored string back to its python value.""" if spec["type"] == "bool": return stored == "1" if spec["type"] == "float": return float(stored) return stored def export_settings_to_env(store, keys=None): """Fill unset env vars from vault settings. Returns the exported key names. Only fills: a set (non-empty) env var always wins. Stored values are canonical, so every existing reader parses them exactly like env input.""" rows = dict((row["key"], row) for row in store.get_or_create_settings(scope=SETTING_SCOPE_GLOBAL)) exported = [] for key in keys or SETTING_EXPORT_KEYS: spec = SETTINGS_CATALOG[key] if os.environ.get(spec["env"]): continue if rows[key]["stored"] is None: continue os.environ[spec["env"]] = rows[key]["stored"] exported.append(key) return exported def boot_settings(config, store): """Apply vault settings at boot: Config fields plus env export for the peripheral keys and every applied Config key (so nested processes agree). Returns the sorted names that took effect from the vault.""" applied = config.apply_store_settings(store) keys = list(SETTING_EXPORT_KEYS) + [key for key in applied if key not in SETTING_EXPORT_KEYS] if getattr(config, "no_opencode_cli", False) and "opencode_enabled" in keys: keys.remove("opencode_enabled") exported = export_settings_to_env(store, keys=keys) return sorted(set(applied + exported)) def ensure_column(db, table, column, ddl): names = [row[1] for row in db.execute("PRAGMA table_info(%s)" % table).fetchall()] if column not in names: db.execute("ALTER TABLE %s ADD COLUMN %s %s" % (table, column, ddl)) def fts_escape(query): terms = re.findall(r"[a-z0-9]+", (query or "").lower()) if not terms: return None return ['"%s"' % term for term in terms] def fts_match(terms): return " AND ".join("{title body} : %s" % term for term in terms) def fts_match_or(terms): return " OR ".join("{title body} : %s" % term for term in terms) def fts_match_prefix(terms): return " AND ".join("{title body} : %s*" % term.strip('"') for term in terms) def fts_match_variants(terms): """MATCH strings from strict to lenient: AND, prefix-AND, OR.""" variants = [fts_match(terms), fts_match_prefix(terms)] if len(terms) > 1: variants.append(fts_match_or(terms)) return variants def fts_rank(table): """Weighted bm25() over the fts_docs-shaped columns (title, body).""" return "bm25(%s, 1.0, 1.0, 1.0, %s, %s)" % (table, FTS_TITLE_WEIGHT, FTS_BODY_WEIGHT) MEM_EVENTS_CAP = 10000 def open_mem_events(): mem = sqlite3.connect(":memory:") try: mem.execute("CREATE VIRTUAL TABLE mem_events USING fts5(item, kind, sub, title, body, profile, tokenize='porter unicode61 remove_diacritics 2')") except sqlite3.Error: mem.execute("CREATE VIRTUAL TABLE mem_events USING fts5(item, kind, sub, title, body, profile)") return mem LOG_DB_KEEP = 20000 LOG_DB_PRUNE_EVERY = 256 LOG_MSG_MAX = 2000 LOG_TEXT_MAX = 4000 LOG_PREVIEW = 300 LOG_META_MAX = 16384 LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR") LOG_MASKED_ARGS = frozenset(("value", "password", "passphrase", "token", "secret", "key", "auth", "authorization")) class DebugLog(object): """Extensive debug logging: one line per event to a sink, plus the vault logs table. Full rules: logging.md.""" def __init__(self): self.enabled = False self.sink = "file" self.format = "sentence" self.home = "" self.handle = None self.wiped = False self.run = uuid.uuid4().hex[:8] self.store = None self.db = None self.db_count = 0 self.dropped = 0 self.lock = threading.RLock() self.context = {} self.old_excepthook = None def configure(self, sink="file", fmt="sentence", home="", store=None): self.reset() self.sink = sink if sink in ("file", "stdout", "stderr") else "file" self.format = fmt if fmt in ("sentence", "json") else "sentence" self.home = home self.enabled = True if store is not None: self.attach_store(store) self.old_excepthook = sys.excepthook sys.excepthook = self._excepthook self.prune() def reset(self): try: self.close() except Exception: pass if self.old_excepthook is not None: try: sys.excepthook = self.old_excepthook except Exception: pass self.enabled = False self.sink = "file" self.format = "sentence" self.home = "" self.handle = None self.wiped = False self.run = uuid.uuid4().hex[:8] self.store = None self.db = None self.db_count = 0 self.dropped = 0 self.context = {} self.old_excepthook = None def attach_store(self, store): self.store = store try: self.db = sqlite3.connect(store.config.db_path, timeout=30, check_same_thread=False) self.db.execute("PRAGMA journal_mode=WAL") self.db.execute("PRAGMA busy_timeout=30000") self.db.create_function("tai_enc", 1, store.seal.lock) except sqlite3.Error: self.db = None def close(self): handle, self.handle = self.handle, None if handle is not None and self.sink == "file": try: handle.close() except (OSError, ValueError): pass db, self.db = self.db, None if db is not None: try: db.commit() db.close() except sqlite3.Error: pass def _open_handle(self): if self.sink == "stdout": return sys.stdout if self.sink == "stderr": return sys.stderr os.makedirs(self.home, exist_ok=True) if not self.wiped and not os.environ.get("TAI_CHILD"): mode = "w" else: mode = "a" self.wiped = True return open(os.path.join(self.home, "debug.log"), mode, encoding="utf-8") @staticmethod def flat(text): return " ".join(str(text or "").split()) def scrub(self, text, profile): if not isinstance(text, str) or not text: return text if self.store is None: return text try: return self.store.redact(text, profile or None) except Exception: return text def scrub_deep(self, value, profile, limit, depth=0): if isinstance(value, str): clean = self.scrub(value, profile) if len(clean) > limit: clean = clean[:limit] + "..." return self.flat(clean) if depth >= 4: return short_error(value) if isinstance(value, list): return [self.scrub_deep(item, profile, limit, depth + 1) for item in value[:100]] if isinstance(value, dict): return dict((str(key), self.scrub_deep(value[key], profile, limit, depth + 1)) for key in sorted(value)) return value def cut_meta(self, meta, profile, limit): return dict((str(key), self.scrub_deep((meta or {})[key], profile, limit)) for key in sorted((meta or {}))) def pack_meta(self, meta): blob = json.dumps(meta, sort_keys=True) if len(blob) <= LOG_META_MAX: return blob slim = dict(meta) for _pass in range(4): big = [key for key in slim if isinstance(slim[key], str) and len(slim[key]) > 100] if not big: break for key in big: slim[key] = slim[key][:len(slim[key]) // 2] + "..." blob = json.dumps(slim, sort_keys=True) if len(blob) <= LOG_META_MAX: return blob return json.dumps({"truncated": True, "keys": sorted(meta)}, sort_keys=True) @staticmethod def quote(value): if not isinstance(value, str): try: return json.dumps(value, sort_keys=True) except (TypeError, ValueError): return short_error(value) if value == "" or any(char.isspace() or char in "\"\\" for char in value): return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"" return value def sentence(self, ts, level, run, profile, bot, chat, component, event, message, meta): parts = [ts, level, "run=%s" % run, "profile=%s" % self.quote(profile), "bot=%s" % self.quote(bot), "chat=%s" % self.quote(chat), "%s.%s" % (component, event)] parts += ["%s=%s" % (key, self.quote(meta[key])) for key in sorted(meta)] return " ".join(parts) + " :: " + message @staticmethod def json_line(ts, level, run, profile, bot, chat, component, event, message, meta): return json.dumps({"ts": ts, "level": level, "run": run, "profile": profile, "bot": bot, "chat": chat, "component": component, "event": event, "message": message, "meta": meta}, sort_keys=True) def event(self, level, component, event, message, profile="", bot="", chat="", meta=None): if not self.enabled: return try: with self.lock: profile = profile or self.context.get("profile", "") bot = bot or self.context.get("bot", "") chat = chat or self.context.get("chat", "") clean = self.flat(self.scrub(message, profile))[:LOG_MSG_MAX] db_meta = self.cut_meta(meta, profile, LOG_TEXT_MAX) show_meta = self.cut_meta(meta, profile, LOG_PREVIEW) ts = datetime.now(timezone.utc).isoformat() if self.format == "json": line = self.json_line(ts, level, self.run, profile, bot, chat, component, event, clean, show_meta) else: line = self.sentence(ts, level, self.run, profile, bot, chat, component, event, clean, show_meta) if self.handle is None: self.handle = self._open_handle() self.handle.write(line + "\n") self.handle.flush() if self.db is not None: blob = self.pack_meta(db_meta) if self.store is not None and self.store.seal.enabled: self.db.execute("INSERT INTO logs (profile, bot, chat, ts, run, level, component, event, message, meta_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, tai_enc(?), tai_enc(?))", (profile, bot, chat, ts, self.run, level, component, event, clean, blob)) else: self.db.execute("INSERT INTO logs (profile, bot, chat, ts, run, level, component, event, message, meta_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (profile, bot, chat, ts, self.run, level, component, event, clean, blob)) self.db_count += 1 self.db.commit() if self.db_count % LOG_DB_PRUNE_EVERY == 0: self.prune() except Exception: self.dropped += 1 def prune(self): if self.db is None: return try: with self.lock: self.db.execute("DELETE FROM logs WHERE id NOT IN (SELECT id FROM logs ORDER BY id DESC LIMIT ?)", (LOG_DB_KEEP,)) self.db.commit() except sqlite3.Error: pass def _excepthook(self, typ, value, tb): try: if not issubclass(typ, (KeyboardInterrupt, SystemExit, GeneratorExit)): self.event("ERROR", "app", "exception", "uncaught %s" % short_error(value), meta={"error": "%s: %s" % (typ.__name__, short_error(value))}) except Exception: pass if self.old_excepthook is not None: try: self.old_excepthook(typ, value, tb) except Exception: pass LOG = DebugLog() def setup_fts(db): try: exists = db.execute("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'fts_docs'").fetchone() if not exists: try: db.execute("CREATE VIRTUAL TABLE fts_docs USING fts5(item, kind, sub, title, body, profile, tokenize='porter unicode61 remove_diacritics 2')") except sqlite3.Error: db.execute("CREATE VIRTUAL TABLE fts_docs USING fts5(item, kind, sub, title, body, profile)") db.execute("""CREATE TRIGGER IF NOT EXISTS trg_records_ai AFTER INSERT ON records BEGIN INSERT INTO fts_docs(item, kind, sub, title, body, profile) VALUES(new.id, 'record', new.kind, new.title, new.content, new.profile); END""") db.execute("""CREATE TRIGGER IF NOT EXISTS trg_records_au AFTER UPDATE ON records WHEN old.title != new.title OR old.content != new.content OR old.profile != new.profile BEGIN DELETE FROM fts_docs WHERE item = old.id AND kind = 'record'; INSERT INTO fts_docs(item, kind, sub, title, body, profile) VALUES(new.id, 'record', new.kind, new.title, new.content, new.profile); END""") db.execute("""CREATE TRIGGER IF NOT EXISTS trg_records_ad AFTER DELETE ON records BEGIN DELETE FROM fts_docs WHERE item = old.id AND kind = 'record'; END""") db.execute("""CREATE TRIGGER IF NOT EXISTS trg_events_ai AFTER INSERT ON events BEGIN INSERT INTO fts_docs(item, kind, sub, title, body, profile) VALUES(new.id, 'event', new.role || '/' || new.kind, new.ts || ' ' || new.role || '/' || new.kind, new.text, new.profile); END""") db.execute("""CREATE TRIGGER IF NOT EXISTS trg_events_au AFTER UPDATE ON events WHEN old.text != new.text OR old.profile != new.profile BEGIN DELETE FROM fts_docs WHERE item = old.id AND kind = 'event'; INSERT INTO fts_docs(item, kind, sub, title, body, profile) VALUES(new.id, 'event', new.role || '/' || new.kind, new.ts || ' ' || new.role || '/' || new.kind, new.text, new.profile); END""") db.execute("""CREATE TRIGGER IF NOT EXISTS trg_audit_ai AFTER INSERT ON audit BEGIN INSERT INTO fts_docs(item, kind, sub, title, body, profile) VALUES('audit:' || new.id, 'audit', new.action, new.path, new.message, new.profile); END""") db.execute("""CREATE TRIGGER IF NOT EXISTS trg_chunks_ai AFTER INSERT ON chunks BEGIN INSERT INTO fts_docs(item, kind, sub, title, body, profile) VALUES('chunk:' || new.id, 'chunk', new.parent_kind, new.parent_id, new.text, new.profile); END""") db.execute("""CREATE TRIGGER IF NOT EXISTS trg_chunks_ad AFTER DELETE ON chunks BEGIN DELETE FROM fts_docs WHERE item = 'chunk:' || old.id AND kind = 'chunk'; END""") db.execute("""CREATE TRIGGER IF NOT EXISTS trg_logs_ai AFTER INSERT ON logs BEGIN INSERT INTO fts_docs(item, kind, sub, title, body, profile) VALUES('log:' || new.id, 'log', new.component || '/' || new.event, new.ts || ' ' || new.level || ' ' || new.component || '/' || new.event, new.message, new.profile); END""") db.execute("""CREATE TRIGGER IF NOT EXISTS trg_logs_ad AFTER DELETE ON logs BEGIN DELETE FROM fts_docs WHERE item = 'log:' || old.id AND kind = 'log'; END""") if db.execute("SELECT COUNT(*) FROM fts_docs").fetchone()[0] == 0: db.execute("INSERT INTO fts_docs(item, kind, sub, title, body, profile) SELECT id, 'record', kind, title, content, profile FROM records") db.execute("INSERT INTO fts_docs(item, kind, sub, title, body, profile) SELECT id, 'event', role || '/' || kind, ts || ' ' || role || '/' || kind, text, profile FROM events") db.execute("INSERT INTO fts_docs(item, kind, sub, title, body, profile) SELECT 'audit:' || id, 'audit', action, path, message, profile FROM audit") db.commit() return True except sqlite3.Error: return False def table_pk_columns(db, table): for row in db.execute("PRAGMA index_list(%s)" % table).fetchall(): if row[3] == "pk": return [info[2] for info in db.execute("PRAGMA index_info(%s)" % row[1]).fetchall()] return [] def rebuild_with_profile(db, table, columns, pk): if table_pk_columns(db, table) == list(pk): return False names = [column.split()[0] for column in columns] have = {row[1] for row in db.execute("PRAGMA table_info(%s)" % table).fetchall()} keep = [name for name in names if name in have] db.execute("CREATE TABLE %s_new (%s, PRIMARY KEY (%s))" % (table, ", ".join(columns), ", ".join(pk))) if keep: db.execute("INSERT OR IGNORE INTO %s_new (%s) SELECT %s FROM %s" % (table, ", ".join(keep), ", ".join(keep), table)) db.execute("DROP TABLE %s" % table) db.execute("ALTER TABLE %s_new RENAME TO %s" % (table, table)) return True def migrate_profiles(db): for table in ("secrets", "records", "tags", "edges", "audit", "events", "schedules"): ensure_column(db, table, "profile", "TEXT") db.execute("UPDATE %s SET profile = 'default' WHERE profile IS NULL" % table) rebuild_with_profile(db, "secrets", ("profile TEXT", "name TEXT", "value TEXT", "meta TEXT", "updated TEXT"), ("profile", "name")) rebuild_with_profile(db, "tags", ("item TEXT", "tag TEXT", "profile TEXT"), ("item", "tag", "profile")) rebuild_with_profile(db, "edges", ("src TEXT", "dst TEXT", "relation TEXT", "profile TEXT", "created TEXT"), ("src", "dst", "relation", "profile")) SCHEMA = ( ("events", ("id INTEGER PRIMARY KEY", "profile TEXT", "ts TEXT", "role TEXT", "kind TEXT", "text TEXT", "tags TEXT"), ()), ("secrets", ("profile TEXT", "name TEXT", "value TEXT", "meta TEXT", "updated TEXT"), ("profile", "name")), ("schedules", ("id INTEGER PRIMARY KEY", "name TEXT", "prompt TEXT", "profile TEXT", "every_sec INTEGER", "next_run TEXT", "timeout INTEGER", "status TEXT", "last_status TEXT", "last_result TEXT", "created TEXT", "updated TEXT"), ()), ("records", ("id TEXT PRIMARY KEY", "kind TEXT", "profile TEXT", "title TEXT", "content TEXT", "size INTEGER", "reads INTEGER", "created TEXT", "updated TEXT"), ()), ("tags", ("item TEXT", "tag TEXT", "profile TEXT"), ("item", "tag", "profile")), ("edges", ("src TEXT", "dst TEXT", "relation TEXT", "profile TEXT", "created TEXT"), ("src", "dst", "relation", "profile")), ("audit", ("id INTEGER PRIMARY KEY", "profile TEXT", "ts TEXT", "actor TEXT", "action TEXT", "path TEXT", "message TEXT", "old_size INTEGER", "new_size INTEGER", "old TEXT", "new TEXT", "tags TEXT"), ()), ("chunks", ("id INTEGER PRIMARY KEY", "parent_kind TEXT", "parent_id TEXT", "idx INTEGER", "text TEXT", "profile TEXT", "created TEXT"), ()), ("dir_defaults", ("dir TEXT PRIMARY KEY", "provider TEXT", "model TEXT", "bot TEXT", "updated TEXT"), ()), ("input_history", ("id INTEGER PRIMARY KEY", "profile TEXT", "bot TEXT", "ts TEXT", "text TEXT"), ()), ("shell_sessions", ("sid TEXT PRIMARY KEY", "parent TEXT", "shell TEXT", "pid INTEGER", "started TEXT", "last_seen TEXT", "cwd TEXT", "chain TEXT"), ()), ("settings", ("key TEXT", "scope TEXT", "value TEXT", "created TEXT", "updated TEXT"), ("key", "scope")), ("mcp_servers", ("name TEXT PRIMARY KEY", "transport TEXT", "command TEXT", "args_json TEXT", "env_json TEXT", "url TEXT", "headers_json TEXT", "cwd TEXT", "allow_json TEXT", "deny_json TEXT", "source TEXT", "created TEXT", "updated TEXT"), ()), ("mcp_server_profiles", ("server TEXT", "profile TEXT", "enabled INTEGER"), ("server", "profile")), ("mcp_tool_pins", ("server TEXT", "tool TEXT", "hash TEXT", "updated TEXT"), ("server", "tool")), ("mcp_server_tools", ("server TEXT", "tool TEXT", "description TEXT", "schema_json TEXT", "updated TEXT"), ("server", "tool")), ("logs", ("id INTEGER PRIMARY KEY", "profile TEXT", "bot TEXT", "chat TEXT", "ts TEXT", "run TEXT", "level TEXT", "component TEXT", "event TEXT", "message TEXT", "meta_json TEXT"), ()), ) SCHEMA_INDEXES = ( ("idx_events_profile", "events(profile)"), ("idx_tags_tag", "tags(tag)"), ("idx_edges_src", "edges(src)"), ("idx_edges_dst", "edges(dst)"), ("idx_audit_path", "audit(path)"), ("idx_tags_profile", "tags(profile)"), ("idx_edges_profile", "edges(profile)"), ("idx_records_profile", "records(profile)"), ("idx_audit_profile", "audit(profile)"), ("idx_chunks_parent", "chunks(parent_kind, parent_id)"), ("idx_chunks_profile", "chunks(profile)"), ("idx_input_history_profile", "input_history(profile, id)"), ("idx_shell_sessions_parent", "shell_sessions(parent)"), ("idx_logs_profile", "logs(profile, id)"), ("idx_logs_bot", "logs(bot)"), ("idx_logs_chat", "logs(chat)"), ("idx_logs_level", "logs(level)"), ("idx_logs_event", "logs(component, event)"), ("idx_logs_run", "logs(run)"), ) def ensure_schema(db): notes = [] for table, columns, pk in SCHEMA: exists = db.execute("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", (table,)).fetchone() if not exists: extra = ", PRIMARY KEY (%s)" % ", ".join(pk) if pk else "" db.execute("CREATE TABLE %s (%s%s)" % (table, ", ".join(columns), extra)) continue have = {row[1] for row in db.execute("PRAGMA table_info(%s)" % table).fetchall()} for column in columns: parts = column.split() if parts[0] not in have: db.execute("ALTER TABLE %s ADD COLUMN %s" % (table, " ".join(parts[:2]))) notes.append("added %s.%s" % (table, parts[0])) for index, target in SCHEMA_INDEXES: db.execute("CREATE INDEX IF NOT EXISTS %s ON %s" % (index, target)) db.commit() return notes INPUT_HISTORY_CAP = 1000 INPUT_HISTORY_MAX_CHARS = 4000 class Store: def __init__(self, config, seal): self.config = config self.seal = seal self.db = sqlite3.connect(config.db_path, timeout=30, check_same_thread=False) self.db.execute("PRAGMA journal_mode=WAL") self.db.execute("PRAGMA synchronous=NORMAL") self.db.execute("PRAGMA busy_timeout=30000") self.schema_notes = ensure_schema(self.db) migrate_profiles(self.db) self.db.commit() self.profile = config.profile self.secret_cache = {} self.fts_ok = setup_fts(self.db) if seal.enabled: self.db.create_function("tai_enc", 1, seal.lock) self.db.create_function("tai_dec", 1, seal.safe_unlock) self.db.execute("UPDATE events SET text = tai_enc(text) WHERE text NOT LIKE 'tai1$%'") self.db.execute("UPDATE secrets SET value = tai_enc(value) WHERE value NOT LIKE 'tai1$%'") self.db.execute("UPDATE secrets SET meta = tai_enc(COALESCE(meta, '{}')) WHERE meta IS NULL OR meta NOT LIKE 'tai1$%'") self.db.execute("UPDATE schedules SET prompt = tai_enc(prompt) WHERE prompt NOT LIKE 'tai1$%'") self.db.execute("UPDATE input_history SET text = tai_enc(text) WHERE text NOT LIKE 'tai1$%'") self.db.execute("UPDATE logs SET message = tai_enc(message) WHERE message NOT LIKE 'tai1$%'") self.db.execute("UPDATE logs SET meta_json = tai_enc(meta_json) WHERE meta_json NOT LIKE 'tai1$%'") sealed_keys = [key for key, spec in SETTINGS_CATALOG.items() if spec["encrypted"]] if sealed_keys: self.db.execute( "UPDATE settings SET value = tai_enc(value) WHERE key IN (%s) AND value IS NOT NULL AND value NOT LIKE 'tai1$%%'" % ", ".join("?" for _key in sealed_keys), sealed_keys) self.db.commit() self.seal_files() else: sealed = self.db.execute("SELECT COUNT(*) FROM events WHERE text LIKE 'tai1$%'").fetchone()[0] if sealed: raise SealError("memory is sealed, set TAI_PASSPHRASE") locked = self.db.execute("SELECT COUNT(*) FROM secrets WHERE value LIKE 'tai1$%' OR meta LIKE 'tai1$%'").fetchone()[0] if locked: raise SealError("secrets are sealed, set TAI_PASSPHRASE") planned = self.db.execute("SELECT COUNT(*) FROM schedules WHERE prompt LIKE 'tai1$%'").fetchone()[0] if planned: raise SealError("schedules are sealed, set TAI_PASSPHRASE") typed = self.db.execute("SELECT COUNT(*) FROM input_history WHERE text LIKE 'tai1$%'").fetchone()[0] if typed: raise SealError("input history is sealed, set TAI_PASSPHRASE") configured = self.db.execute("SELECT COUNT(*) FROM settings WHERE value LIKE 'tai1$%'").fetchone()[0] if configured: raise SealError("settings are sealed, set TAI_PASSPHRASE") bridged = self.db.execute("SELECT COUNT(*) FROM mcp_servers WHERE headers_json LIKE 'tai1$%'").fetchone()[0] if bridged: raise SealError("mcp headers are sealed, set TAI_PASSPHRASE") self.memdb = None self.mem_events_max = 0 if seal.enabled and self.fts_ok: try: self.memdb = open_mem_events() floor = self.db.execute("SELECT COALESCE(MAX(id), 0) FROM events").fetchone()[0] - MEM_EVENTS_CAP self.mem_events_max = max(0, floor) self._sync_mem_events() except sqlite3.Error: self.memdb = None def _sync_mem_events(self): if self.memdb is None: return try: rows = self.db.execute("SELECT id, profile, ts, role, kind, tai_dec(text) FROM events WHERE id > ? ORDER BY id ASC", (self.mem_events_max,)).fetchall() except sqlite3.Error: return if not rows: return try: self.memdb.executemany("INSERT INTO mem_events(item, kind, sub, title, body, profile) VALUES (?, 'event', ? || '/' || ?, ? || ' ' || ? || '/' || ?, ?, ?)", [(row[0], row[3], row[4], row[2], row[3], row[4], row[5], row[1]) for row in rows]) self.memdb.commit() except sqlite3.Error: return self.mem_events_max = rows[-1][0] try: self.memdb.execute("DELETE FROM mem_events WHERE CAST(item AS INTEGER) <= ?", (self.mem_events_max - MEM_EVENTS_CAP,)) self.memdb.commit() except sqlite3.Error: pass def seal_files(self): for entry in os.listdir(self.config.profiles_dir): if not entry.endswith((".sys.md", ".session.json", ".bots.json")): continue path = os.path.join(self.config.profiles_dir, entry) try: with open(path, "r", encoding="utf-8") as handle: raw = handle.read() except OSError: continue if raw.startswith(Seal.PREFIX): continue try: with open(path, "w", encoding="utf-8") as handle: handle.write(self.seal.lock(raw)) except OSError: pass def history_last(self, profile): row = self.db.execute("SELECT %s FROM input_history WHERE profile = ? ORDER BY id DESC LIMIT 1" % ("tai_dec(text)" if self.seal.enabled else "text"), (profile,)).fetchone() return row[0] if row else None def history_add(self, profile, bot, text): """Record one typed input for the profile. Sealed like events, one profile list shared by every bot (readline habit), the bot kept as a column for filtering. Skips empty text and an immediate repeat of the last entry; trims the profile to INPUT_HISTORY_CAP rows in the same transaction. Returns True when a row was written.""" value = str(text or "").strip()[:INPUT_HISTORY_MAX_CHARS] if not value: return False try: if self.history_last(profile) == value: return False if self.seal.enabled: self.db.execute("INSERT INTO input_history (profile, bot, ts, text) VALUES (?, ?, ?, tai_enc(?))", (profile, bot, now_iso(), value)) else: self.db.execute("INSERT INTO input_history (profile, bot, ts, text) VALUES (?, ?, ?, ?)", (profile, bot, now_iso(), value)) self.db.execute("DELETE FROM input_history WHERE profile = ? AND id <= COALESCE((SELECT id FROM input_history WHERE profile = ? ORDER BY id DESC LIMIT 1 OFFSET ?), 0)", (profile, profile, INPUT_HISTORY_CAP)) self.db.commit() return True except sqlite3.Error: return False def history_load(self, profile, limit=INPUT_HISTORY_CAP): """Entries oldest to newest. A profile with no rows yet imports the old plain-text readline file once, so earlier REPL history is kept.""" limit = max(1, min(int(limit), INPUT_HISTORY_CAP)) try: count = self.db.execute("SELECT COUNT(*) FROM input_history WHERE profile = ?", (profile,)).fetchone()[0] if count == 0: self.history_import_file(profile, self.config.history_path) rows = self.db.execute("SELECT %s FROM input_history WHERE profile = ? ORDER BY id DESC LIMIT ?" % ("tai_dec(text)" if self.seal.enabled else "text"), (profile, limit)).fetchall() except sqlite3.Error: return [] return [row[0] for row in reversed(rows)] def history_import_file(self, profile, path): try: with open(path, "r", encoding="utf-8", errors="replace") as handle: lines = [line.rstrip("\n") for line in handle if line.strip()][-INPUT_HISTORY_CAP:] except OSError: return 0 written = 0 for line in lines: written += 1 if self.history_add(profile, "main", line) else 0 return written def log_event(self, profile, role, kind, text, tags=()): value = (text or "")[:2000] stamped = " " + " ".join([role, kind] + normalize_tags(tags)) + " " try: if self.seal.enabled: self.db.execute("INSERT INTO events (profile, ts, role, kind, text, tags) VALUES (?, ?, ?, ?, tai_enc(?), ?)", (profile, now_iso(), role, kind, value, stamped)) else: self.db.execute("INSERT INTO events (profile, ts, role, kind, text, tags) VALUES (?, ?, ?, ?, ?, ?)", (profile, now_iso(), role, kind, value, stamped)) self.db.commit() self._sync_mem_events() except sqlite3.Error: pass def log_query(self, level=None, component=None, event=None, profile=None, bot=None, chat=None, run=None, limit=100): """Read debug log rows newest first, seal-aware. Every filter hits an index.""" message_col = "tai_dec(message)" if self.seal.enabled else "message" meta_col = "tai_dec(meta_json)" if self.seal.enabled else "meta_json" query = "SELECT id, profile, bot, chat, ts, run, level, component, event, %s, %s FROM logs" % (message_col, meta_col) clauses, params = [], [] for column, value in (("level", level), ("component", component), ("event", event), ("profile", profile), ("bot", bot), ("chat", chat), ("run", run)): if value: clauses.append(column + " = ?") params.append(value) if clauses: query += " WHERE " + " AND ".join(clauses) query += " ORDER BY id DESC LIMIT ?" params.append(max(1, min(limit or 100, 1000))) try: rows = self.db.execute(query, params).fetchall() except sqlite3.Error: return [] found = [] for row in rows: try: meta = json.loads(row[10] or "{}") except ValueError: meta = {} found.append({"id": row[0], "profile": row[1], "bot": row[2], "chat": row[3], "ts": row[4], "run": row[5], "level": row[6], "component": row[7], "event": row[8], "message": row[9], "meta": meta if isinstance(meta, dict) else {}}) return found def fts_search(self, query, kinds=(), profile=None, limit=10): profile = self._scope(profile) if not self.fts_ok: return [] terms = fts_escape(query) if terms is None: return [] capped = max(1, min(limit, 100)) rank = fts_rank("fts_docs") sql = "SELECT item, kind, sub, title, snippet(fts_docs, 4, '[', ']', '...', 12), %s FROM fts_docs WHERE fts_docs MATCH ? AND profile = ?" % rank params_tail = [] if kinds: marks = ", ".join("?" * len(kinds)) sql += " AND kind IN (%s)" % marks params_tail += list(kinds) if self.seal.enabled: sql += " AND kind != 'event' AND kind != 'log'" sql += " ORDER BY %s LIMIT ?" % rank mem_rank = fts_rank("mem_events") mem_sql = "SELECT item, kind, sub, title, snippet(mem_events, 4, '[', ']', '...', 12), %s FROM mem_events WHERE mem_events MATCH ? AND profile = ? ORDER BY %s LIMIT ?" % (mem_rank, mem_rank) for match in fts_match_variants(terms): try: rows = self.db.execute(sql, tuple([match, profile] + params_tail + [capped])).fetchall() except sqlite3.Error: rows = [] hits = [{"item": row[0], "kind": row[1], "sub": row[2], "title": row[3], "snippet": row[4], "rank": row[5]} for row in rows] if self.seal.enabled and self.memdb is not None and (not kinds or "event" in kinds): self._sync_mem_events() try: mem_rows = self.memdb.execute(mem_sql, (match, profile, capped)).fetchall() except sqlite3.Error: mem_rows = [] hits += [{"item": row[0], "kind": row[1], "sub": row[2], "title": row[3], "snippet": row[4], "rank": row[5]} for row in mem_rows] hits.sort(key=lambda hit: hit["rank"]) hits = hits[:capped] if hits: return hits return [] def audit_search(self, query, profile=None, limit=20): profile = self._scope(profile) hits = self.fts_search(query, ("audit",), profile, limit) found = [] for hit in hits: try: row_id = int(str(hit["item"]).split(":", 1)[1]) except (IndexError, ValueError): continue row = self.audit_get(row_id, profile) if row is not None: row["snippet"] = hit["snippet"] found.append(row) return found def search_events(self, profile, query, limit=8, tags=()): clean = normalize_tags(tags) if self.fts_ok: hits = self.fts_search(query, ("event",), profile, max(limit * 5, 20)) if hits: ids = [int(hit["item"]) for hit in hits] marks = ", ".join("?" * len(ids)) text_col = "tai_dec(text)" if self.seal.enabled else "text" rows = {row[0]: row[1:] for row in self.db.execute("SELECT id, ts, role, kind, %s, tags FROM events WHERE id IN (%s)" % (text_col, marks), tuple(ids)).fetchall()} found = [] for hit in hits: row = rows.get(int(hit["item"])) if row is None: continue if clean and not all((" %s " % tag) in (" %s " % (row[4] or "")) for tag in clean): continue found.append(row[:4]) if len(found) >= limit: break if found: return found return self.events_like(profile, query, limit, clean) def events_like(self, profile, query, limit=8, tags=()): clean = normalize_tags(tags) extra = "".join(" AND tags LIKE ?" for _tag in clean) wild = tuple("% %s %%" % tag for tag in clean) if self.seal.enabled: return self.db.execute("SELECT ts, role, kind, text FROM (SELECT id, ts, role, kind, tags, tai_dec(text) AS text FROM events WHERE profile = ? ORDER BY id DESC LIMIT 5000) WHERE text LIKE ?" + extra + " ORDER BY id DESC LIMIT ?", (profile, "%" + query + "%") + wild + (limit,)).fetchall() return self.db.execute("SELECT ts, role, kind, text FROM events WHERE profile = ? AND text LIKE ?" + extra + " ORDER BY id DESC LIMIT ?", (profile, "%" + query + "%") + wild + (limit,)).fetchall() def _scope(self, profile): return profile or self.profile def save_secret(self, name, value, meta=None, tags=None, profile=None): profile = self._scope(profile) self.secret_cache.pop(profile, None) stored = self.seal.lock(value) if self.seal.enabled else value blob = json.dumps(meta or {}) locked_meta = self.seal.lock(blob) if self.seal.enabled else blob self.db.execute("INSERT OR REPLACE INTO secrets (profile, name, value, meta, updated) VALUES (?, ?, ?, ?, ?)", (profile, name, stored, locked_meta, now_iso())) if tags is None: self.tag_item("secret:" + name, ["secret"], profile) else: self.set_item_tags("secret:" + name, ["secret"] + list(tags), profile) self.db.commit() LOG.event("DEBUG", "vault", "secret_saved", "secret %s saved" % name, profile=profile, meta={"name": name}) def secret_meta(self, name, profile=None): profile = self._scope(profile) row = self.db.execute("SELECT meta FROM secrets WHERE profile = ? AND name = ?", (profile, name)).fetchone() if row is None or not row[0]: return {} raw = self.seal.safe_unlock(row[0]) if row[0].startswith(Seal.PREFIX) else row[0] try: data = json.loads(raw) except ValueError: return {} return data if isinstance(data, dict) else {} def list_secret_infos(self, profile=None): profile = self._scope(profile) rows = self.db.execute("SELECT name, meta, updated FROM secrets WHERE profile = ? ORDER BY name", (profile,)).fetchall() items = [] for name, meta, updated in rows: raw = None if meta: raw = self.seal.safe_unlock(meta) if meta.startswith(Seal.PREFIX) else meta try: data = json.loads(raw or "{}") except ValueError: data = {} items.append({"name": name, "meta": data if isinstance(data, dict) else {}, "tags": self.item_tags("secret:" + name, profile), "updated": updated}) return items def known_tags(self, profile=None): profile = self._scope(profile) return {row[0] for row in self.db.execute("SELECT DISTINCT tag FROM tags WHERE profile = ?", (profile,)).fetchall()} def canonical_tag(self, tag, vocab=None, profile=None): if vocab is None: vocab = self.known_tags(profile) if tag in vocab: return tag single = singular_noun(tag) if single != tag and single in vocab: return single return tag def canonical_tags(self, tags, vocab=None, profile=None): profile = self._scope(profile) if vocab is None: vocab = self.known_tags(profile) seen = [] for tag in normalize_tags(tags): clean = self.canonical_tag(tag, vocab) if clean not in seen: seen.append(clean) return seen def tag_counts(self, prefix="", limit=50, profile=None): profile = self._scope(profile) clean = normalize_tag(prefix) sql = "SELECT tag, COUNT(*) FROM tags WHERE profile = ?" params = [profile] if clean: sql += " AND tag LIKE ? ESCAPE '\\'" params.append(clean.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%") sql += " GROUP BY tag ORDER BY COUNT(*) DESC, tag LIMIT ?" params.append(max(1, min(limit, 200))) return [(row[0], row[1]) for row in self.db.execute(sql, tuple(params)).fetchall()] def auto_tags_for(self, text, explicit=(), vocab=None): if vocab is None: vocab = self.known_tags() skip = set(explicit) | set(TAG_AUTO_SKIP) words = re.findall(r"[a-z0-9]+", (text or "").lower()) found = [] for pos, word in enumerate(words): if len(found) >= TAG_AUTO_MAX: break if pos + 1 < len(words): pair = word + "-" + words[pos + 1] if pair in vocab and pair not in skip and pair not in found: found.append(pair) continue if word in vocab and word not in skip and word not in found: found.append(word) continue single = singular_noun(word) if single != word and single in vocab and single not in skip and single not in found: found.append(single) return found def tag_neighbors(self, record_id, limit=None, profile=None): profile = self._scope(profile) rows = self.db.execute("SELECT r.id, MIN(t.tag) FROM records r JOIN tags t ON t.item = r.id WHERE t.tag IN (SELECT tag FROM tags WHERE item = ? AND profile = ? AND tag NOT IN ('record', 'file')) AND r.id != ? AND r.profile = ? AND t.profile = ? GROUP BY r.id ORDER BY r.updated DESC LIMIT ?", (record_id, profile, record_id, profile, profile, max(1, limit or TAG_LINK_MAX))).fetchall() return [(row[0], row[1]) for row in rows] def tag_item(self, item, tags, profile=None): profile = self._scope(profile) for tag in self.canonical_tags(tags, None, profile): self.db.execute("INSERT OR IGNORE INTO tags (item, tag, profile) VALUES (?, ?, ?)", (item, tag, profile)) self.db.commit() def set_item_tags(self, item, tags, profile=None): profile = self._scope(profile) self.db.execute("DELETE FROM tags WHERE item = ? AND profile = ?", (item, profile)) for tag in self.canonical_tags(tags, None, profile): self.db.execute("INSERT OR IGNORE INTO tags (item, tag, profile) VALUES (?, ?, ?)", (item, tag, profile)) self.db.commit() def item_tags(self, item, profile=None): profile = self._scope(profile) return [row[0] for row in self.db.execute("SELECT tag FROM tags WHERE item = ? AND profile = ? ORDER BY tag", (item, profile)).fetchall()] def tagged_items(self, tags, profile=None): profile = self._scope(profile) clean = normalize_tags(tags) if not clean: return None marks = ", ".join("?" * len(clean)) rows = self.db.execute("SELECT item FROM tags WHERE profile = ? AND tag IN (%s) GROUP BY item HAVING COUNT(DISTINCT tag) = ?" % marks, (profile,) + tuple(clean) + (len(clean),)).fetchall() return [row[0] for row in rows] def has_node(self, kind, key, profile=None): profile = self._scope(profile) if kind == "mem": row = self.db.execute("SELECT 1 FROM records WHERE profile = ? AND id = ?", (profile, "mem:" + key,)).fetchone() elif kind == "secret": row = self.db.execute("SELECT 1 FROM secrets WHERE profile = ? AND name = ?", (profile, key,)).fetchone() else: row = self.db.execute("SELECT 1 FROM schedules WHERE profile = ? AND id = ?", (profile, int(key),)).fetchone() return row is not None def node_title(self, node, profile=None): profile = self._scope(profile) parsed = parse_node_id(node) if parsed is None: return node kind, key = parsed if kind == "mem": row = self.db.execute("SELECT title FROM records WHERE profile = ? AND id = ?", (profile, node,)).fetchone() return row[0] or node if row else node if kind == "secret": return "secret:" + key row = self.db.execute("SELECT name, prompt FROM schedules WHERE profile = ? AND id = ?", (profile, int(key),)).fetchone() if not row: return node return row[0] or "schedule #%s" % key def add_record(self, kind, title, content, tags=(), profile=None): profile = self._scope(profile) record_id = "mem:" + uuid.uuid4().hex[:16] while self.db.execute("SELECT 1 FROM records WHERE id = ?", (record_id,)).fetchone(): record_id = "mem:" + uuid.uuid4().hex[:16] stamp = datetime.now(timezone.utc).isoformat() size = len(content.encode("utf-8")) self.db.execute("INSERT INTO records(id, kind, profile, title, content, size, reads, created, updated) VALUES(?, ?, ?, ?, ?, ?, 0, ?, ?)", (record_id, kind, profile, title, content, size, stamp, stamp)) vocab = self.known_tags(profile) explicit = self.canonical_tags(tags, vocab, profile) scan = "%s\n%s" % (title, content.split("\n...[truncated", 1)[0]) auto = self.auto_tags_for(scan, explicit, vocab) self.set_item_tags(record_id, ["record"] + explicit + auto, profile) for other, shared in self.tag_neighbors(record_id, None, profile): self.db.execute("INSERT OR IGNORE INTO edges(src, dst, relation, profile, created) VALUES(?, ?, ?, ?, ?)", (record_id, other, "shares-" + shared, profile, stamp)) self.db.commit() LOG.event("DEBUG", "vault", "record_saved", "record %s saved (%s, %d bytes)" % (record_id, kind, size), profile=profile, meta={"id": record_id, "kind": kind, "size": size}) return record_id def add_chunks(self, parent_kind, parent_id, texts, profile=None): """Replaces any prior chunks for this parent, so re-indexing is idempotent.""" profile = self._scope(profile) self.db.execute("DELETE FROM chunks WHERE parent_kind = ? AND parent_id = ? AND profile = ?", (parent_kind, parent_id, profile)) stamp = datetime.now(timezone.utc).isoformat() for idx, text in enumerate(texts): self.db.execute("INSERT INTO chunks(parent_kind, parent_id, idx, text, profile, created) VALUES (?, ?, ?, ?, ?, ?)", (parent_kind, parent_id, idx, text, profile, stamp)) self.db.commit() def get_chunks_by_ids(self, ids, profile=None): if not ids: return {} profile = self._scope(profile) marks = ", ".join("?" * len(ids)) rows = self.db.execute("SELECT id, parent_kind, parent_id, idx, text FROM chunks WHERE profile = ? AND id IN (%s)" % marks, tuple([profile] + list(ids))).fetchall() return {row[0]: {"parent_kind": row[1], "parent_id": row[2], "idx": row[3], "text": row[4]} for row in rows} def get_record(self, record_id, profile=None): profile = self._scope(profile) row = self.db.execute("SELECT id, kind, title, content, size, reads, created, updated FROM records WHERE profile = ? AND id = ?", (profile, record_id,)).fetchone() if row is None: return None self.db.execute("UPDATE records SET reads = reads + 1 WHERE id = ?", (record_id,)) self.db.commit() return {"id": row[0], "kind": row[1], "title": row[2], "content": row[3], "size": as_int(row[4]), "reads": as_int(row[5]) + 1, "created": row[6], "updated": row[7], "tags": self.item_tags(row[0], profile)} def read_record(self, record_id, offset=0, limit=4000, profile=None): profile = self._scope(profile) row = self.db.execute("SELECT content FROM records WHERE profile = ? AND id = ?", (profile, record_id,)).fetchone() if row is None: return None content = row[0] total = len(content) start = max(0, min(offset, total)) end = min(total, start + max(1, limit)) self.db.execute("UPDATE records SET reads = reads + 1 WHERE id = ?", (record_id,)) self.db.commit() return {"id": record_id, "slice": content[start:end], "start": start, "end": end, "total": total, "tags": self.item_tags(record_id, profile)} def search_records(self, query="", kind=None, tags=(), limit=8, profile=None): profile = self._scope(profile) clean = normalize_tags(tags) terms = fts_escape(query) if query else None if terms is not None and self.fts_ok: ranked = self.search_records_fts(terms, kind, clean, limit, profile) if ranked: return ranked sql = "SELECT id, kind, title, size, reads, updated FROM records" clauses = ["profile = ?"] params = [profile] if kind: clauses.append("kind = ?") params.append(kind) if query: clauses.append("(title LIKE ? OR content LIKE ?)") params += ["%" + query + "%", "%" + query + "%"] if clean: for tag in clean: options = tag_variants(tag) marks = ", ".join("?" * len(options)) clauses.append("id IN (SELECT item FROM tags WHERE profile = ? AND tag IN (%s))" % marks) params += [profile] + options sql += " WHERE " + " AND ".join(clauses) sql += " ORDER BY updated DESC LIMIT ?" params.append(max(1, min(limit, 50))) rows = self.db.execute(sql, tuple(params)).fetchall() return [{"id": row[0], "kind": row[1], "title": row[2], "size": row[3], "reads": row[4], "updated": row[5], "tags": self.item_tags(row[0], profile)} for row in rows] def search_records_fts(self, terms, kind, clean, limit, profile): rank = fts_rank("f") sql = "SELECT r.id, r.kind, r.title, r.size, r.reads, r.updated FROM records r JOIN fts_docs f ON f.item = r.id AND f.kind = 'record' WHERE r.profile = ? AND f MATCH ?" params_tail = [] if kind: sql += " AND r.kind = ?" params_tail.append(kind) for tag in clean: options = tag_variants(tag) marks = ", ".join("?" * len(options)) sql += " AND r.id IN (SELECT item FROM tags WHERE profile = ? AND tag IN (%s))" % marks params_tail += [profile] + options sql += " ORDER BY %s LIMIT ?" % rank capped = max(1, min(limit, 50)) rows = [] for match in fts_match_variants(terms): try: rows = self.db.execute(sql, tuple([profile, match] + params_tail + [capped])).fetchall() except sqlite3.Error: rows = [] if rows: break return [{"id": row[0], "kind": row[1], "title": row[2], "size": row[3], "reads": row[4], "updated": row[5], "tags": self.item_tags(row[0], profile)} for row in rows] def delete_record(self, record_id, profile=None): profile = self._scope(profile) done = self.db.execute("DELETE FROM records WHERE profile = ? AND id = ?", (profile, record_id,)) if done.rowcount > 0: self.db.execute("DELETE FROM tags WHERE item = ? AND profile = ?", (record_id, profile)) self.db.execute("DELETE FROM edges WHERE profile = ? AND (src = ? OR dst = ?)", (profile, record_id, record_id)) self.db.execute("DELETE FROM chunks WHERE parent_kind = 'record' AND parent_id = ? AND profile = ?", (record_id, profile)) self.db.commit() LOG.event("DEBUG", "vault", "record_deleted", "record %s deleted" % record_id, profile=profile, meta={"id": record_id}) return True self.db.commit() return False def add_edge(self, src, dst, relation="linked", profile=None): profile = self._scope(profile) for node in (src, dst): parsed = parse_node_id(node) if parsed is None or not self.has_node(*parsed, profile): raise ValueError("unknown node " + node) clean = re.sub(r"[^a-z0-9]+", "-", str(relation or "").lower()).strip("-")[:32] or "linked" self.db.execute("INSERT OR IGNORE INTO edges(src, dst, relation, profile, created) VALUES(?, ?, ?, ?, ?)", (src, dst, clean, profile, datetime.now(timezone.utc).isoformat())) self.db.commit() return clean def edges_for(self, node, profile=None): profile = self._scope(profile) rows = self.db.execute("SELECT src, dst, relation FROM edges WHERE profile = ? AND (src = ? OR dst = ?)", (profile, node, node)).fetchall() return [{"relation": row[2], "other": row[1] if row[0] == node else row[0], "direction": "out" if row[0] == node else "in"} for row in rows] def remove_edges(self, node, profile=None): profile = self._scope(profile) done = self.db.execute("DELETE FROM edges WHERE profile = ? AND (src = ? OR dst = ?)", (profile, node, node)) self.db.commit() return done.rowcount def traverse(self, start, depth=2, limit=50, profile=None): profile = self._scope(profile) seen = {start} found = [{"node": start, "title": self.node_title(start, profile), "depth": 0, "via": ""}] frontier = [(start, 0, "")] capped = max(1, min(depth, 4)) while frontier and len(found) < max(1, min(limit, 200)): node, level, _via = frontier.pop(0) if level >= capped: continue for edge in self.edges_for(node, profile): other = edge["other"] if other in seen: continue seen.add(other) hop = "%s -[%s]-> %s" % (node, edge["relation"], other) if edge["direction"] == "out" else "%s <-[%s]- %s" % (node, edge["relation"], other) found.append({"node": other, "title": self.node_title(other, profile), "depth": level + 1, "via": hop}) frontier.append((other, level + 1, hop)) if len(found) >= max(1, min(limit, 200)): break return found def audit_event(self, actor, action, path, message="", old=None, new=None, tags=(), old_size=None, new_size=None, profile=None): profile = self._scope(profile) old_text, computed_old = capped_text(old) new_text, computed_new = capped_text(new) if old is not None and old_size is not None and old_size > len(old.encode("utf-8")): old_text = old[:AUDIT_MAX_CHARS] + trunc_marker(old_size) computed_old = old_size if new is not None and new_size is not None and new_size > len(new.encode("utf-8")): new_text = new[:AUDIT_MAX_CHARS] + trunc_marker(new_size) computed_new = new_size cursor = self.db.execute("INSERT INTO audit(profile, ts, actor, action, path, message, old_size, new_size, old, new, tags) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (profile, datetime.now(timezone.utc).isoformat(), actor, action, path, str(message or "")[:2000], computed_old, computed_new, old_text, new_text, " ".join(normalize_tags(tags)))) self.db.commit() return cursor.lastrowid def audit_history(self, path=None, tag=None, limit=20, profile=None): profile = self._scope(profile) sql = "SELECT id, ts, actor, action, path, message, old_size, new_size, tags FROM audit" clauses = ["profile = ?"] params = [profile] if path: clauses.append("path = ?") params.append(path) if tag: clauses.append("tags LIKE ?") params.append("%" + tag + "%") sql += " WHERE " + " AND ".join(clauses) sql += " ORDER BY id DESC LIMIT ?" params.append(max(1, min(limit, 100))) rows = self.db.execute(sql, tuple(params)).fetchall() return [{"id": row[0], "ts": row[1], "actor": row[2], "action": row[3], "path": row[4], "message": row[5], "old_size": row[6], "new_size": row[7], "tags": row[8]} for row in rows] def audit_get(self, row_id, profile=None): profile = self._scope(profile) row = self.db.execute("SELECT id, ts, actor, action, path, message, old_size, new_size, old, new, tags FROM audit WHERE profile = ? AND id = ?", (profile, row_id,)).fetchone() if row is None: return None return {"id": row[0], "ts": row[1], "actor": row[2], "action": row[3], "path": row[4], "message": row[5], "old_size": row[6], "new_size": row[7], "old": row[8], "new": row[9], "tags": row[10]} def file_record_id(self, path, profile=None): profile = self._scope(profile) row = self.db.execute("SELECT id FROM records WHERE profile = ? AND kind = 'file' AND title = ?", (profile, path,)).fetchone() return row[0] if row else None def upsert_file_record(self, path, content, true_size=None, profile=None): profile = self._scope(profile) full_bytes = len(content.encode("utf-8")) if true_size is not None and true_size > full_bytes: text = content + trunc_marker(true_size) elif len(content) <= FILE_RECORD_MAX: text = content else: text = content[:FILE_RECORD_MAX] + trunc_marker(full_bytes) ext = os.path.splitext(path)[1].lstrip(".").lower() explicit = ["file"] + ([ext] if ext and ext.isalnum() and len(ext) <= 5 else []) vocab = self.known_tags(profile) tags = explicit + self.auto_tags_for("%s\n%s" % (path, content), explicit, vocab) record_id = self.file_record_id(path, profile) stamp = datetime.now(timezone.utc).isoformat() if record_id is None: return self.add_record("file", path, text, tags, profile) self.db.execute("UPDATE records SET content = ?, size = ?, updated = ? WHERE id = ?", (text, len(text.encode("utf-8")), stamp, record_id)) self.set_item_tags(record_id, tags, profile) self.db.commit() return record_id def delete_file_record(self, path, profile=None): profile = self._scope(profile) record_id = self.file_record_id(path, profile) if record_id is None: return False return self.delete_record(record_id, profile) def load_secret(self, name, profile=None): profile = self._scope(profile) row = self.db.execute("SELECT value FROM secrets WHERE profile = ? AND name = ?", (profile, name,)).fetchone() if row is None: return None if row[0].startswith(Seal.PREFIX): return self.seal.unlock(row[0]) if self.seal.enabled else None return row[0] def delete_secret(self, name, profile=None): profile = self._scope(profile) self.secret_cache.pop(profile, None) done = self.db.execute("DELETE FROM secrets WHERE profile = ? AND name = ?", (profile, name,)) if done.rowcount > 0: item = "secret:" + name self.db.execute("DELETE FROM tags WHERE item = ? AND profile = ?", (item, profile)) self.db.execute("DELETE FROM edges WHERE profile = ? AND (src = ? OR dst = ?)", (profile, item, item)) self.db.commit() LOG.event("DEBUG", "vault", "secret_deleted", "secret %s deleted" % name, profile=profile, meta={"name": name}) return True self.db.commit() return False def secret_values(self, profile=None): profile = self._scope(profile) if profile not in self.secret_cache: found = {} for name, value in self.db.execute("SELECT name, value FROM secrets WHERE profile = ?", (profile,)).fetchall(): if value.startswith(Seal.PREFIX): if not self.seal.enabled: continue value = self.seal.unlock(value) if len(value) >= 4: found[name] = value self.secret_cache[profile] = found return self.secret_cache[profile] def redact(self, text, profile=None): if not isinstance(text, str): return text for name, value in sorted(self.secret_values(profile).items(), key=lambda item: -len(item[1])): if value in text: text = text.replace(value, "[redacted:%s]" % name) return text def add_schedule(self, name, prompt, profile, every_sec, next_run, timeout, tags=None): stored = self.seal.lock(prompt) if self.seal.enabled else prompt done = self.db.execute("INSERT INTO schedules (name, prompt, profile, every_sec, next_run, timeout, status, last_status, last_result, created, updated) VALUES (?, ?, ?, ?, ?, ?, 'pending', '', '', ?, ?)", (name, stored, profile, every_sec, next_run, timeout, now_iso(), now_iso())) item = "sched:%s" % done.lastrowid if tags is None: self.tag_item(item, ["schedule"], profile) else: self.set_item_tags(item, ["schedule"] + list(tags), profile) self.db.commit() LOG.event("DEBUG", "vault", "schedule_saved", "schedule #%s saved (%s)" % (done.lastrowid, name), profile=profile, meta={"id": done.lastrowid, "name": name}) return done.lastrowid def remove_schedule(self, row_id, profile=None): profile = self._scope(profile) done = self.db.execute("DELETE FROM schedules WHERE id = ? AND profile = ?", (row_id, profile)) if done.rowcount > 0: item = "sched:%s" % row_id self.db.execute("DELETE FROM tags WHERE item = ? AND profile = ?", (item, profile)) self.db.execute("DELETE FROM edges WHERE profile = ? AND (src = ? OR dst = ?)", (profile, item, item)) self.db.commit() LOG.event("DEBUG", "vault", "schedule_removed", "schedule #%s removed" % row_id, profile=profile, meta={"id": row_id}) return True self.db.commit() return False def list_schedules(self, profile=None): profile = self._scope(profile) rows = self.db.execute("SELECT id, name, prompt, profile, every_sec, next_run, timeout, status, last_status, last_result, created FROM schedules WHERE profile = ? ORDER BY next_run", (profile,)).fetchall() items = [] for row in rows: prompt = self.seal.safe_unlock(row[2]) if row[2].startswith(Seal.PREFIX) else row[2] items.append({"id": row[0], "name": row[1], "prompt": prompt, "profile": row[3], "every": row[4], "next_run": row[5], "timeout": row[6], "status": row[7], "last": row[8], "result": row[9], "created": row[10], "tags": self.item_tags("sched:%s" % row[0], row[3])}) return items def profile_path(self, name): return os.path.join(self.config.profiles_dir, name + ".sys.md") def session_path(self, name, ctx=None): sid = ShellSession.sanitize(ctx) if ctx else "" if sid: return os.path.join(self.config.profiles_dir, "%s.session.%s.json" % (name, sid)) return os.path.join(self.config.profiles_dir, name + ".session.json") def list_profiles(self): names = [] for entry in sorted(os.listdir(self.config.profiles_dir)): if entry.endswith(".sys.md") and ".bot." not in entry: names.append(entry[:-7]) return names def unseal_file(self, raw): if raw.startswith(Seal.PREFIX): if not self.seal.enabled: raise SealError("profile is sealed, set TAI_PASSPHRASE") return self.seal.unlock(raw) return raw def load_system(self, name): path = self.profile_path(name) if not os.path.exists(path): return None try: with open(path, "r", encoding="utf-8") as handle: return self.unseal_file(handle.read()) except (OSError, ValueError): quarantine(path) return None except SealError: if not self.seal.enabled: raise quarantine(path) return None def save_system(self, name, text): try: sealed_write(self.profile_path(name), self.seal.lock(text)) except OSError: pass def load_session(self, name, bot="main", ctx=None): path = self.session_path(name, ctx=ctx) if bot == "main" else self.bot_session_path(name, bot, ctx=ctx) if not os.path.exists(path): return [] try: with open(path, "r", encoding="utf-8") as handle: raw = handle.read() data = json.loads(self.unseal_file(raw)) return [item for item in data if isinstance(item, dict) and item.get("role") in ("user", "assistant", "tool")] except (OSError, ValueError): quarantine(path) return [] except SealError: if not self.seal.enabled: raise quarantine(path) return [] def save_session(self, name, messages, bot="main", ctx=None): path = self.session_path(name, ctx=ctx) if bot == "main" else self.bot_session_path(name, bot, ctx=ctx) try: sealed_write(path, self.seal.lock(json.dumps([item for item in messages if item.get("role") != "system"]))) except OSError: pass if ShellSession.sanitize(ctx) if ctx else "": stem = name if bot == "main" else "%s.bot.%s" % (name, bot) self.prune_ctx_files("%s.session." % stem, "%s.session.json" % stem) def prune_ctx_files(self, prefix, legacy, cap=0): try: entries = [entry for entry in os.listdir(self.config.profiles_dir) if entry.startswith(prefix) and entry.endswith(".json") and entry != legacy] except OSError: return keep = cap or ShellSession.FILES_CAP if len(entries) <= keep: return paths = [os.path.join(self.config.profiles_dir, entry) for entry in entries] try: paths.sort(key=lambda item: os.path.getmtime(item)) except OSError: return for stale in paths[:len(paths) - keep]: try: os.remove(stale) except OSError: pass def settings_row(self, key, scope, own, fallback): """Merge one catalog entry with its stored rows into a result dict. own/fallback are (value, created, updated) tuples or None. Precedence: non-empty env, own scope row, global fallback row, catalog default.""" spec = SETTINGS_CATALOG[key] stored = None created = updated = None if own is not None and own[0] is not None: stored, created, updated = own elif fallback is not None and fallback[0] is not None: stored, created, updated = fallback elif own is not None: created, updated = own[1], own[2] if spec["encrypted"] and stored is not None and stored.startswith(Seal.PREFIX): stored = self.seal.safe_unlock(stored) env_raw = os.environ.get(spec["env"]) or "" if env_raw.strip(): value = parse_setting_env(spec, env_raw.strip()) source = "env" elif stored is not None: value = parse_setting_stored(spec, stored) source = "db" else: value = spec["default"] source = "default" if spec["required"] and (value is None or value == ""): raise ValueError("setting '%s' is required but unset" % key) return {"key": key, "scope": scope, "type": spec["type"], "default": spec["default"], "required": spec["required"], "nullable": spec["nullable"], "options": spec["options"], "preferred": spec["preferred"], "encrypted": spec["encrypted"], "env": spec["env"], "desc": spec["desc"], "stored": stored, "value": value, "source": source, "created": created, "updated": updated} def read_setting_rows(self, scope): try: found = self.db.execute("SELECT key, value, created, updated FROM settings WHERE scope = ?", (scope,)).fetchall() except sqlite3.Error: return {} return dict((key, (value, created, updated)) for key, value, created, updated in found) def get_or_create_settings(self, keys=None, scope="global"): """Ensure vault rows exist for keys (all catalog keys when None) and return their merged rows. Unknown keys and bad scopes raise.""" validate_setting_scope(scope) if isinstance(keys, str): keys = [keys] names = sorted(SETTINGS_CATALOG) if keys is None else sorted(keys) for name in names: if name not in SETTINGS_CATALOG: raise ValueError("unknown setting '%s'" % (name,)) own = self.read_setting_rows(scope) global_rows = self.read_setting_rows(SETTING_SCOPE_GLOBAL) if scope != SETTING_SCOPE_GLOBAL else own now = now_iso() created_any = False for name in names: if name not in own: self.db.execute("INSERT INTO settings (key, scope, value, created, updated) VALUES (?, ?, NULL, ?, ?)", (name, scope, now, now)) created_any = True if created_any: self.db.commit() own = self.read_setting_rows(scope) return [self.settings_row(name, scope, own.get(name), None if scope == SETTING_SCOPE_GLOBAL else global_rows.get(name)) for name in names] def get_all_settings(self, scope="global"): """Read-only merge of the whole catalog for one scope. Creates nothing.""" validate_setting_scope(scope) own = self.read_setting_rows(scope) global_rows = self.read_setting_rows(SETTING_SCOPE_GLOBAL) if scope != SETTING_SCOPE_GLOBAL else own return [self.settings_row(name, scope, own.get(name), None if scope == SETTING_SCOPE_GLOBAL else global_rows.get(name)) for name in sorted(SETTINGS_CATALOG)] def upsert_settings(self, items, scope="global"): """Validate and store setting values atomically. items maps key to a python value; None clears a nullable key back to null. Anything invalid raises ValueError and writes nothing. Returns fresh rows.""" validate_setting_scope(scope) if not isinstance(items, dict) or not items: raise ValueError("upsert needs a non-empty {key: value} mapping") canonical = {} for key in sorted(items): if key not in SETTINGS_CATALOG: raise ValueError("unknown setting '%s'" % (key,)) canonical[key] = normalize_setting_value(key, SETTINGS_CATALOG[key], items[key]) now = now_iso() try: for key in sorted(canonical): spec = SETTINGS_CATALOG[key] value = canonical[key] if value is not None and spec["encrypted"]: value = self.seal.lock(value) exists = self.db.execute("SELECT 1 FROM settings WHERE key = ? AND scope = ?", (key, scope)).fetchone() if exists: self.db.execute("UPDATE settings SET value = ?, updated = ? WHERE key = ? AND scope = ?", (value, now, key, scope)) else: self.db.execute("INSERT INTO settings (key, scope, value, created, updated) VALUES (?, ?, ?, ?, ?)", (key, scope, value, now, now)) self.db.commit() except sqlite3.Error as exc: self.db.rollback() raise ValueError("settings upsert failed: %s" % exc) return self.get_or_create_settings(sorted(canonical), scope=scope) def mcp_parse_json(self, raw, fallback): try: data = json.loads(raw or "") except ValueError: return fallback return data if isinstance(data, type(fallback)) else fallback def mcp_row(self, found): name, transport, command, args_json, env_json, url, headers_json, cwd, allow_json, deny_json, source, created, updated = found if headers_json and headers_json.startswith(Seal.PREFIX): headers_json = self.seal.safe_unlock(headers_json) return {"name": name, "transport": transport, "command": command or "", "args": self.mcp_parse_json(args_json, []), "env": self.mcp_parse_json(env_json, {}), "url": url or "", "headers": self.mcp_parse_json(headers_json, {}), "cwd": cwd or "", "allow": self.mcp_parse_json(allow_json, []), "deny": self.mcp_parse_json(deny_json, []), "source": source or "", "created": created, "updated": updated} def mcp_save_server(self, entry): name = entry.get("name", "") if not MCP_SERVER_RE.match(name): raise ValueError("bad MCP server name '%s'" % name) known = self.db.execute("SELECT created FROM mcp_servers WHERE name = ?", (name,)).fetchone() if known is None: count = self.db.execute("SELECT COUNT(*) FROM mcp_servers").fetchone()[0] if count >= MCP_MAX_SERVERS: raise ValueError("too many MCP servers (cap %d)" % MCP_MAX_SERVERS) headers = entry.get("headers") or {} sealed = self.seal.lock(json.dumps(headers, sort_keys=True)) if headers else "" now = now_iso() created = known[0] if known else now self.db.execute("INSERT OR REPLACE INTO mcp_servers " "(name, transport, command, args_json, env_json, url, headers_json, cwd, allow_json, deny_json, source, created, updated) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (name, entry.get("transport"), entry.get("command") or "", json.dumps(entry.get("args") or []), json.dumps(entry.get("env") or {}), entry.get("url") or "", sealed, entry.get("cwd") or "", json.dumps(entry.get("allow") or []), json.dumps(entry.get("deny") or []), entry.get("source") or "", created, now)) self.db.commit() def mcp_get_server(self, name): found = self.db.execute("SELECT name, transport, command, args_json, env_json, url, headers_json, cwd, allow_json, deny_json, source, created, updated " "FROM mcp_servers WHERE name = ?", (name,)).fetchone() return self.mcp_row(found) if found else None def mcp_list_servers(self): found = self.db.execute("SELECT name, transport, command, args_json, env_json, url, headers_json, cwd, allow_json, deny_json, source, created, updated " "FROM mcp_servers ORDER BY name").fetchall() return [self.mcp_row(row) for row in found] def mcp_remove_server(self, name): self.db.execute("DELETE FROM mcp_server_tools WHERE server = ?", (name,)) self.db.execute("DELETE FROM mcp_tool_pins WHERE server = ?", (name,)) self.db.execute("DELETE FROM mcp_server_profiles WHERE server = ?", (name,)) self.db.execute("DELETE FROM mcp_servers WHERE name = ?", (name,)) self.db.commit() def mcp_set_enabled(self, server, profile, enabled): self.db.execute("INSERT OR REPLACE INTO mcp_server_profiles (server, profile, enabled) VALUES (?, ?, ?)", (server, profile, 1 if enabled else 0)) self.db.commit() def mcp_server_enabled(self, server, profile): found = self.db.execute("SELECT enabled FROM mcp_server_profiles WHERE server = ? AND profile = ?", (server, profile)).fetchone() return bool(found and found[0]) def mcp_enabled_servers(self, profile): return [row for row in self.mcp_list_servers() if self.mcp_server_enabled(row["name"], profile)] def mcp_store_tools(self, server, tools): now = now_iso() self.db.execute("DELETE FROM mcp_server_tools WHERE server = ?", (server,)) self.db.execute("DELETE FROM mcp_tool_pins WHERE server = ?", (server,)) for item in tools: if not isinstance(item, dict) or not item.get("name"): continue tool = item["name"] desc = item.get("description", "") or "" schema = item.get("inputSchema") or {} self.db.execute("INSERT INTO mcp_server_tools (server, tool, description, schema_json, updated) VALUES (?, ?, ?, ?, ?)", (server, tool, desc, json.dumps(schema), now)) self.db.execute("INSERT INTO mcp_tool_pins (server, tool, hash, updated) VALUES (?, ?, ?, ?)", (server, tool, mcp_tool_hash(tool, desc, schema), now)) self.db.commit() def mcp_cached_tools(self, server): found = self.db.execute("SELECT tool, description, schema_json FROM mcp_server_tools WHERE server = ? ORDER BY tool", (server,)).fetchall() return [{"tool": tool, "description": desc or "", "schema": self.mcp_parse_json(schema, {})} for tool, desc, schema in found] def mcp_tool_pins(self, server): found = self.db.execute("SELECT tool, hash FROM mcp_tool_pins WHERE server = ?", (server,)).fetchall() return dict((tool, digest) for tool, digest in found) def load_dir_defaults(self, directory): try: row = self.db.execute("SELECT provider, model, bot FROM dir_defaults WHERE dir = ?", (directory,)).fetchone() except sqlite3.Error: return {} if not row: return {} return {key: value for key, value in zip(("provider", "model", "bot"), row) if value} def save_dir_defaults(self, directory, provider=None, model=None, bot=None, clear=()): if not directory: return current = self.load_dir_defaults(directory) for key, value in (("provider", provider), ("model", model), ("bot", bot)): if value: current[key] = value for key in clear or (): current.pop(key, None) try: if not current: self.db.execute("DELETE FROM dir_defaults WHERE dir = ?", (directory,)) else: self.db.execute( "INSERT OR REPLACE INTO dir_defaults (dir, provider, model, bot, updated) VALUES (?, ?, ?, ?, ?)", (directory, current.get("provider", ""), current.get("model", ""), current.get("bot", ""), now_iso())) self.db.execute( "DELETE FROM dir_defaults WHERE dir NOT IN (SELECT dir FROM dir_defaults ORDER BY updated DESC LIMIT ?)", (DIR_DEFAULTS_CAP,)) self.db.commit() except sqlite3.Error: pass def turn_state_path(self, profile, bot="main", ctx=None): sid = ShellSession.sanitize(ctx) if ctx else "" stem = profile if bot == "main" else "%s.bot.%s" % (profile, bot) if sid: return os.path.join(self.config.profiles_dir, "%s.turn.%s.json" % (stem, sid)) return os.path.join(self.config.profiles_dir, "%s.turn.json" % stem) def load_turn_state(self, profile, bot="main", ctx=None): path = self.turn_state_path(profile, bot, ctx=ctx) if not os.path.exists(path): return {} try: with open(path, "r", encoding="utf-8") as handle: data = json.loads(self.unseal_file(handle.read())) return data if isinstance(data, dict) else {} except (OSError, ValueError): quarantine(path) return {} except SealError: if not self.seal.enabled: raise quarantine(path) return {} def save_turn_state(self, profile, bot, state, ctx=None): try: sealed_write(self.turn_state_path(profile, bot, ctx=ctx), self.seal.lock(json.dumps(state))) except OSError: pass if ShellSession.sanitize(ctx) if ctx else "": stem = profile if bot == "main" else "%s.bot.%s" % (profile, bot) self.prune_ctx_files("%s.turn." % stem, "%s.turn.json" % stem) def bot_system_path(self, profile, bot): return os.path.join(self.config.profiles_dir, "%s.bot.%s.sys.md" % (profile, bot)) def bot_session_path(self, profile, bot, ctx=None): sid = ShellSession.sanitize(ctx) if ctx else "" if sid: return os.path.join(self.config.profiles_dir, "%s.bot.%s.session.%s.json" % (profile, bot, sid)) return os.path.join(self.config.profiles_dir, "%s.bot.%s.session.json" % (profile, bot)) def bots_registry_path(self, profile): return os.path.join(self.config.profiles_dir, "%s.bots.json" % profile) def load_bots(self, profile): path = self.bots_registry_path(profile) if not os.path.exists(path): return {} try: with open(path, "r", encoding="utf-8") as handle: data = json.loads(self.unseal_file(handle.read())) bots = data.get("bots") if isinstance(data, dict) else None return bots if isinstance(bots, dict) else {} except (OSError, ValueError): quarantine(path) return {} except SealError: if not self.seal.enabled: raise quarantine(path) return {} def save_bots(self, profile, bots): try: sealed_write(self.bots_registry_path(profile), self.seal.lock(json.dumps({"bots": bots}))) except OSError: pass def list_bots(self, profile): bots = self.load_bots(profile) found = [{"name": "main", "nicknames": []}] for name in sorted(bots): found.append({"name": name, "nicknames": (bots.get(name) or {}).get("nicknames", [])}) return found def resolve_bot(self, profile, mention): want = str(mention or "").strip().lower() if want in ("", "main"): return "main" bots = self.load_bots(profile) if want in bots: return want for name in sorted(bots): if want in (bots.get(name) or {}).get("nicknames", []): return name return None def load_bot_system(self, profile, bot): if bot == "main": return self.load_system(profile) path = self.bot_system_path(profile, bot) if not os.path.exists(path): return None try: with open(path, "r", encoding="utf-8") as handle: return self.unseal_file(handle.read()) except (OSError, ValueError): quarantine(path) return None except SealError: if not self.seal.enabled: raise quarantine(path) return None def save_bot_system(self, profile, bot, text): try: sealed_write(self.bot_system_path(profile, bot), self.seal.lock(text)) except OSError: pass def close(self): try: self.db.execute("PRAGMA wal_checkpoint(TRUNCATE)") except sqlite3.Error: pass try: self.db.close() except sqlite3.Error: pass if getattr(self, "memdb", None) is not None: try: self.memdb.close() except sqlite3.Error: pass self.memdb = None class BackendError(Exception): def __init__(self, message, status=0): super().__init__(message) self.status = status def muse_binary(): return os.environ.get("MUSE_BIN") or shutil.which("muse") def opencode_binary(): return os.environ.get("OPENCODE_BIN") or shutil.which("opencode") def grok_binary(): return os.environ.get("GROK_BIN") or shutil.which("grok") or shutil.which("grokcli") def claude_binary(): return os.environ.get("CLAUDE_BIN") or shutil.which("claude") def codex_binary(): return os.environ.get("CODEX_BIN") or shutil.which("codex") def gemini_binary(): return os.environ.get("GEMINI_BIN") or shutil.which("gemini") def opencode_prompt(messages, tools): schema = json.dumps(muse_output_schema()) rules = ( "You are the model behind another agent. The transcript above is the whole conversation; lines starting with TOOL <id> are results of tool calls that already ran. " "Do not use any of your own tools and do not repeat a tool call whose result is already shown. " "If the results answer the user, put the final answer in content with an empty tool_calls list; otherwise request the next tool calls from TOOLS. " "An empty tool_calls list ends the turn at once, nothing else runs. " "Never narrate a planned action without its tool call in the same reply. " "Reply with one JSON object only, no code fence, matching this schema: " ) return muse_prompt(messages, tools) + "\n\n" + rules + schema def opencode_find_answer(text): # Scan every "{" last-first and decode one value at each spot: free # models often echo the prompt schema or tool JSON before the real # answer, and a first-"{"-to-last-"}" slice spans both into garbage. # Last-first means the model's own trailing answer wins over echoes. decoder = json.JSONDecoder() starts = [pos for pos, char in enumerate(text) if char == "{"] for pos in reversed(starts[-OPENCODE_PARSE_MAX_SCANS:]): try: answer, _end = decoder.raw_decode(text, pos) except ValueError: continue if isinstance(answer, dict) and ("content" in answer or "tool_calls" in answer): return answer return None def opencode_parse(text): text = (text or "").strip() fenced = re.match(r"^```(?:json)?\s*(.*?)\s*```$", text, re.S) if fenced: text = fenced.group(1) return opencode_find_answer(text) or {"content": text, "tool_calls": []} def cli_json_repair_prompt(messages, tools, bad_text): return opencode_prompt(messages, tools) + ( "\n\nYour previous reply contained no JSON object, so no tool ran and the turn is about to end. " "Reply now with exactly one JSON object matching the schema above and no other text. " "Previous reply for reference:\n" + (bad_text or "").strip()[:2000] ) def opencode_parse_events(stdout_text, returncode, stderr_text): """Shared by opencode_run() (local CLI) and box_opencode_run() (same CLI, executed inside the sandbox box) -- the JSON-lines event stream is identical either way, only how the process gets launched differs.""" texts = [] for line in stdout_text.splitlines(): try: event = json.loads(line) except ValueError: continue if not isinstance(event, dict): continue if event.get("type") == "error": data = (event.get("error") or {}).get("data") or {} raise BackendError("opencode: %s" % (data.get("message") or json.dumps(event.get("error"))[:300]), data.get("statusCode") or 0) part = event.get("part") or {} if event.get("type") == "text" and part.get("text"): texts.append(part["text"]) if not texts: raise BackendError("opencode returned no text (exit %d): %s" % (returncode, (stderr_text or stdout_text)[-300:])) return "".join(texts) class Opencode: """Opencode CLI runner (static; tests patch opencode_run live).""" @staticmethod def opencode_run(model, prompt, timeout): binary = opencode_binary() if not binary: raise BackendError("opencode CLI not found (OPENCODE_BIN or PATH)") argv = [binary, "run", "--pure", "--agent", OPENCODE_AGENT, "--format", "json", "-m", "opencode/" + model] with tempfile.TemporaryDirectory(prefix="tai-opencode-") as folder: try: done = subprocess.run(argv, input=prompt, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, cwd=folder, timeout=timeout) except subprocess.TimeoutExpired: raise BackendError("opencode run timed out after %ds" % timeout) except OSError as exc: raise BackendError("opencode run failed: %s" % short_error(exc)) return opencode_parse_events(done.stdout, done.returncode, done.stderr) def opencode_run(model, prompt, timeout): return Opencode.opencode_run(model, prompt, timeout) def opencode_free_models(): binary = opencode_binary() if not binary: return None try: done = subprocess.run([binary, "models", "opencode"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=MODEL_LIST_TIMEOUT * 4) except (OSError, subprocess.TimeoutExpired): return None models = [line.strip().split("/", 1)[1] for line in done.stdout.splitlines() if line.strip().startswith("opencode/")] return [item for item in models if is_free_model(item)] or None def _cli_unknown_flag(stderr): return bool(re.search(r"unknown (option|flag|command)|unrecognised|unrecognized|invalid flag|not found|no such option", stderr or "", re.IGNORECASE)) def _cli_auth_failure(text): return bool(re.search(r"not (logged in|authenticated)|auth|api key|unauthorized|401|login required|please (login|log in|authenticate)", text or "", re.IGNORECASE)) def _cli_run(argv, prompt_input, timeout, label): try: done = subprocess.run(argv, input=prompt_input, stdin=subprocess.DEVNULL if prompt_input is None else None, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=timeout) except subprocess.TimeoutExpired: raise BackendError("%s run timed out after %ds" % (label, timeout)) except OSError as exc: raise BackendError("%s run failed: %s" % (label, short_error(exc))) return done def grok_parse_output(stdout): try: data = json.loads(stdout or "") except ValueError: return opencode_parse(stdout) if isinstance(data, dict): if data.get("is_error") or data.get("error"): detail = data.get("error") or data.get("result") or data.get("text") or "unknown error" raise BackendError("grok: %s" % str(detail)[:300]) if isinstance(data.get("content"), str) or isinstance(data.get("tool_calls"), list): if "content" in data or "tool_calls" in data: return data for key in ("text", "result", "output", "answer", "content"): value = data.get(key) if isinstance(value, str) and value.strip(): return opencode_parse(value) return opencode_parse(stdout) def grok_run(prompt, timeout): binary = grok_binary() if not binary: raise BackendError("grok CLI not found (GROK_BIN or PATH)") with tempfile.TemporaryDirectory(prefix="tai-grok-") as folder: prompt_path = os.path.join(folder, "prompt.txt") with open(prompt_path, "w", encoding="utf-8") as handle: handle.write(prompt) variants = [ [binary, "--prompt-file", prompt_path, "--output-format", "json", "--no-auto-update"], [binary, "--prompt-file", prompt_path, "--output-format", "json"], [binary, "-p", prompt, "--output-format", "json"], [binary, "-p", prompt], ] last_error = "" for argv in variants: done = _cli_run(argv, None, timeout, "grok") if done.returncode != 0 and _cli_unknown_flag(done.stderr) and argv is not variants[-1]: last_error = (done.stderr or "").strip()[:200] continue if done.returncode != 0 and not (done.stdout or "").strip(): blob = ((done.stderr or "") + "\n" + (done.stdout or "")).strip() if _cli_auth_failure(blob): raise BackendError("grok not authenticated (set XAI_API_KEY or run grok login): %s" % blob[:200]) raise BackendError("grok exit %d: %s" % (done.returncode, blob[:300] or last_error or "no output")) if not (done.stdout or "").strip(): blob = (done.stderr or "").strip() raise BackendError("grok returned no output: %s" % (blob[:300] or last_error or "empty stdout")) return done.stdout or "" raise BackendError("grok failed: %s" % (last_error or "no usable output")) def claude_parse_output(stdout): try: data = json.loads(stdout or "") except ValueError: return opencode_parse(stdout) if not isinstance(data, dict): return opencode_parse(stdout) if data.get("is_error"): raise BackendError("claude: %s" % str(data.get("result") or "unknown error")[:300]) structured = data.get("structured_output") if isinstance(structured, dict) and ("content" in structured or "tool_calls" in structured): return structured result = data.get("result") if isinstance(result, str) and result.strip(): return opencode_parse(result) return opencode_parse(stdout) def claude_run(prompt, timeout): binary = claude_binary() if not binary: raise BackendError("claude CLI not found (CLAUDE_BIN or PATH)") schema = json.dumps(muse_output_schema()) base = [binary, "-p"] if len(prompt.encode("utf-8", "replace")) > 120000: variants = [ (base + ["--output-format", "json", "--json-schema", schema], prompt), (base + ["--output-format", "json"], prompt), ] else: variants = [ (base + [prompt, "--output-format", "json", "--json-schema", schema], None), (base + [prompt, "--output-format", "json"], None), (base + [prompt], None), ] last_error = "" for argv, prompt_input in variants: done = _cli_run(argv, prompt_input, timeout, "claude") if done.returncode != 0 and _cli_unknown_flag(done.stderr) and (argv, prompt_input) != variants[-1]: last_error = (done.stderr or "").strip()[:200] continue if done.returncode != 0 and not (done.stdout or "").strip(): blob = ((done.stderr or "") + "\n" + (done.stdout or "")).strip() if _cli_auth_failure(blob): raise BackendError("claude not authenticated (run claude login): %s" % blob[:200]) raise BackendError("claude exit %d: %s" % (done.returncode, blob[:300] or last_error or "no output")) if not (done.stdout or "").strip(): blob = (done.stderr or "").strip() raise BackendError("claude returned no output: %s" % (blob[:300] or last_error or "empty stdout")) return done.stdout or "" raise BackendError("claude failed: %s" % (last_error or "no usable output")) def codex_jsonl_texts(stdout): texts = [] last_error = "" for line in (stdout or "").splitlines(): line = line.strip() if not line.startswith("{"): continue try: event = json.loads(line) except ValueError: continue if not isinstance(event, dict): continue etype = event.get("type") if etype == "item.completed": item = event.get("item") or {} if item.get("type") == "agent_message" and item.get("text"): texts.append(item["text"]) elif etype in ("turn.failed", "error"): msg = event.get("message") or event.get("error") or "" if isinstance(msg, dict): msg = msg.get("message") or json.dumps(msg) if msg: last_error = str(msg)[:300] return texts, last_error def codex_parse_output(stdout, outfile_text="", used_schema=False): if isinstance(outfile_text, str) and outfile_text.strip(): if used_schema: try: answer = json.loads(outfile_text) except ValueError: answer = None if isinstance(answer, dict) and ("content" in answer or "tool_calls" in answer): return answer return opencode_parse(outfile_text) texts, last_error = codex_jsonl_texts(stdout) if texts: return opencode_parse("".join(texts)) if last_error: raise BackendError("codex: %s" % last_error) return opencode_parse(stdout) def codex_sandbox_args(): """Codex CLI sandbox flags. Default off; set TAI_CODEX_SANDBOX=1 to re-enable.""" if (os.environ.get("TAI_CODEX_SANDBOX") or "").strip().lower() in ("1", "true", "yes", "on", "read-only", "readonly"): return ["--sandbox", "read-only"] return [] def codex_run(prompt, timeout): binary = codex_binary() if not binary: raise BackendError("codex CLI not found (CODEX_BIN or PATH)") schema = json.dumps(muse_output_schema()) with tempfile.TemporaryDirectory(prefix="tai-codex-") as folder: schema_path = os.path.join(folder, "schema.json") out_path = os.path.join(folder, "last.txt") with open(schema_path, "w", encoding="utf-8") as handle: handle.write(schema) base = [binary, "exec", "--json", "--ephemeral"] + codex_sandbox_args() + ["--skip-git-repo-check"] if len(prompt.encode("utf-8", "replace")) > 120000: variants = [ (base + ["--output-schema", schema_path, "-o", out_path, "-"], prompt, True), (base + ["-o", out_path, "-"], prompt, False), ] else: variants = [ (base + ["--output-schema", schema_path, "-o", out_path, prompt], None, True), (base + ["-o", out_path, prompt], None, False), (base + [prompt], None, False), ] last_error = "" for argv, prompt_input, used_schema in variants: try: done = subprocess.run(argv, input=prompt_input, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, cwd=folder, timeout=timeout) except subprocess.TimeoutExpired: raise BackendError("codex run timed out after %ds" % timeout) except OSError as exc: raise BackendError("codex run failed: %s" % short_error(exc)) if done.returncode != 0 and _cli_unknown_flag(done.stderr) and (argv, prompt_input, used_schema) != variants[-1]: last_error = (done.stderr or "").strip()[:200] continue outfile_text = "" if "-o" in argv: try: with open(out_path, "r", encoding="utf-8", errors="replace") as handle: outfile_text = handle.read() except OSError: outfile_text = "" if done.returncode != 0 and not (outfile_text.strip() or (done.stdout or "").strip()): blob = ((done.stderr or "") + "\n" + (done.stdout or "")).strip() if _cli_auth_failure(blob): raise BackendError("codex not authenticated (run codex login): %s" % blob[:200]) raise BackendError("codex exit %d: %s" % (done.returncode, blob[:300] or last_error or "no output")) if not (outfile_text.strip() or (done.stdout or "").strip()): blob = (done.stderr or "").strip() raise BackendError("codex returned no output: %s" % (blob[:300] or last_error or "empty stdout")) return done.stdout or "", outfile_text, used_schema raise BackendError("codex failed: %s" % (last_error or "no usable output")) def gemini_parse_output(stdout): try: data = json.loads(stdout or "") except ValueError: return opencode_parse(stdout) if not isinstance(data, dict): return opencode_parse(stdout) err = data.get("error") if err: raise BackendError("gemini: %s" % (json.dumps(err) if isinstance(err, dict) else str(err))[:300]) response = data.get("response") if isinstance(response, str) and response.strip(): return opencode_parse(response) return opencode_parse(stdout) def gemini_plan_enabled(): """Gemini plan-mode variant. Default off; set TAI_GEMINI_PLAN=1 to re-enable.""" return (os.environ.get("TAI_GEMINI_PLAN") or "").strip().lower() in ("1", "true", "yes", "on", "plan") def gemini_run(prompt, timeout): binary = gemini_binary() if not binary: raise BackendError("gemini CLI not found (GEMINI_BIN or PATH)") base = [binary, "-p", prompt] variants = [] if gemini_plan_enabled(): variants.append(base + ["--output-format", "json", "--approval-mode", "plan"]) variants.extend([ base + ["--output-format", "json"], base, ]) last_error = "" for argv in variants: done = _cli_run(argv, None, timeout, "gemini") if done.returncode != 0 and _cli_unknown_flag(done.stderr) and argv is not variants[-1]: last_error = (done.stderr or "").strip()[:200] continue if done.returncode != 0 and not (done.stdout or "").strip(): blob = ((done.stderr or "") + "\n" + (done.stdout or "")).strip() if _cli_auth_failure(blob): raise BackendError("gemini not authenticated (run gemini login or set GEMINI_API_KEY): %s" % blob[:200]) hint = {42: "input error", 53: "turn limit exceeded"}.get(done.returncode, "") raise BackendError("gemini exit %d%s: %s" % (done.returncode, " (%s)" % hint if hint else "", blob[:300] or last_error or "no output")) if not (done.stdout or "").strip(): blob = (done.stderr or "").strip() raise BackendError("gemini returned no output: %s" % (blob[:300] or last_error or "empty stdout")) return done.stdout or "" raise BackendError("gemini failed: %s" % (last_error or "no usable output")) def muse_output_schema(): call = {"type": "object", "additionalProperties": False, "properties": {"name": {"type": "string"}, "arguments": {"type": "string"}}, "required": ["name", "arguments"]} return {"type": "object", "additionalProperties": False, "properties": {"content": {"type": "string"}, "tool_calls": {"type": "array", "items": call}}, "required": ["content", "tool_calls"]} def muse_prompt(messages, tools): parts = [] for message in messages or []: role = (message.get("role") or "user").upper() body = message.get("content") or "" if isinstance(body, list): body = " ".join(str(part.get("text") or "") for part in body if isinstance(part, dict)) if message.get("tool_call_id"): parts.append("%s %s:\n%s" % (role, message["tool_call_id"], body)) else: parts.append("%s:\n%s" % (role, body)) for call in message.get("tool_calls") or []: func = call.get("function") or {} parts.append("TOOL CALL %s:\n%s %s" % (call.get("id") or "", func.get("name") or "", func.get("arguments") or "")) if tools: parts.append("TOOLS:\n%s" % json.dumps(tools)) parts.append("Answer with content text plus tool_calls for every tool to invoke now. Empty tool_calls ends the turn.") parts.append("Reply with data only: never invoke assistant or harness tools yourself. The TOOLS block above is response vocabulary, not executable tools.") return "\n\n".join(part for part in parts if part.strip()) def muse_argv(config, schema_path, prompt_path, session_id=None): argv = [muse_binary(), "exec", "--json", "--max-model-steps", str(MUSE_MAX_STEPS), "--disable-web-tools", "--disable-reminders", "--disable-shell", "--disable-write", "--output-schema", schema_path, "--prompt-file", prompt_path] if session_id is None: argv.append("--no-session-log") else: argv.extend(["--allow-workspace-switch", "--session-id", session_id]) if config.explicit_model: argv.extend(["--model", config.explicit_model]) return argv def muse_write_file(path, text): with open(path, "w", encoding="utf-8") as handle: handle.write(text) handle.flush() os.fsync(handle.fileno()) def muse_message_hash(message): blob = json.dumps(message, sort_keys=True, separators=(",", ":"), default=str) return hashlib.sha256(blob.encode("utf-8")).hexdigest() def muse_map_path(home): return os.path.join(home, MUSE_SESSIONS_FILE) def muse_load_map(home): path = muse_map_path(home) try: with open(path, "r", encoding="utf-8") as handle: data = json.load(handle) except FileNotFoundError: return {} except (OSError, ValueError): quarantine(path) return {} if not isinstance(data, dict): quarantine(path) return {} now = time.time() pruned = False for key in list(data): entry = data[key] if not isinstance(entry, dict) or not isinstance(entry.get("id"), str) or not isinstance(entry.get("hashes"), list): del data[key] pruned = True elif key.startswith("worker/"): try: stamp = _fromisoformat(entry.get("updated") or "").timestamp() except ValueError: stamp = 0 if now - stamp > MUSE_WORKER_TTL: del data[key] pruned = True if pruned: muse_save_map(home, data) return data def muse_save_map(home, data): muse_write_file(muse_map_path(home), json.dumps(data, sort_keys=True) + "\n") _MUSE_THREAD_LOCKS = {} _MUSE_THREAD_GUARD = threading.Lock() def muse_acquire(home, name): with _MUSE_THREAD_GUARD: thread_lock = _MUSE_THREAD_LOCKS.setdefault(name, threading.Lock()) thread_lock.acquire() handle = None if fcntl is not None: try: folder = os.path.join(home, MUSE_LOCKS_DIR) os.makedirs(folder, exist_ok=True) handle = open(os.path.join(folder, hashlib.sha256(name.encode("utf-8")).hexdigest() + ".lock"), "w") fcntl.flock(handle.fileno(), fcntl.LOCK_EX) except OSError: if handle is not None: try: handle.close() except OSError: pass handle = None def release(): try: if handle is not None: if fcntl is not None: try: fcntl.flock(handle.fileno(), fcntl.LOCK_UN) except OSError: pass try: handle.close() except OSError: pass finally: thread_lock.release() return release def muse_transact(home, func): release = muse_acquire(home, "map") try: data = muse_load_map(home) result = func(data) muse_save_map(home, data) return result finally: release() def muse_session_root(): base = os.environ.get("XDG_DATA_HOME") or os.path.join(os.path.expanduser("~"), ".local", "share") return os.path.join(base, "muse", "sessions") def muse_session_alive(session_id): try: pattern = os.path.join(muse_session_root(), "*", "*", "*", session_id, "session.jsonl") return bool(glob.glob(pattern)) except (OSError, ValueError): return True def muse_session_split(messages, hashes): if len(hashes) > len(messages): return None for pos, digest in enumerate(hashes): if muse_message_hash(messages[pos]) != digest: return None return list(messages[len(hashes):]) def muse_valid_answer(text): try: data = json.loads(text) except ValueError: return None if not isinstance(data, dict): return None if not isinstance(data.get("content"), str) or not isinstance(data.get("tool_calls"), list): return None for call in data["tool_calls"]: if not isinstance(call, dict) or not isinstance(call.get("name"), str) or not isinstance(call.get("arguments"), str): return None return data def muse_gate(func): def gated(ok): func(ok) gated.armed = threading.Event() return gated def muse_reap(proc, stderr_done, on_reap): def target(): ok = False try: try: for _line in proc.stdout: pass except (OSError, ValueError): pass try: proc.stdout.close() except (OSError, ValueError): pass try: ok = proc.wait(timeout=MUSE_REAP_GRACE) == 0 except subprocess.TimeoutExpired: try: proc.kill() except OSError: pass try: proc.wait(timeout=10) except (OSError, subprocess.SubprocessError): pass try: stderr_done.wait(timeout=10) except (OSError, RuntimeError): pass finally: if on_reap is not None: try: on_reap(ok) except Exception: pass thread = threading.Thread(target=target, daemon=True) thread.start() return thread def muse_run(argv, cwd, stream_sink, timeout, on_reap=None): try: proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, cwd=cwd, bufsize=1) except OSError as exc: raise BackendError("muse exec failed: %s" % short_error(exc)) gate = getattr(on_reap, "armed", None) if gate is not None: gate.set() stderr_parts = [] stderr_done = threading.Event() def collect_stderr(): try: for chunk in proc.stderr: stderr_parts.append(chunk) except (OSError, ValueError): pass finally: stderr_done.set() threading.Thread(target=collect_stderr, daemon=True).start() finished = threading.Event() expired = threading.Event() def watchdog(): if not finished.wait(timeout): expired.set() try: proc.kill() except OSError: pass threading.Thread(target=watchdog, daemon=True).start() answer = None terminal = None buffer = [] try: for line in proc.stdout: text = line.strip() if not text.startswith("{"): continue try: event = json.loads(text) except ValueError: continue kind = event.get("payload_type") or "" payload = event.get("payload") or {} if kind == "run.output.delta" and payload.get("text"): piece = payload["text"] if stream_sink is not None: stream_sink(piece) buffer.append(piece) if answer is None: answer = muse_valid_answer("".join(buffer)) if answer is not None: break elif kind.startswith("run.terminal."): terminal = payload break finally: finished.set() muse_reap(proc, stderr_done, on_reap) if terminal is not None: if terminal.get("terminal") != "completed": raise BackendError("muse %s: %s" % (terminal.get("terminal") or "failed", (terminal.get("reason") or "unknown")[:300])) parsed = muse_valid_answer(terminal.get("text") or "") if parsed is None: raise BackendError("muse returned invalid structured answer") return parsed if answer is not None: return answer if expired.is_set(): raise BackendError("muse timed out after %ds" % timeout) try: code = proc.returncode except (OSError, ValueError): code = "?" raise BackendError("muse exit %s: %s" % (code, "".join(stderr_parts).strip()[-300:] or "no terminal event")) def _cli_version_detail(done, fallback): return (done.stdout.strip() or done.stderr.strip()).splitlines()[0][:60] if (done.stdout.strip() or done.stderr.strip()) else fallback def _cli_version_only_health(binary, fallback_name): """Shared by the CLIs with no cheap, read-only way to check real login state (muse, grok): `--version` only proves the binary runs, never that it's actually authenticated, so this deliberately never claims "ok" or "subscription" -- a claim this check cannot back up (see codex_health/ claude_health for the two CLIs that DO expose a real status command, used instead of this). Caught a real bug: this used to print "ok, subscription" unconditionally on a successful --version, which is false whenever the CLI is installed but never logged in.""" try: done = subprocess.run([binary, "--version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=15) except (OSError, subprocess.SubprocessError) as exc: return "fail: %s" % short_error(exc)[:60] if done.returncode != 0: return "fail: exit %d" % done.returncode return "cli found (%s), login unverified: no status command" % _cli_version_detail(done, fallback_name) def muse_health(): binary = muse_binary() if not binary: return "missing: muse CLI not found" return _cli_version_only_health(binary, "muse") def grok_health(): binary = grok_binary() if not binary: return "missing: grok CLI not found" return _cli_version_only_health(binary, "grok") def claude_health(): binary = claude_binary() if not binary: return "missing: claude CLI not found" try: done = subprocess.run([binary, "auth", "status"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=15) except (OSError, subprocess.SubprocessError) as exc: return "fail: %s" % short_error(exc)[:60] try: info = json.loads(done.stdout) except ValueError: info = None if isinstance(info, dict) and "loggedIn" in info: if info["loggedIn"]: return "ok, subscription (%s)" % (info.get("authMethod") or "logged in") return "not logged in" # `claude auth status` didn't return the expected shape (older/newer # CLI) -- fall back to the honest version-only signal instead of # claiming a subscription this check couldn't actually verify. return _cli_version_only_health(binary, "claude") def codex_health(): binary = codex_binary() if not binary: return "missing: codex CLI not found" try: done = subprocess.run([binary, "login", "status"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=15) except (OSError, subprocess.SubprocessError) as exc: return "fail: %s" % short_error(exc)[:60] detail = _cli_version_detail(done, "codex") if done.returncode != 0: return "not logged in: %s" % detail return "ok, subscription (%s)" % detail def gemini_health(): binary = gemini_binary() if not binary: return "missing: gemini CLI not found" return _cli_version_only_health(binary, "gemini") _OPENCODE_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" _OPENCODE_SESSION = "ses_" + "".join(random.choice(_OPENCODE_ID_ALPHABET) for _ in range(26)) def opencode_headers(): return { "User-Agent": os.environ.get("OPENCODE_CLIENT_USER_AGENT") or OPENCODE_USER_AGENT, "x-opencode-client": os.environ.get("OPENCODE_CLIENT_NAME") or OPENCODE_CLIENT_NAME, "x-opencode-project": "global", "x-opencode-session": _OPENCODE_SESSION, "x-opencode-request": "msg_" + "".join(random.choice(_OPENCODE_ID_ALPHABET) for _ in range(26)), } def model_speed_reward(tokens_per_second): if not tokens_per_second or tokens_per_second <= 0: return MODEL_NEUTRAL_REWARD return tokens_per_second / (tokens_per_second + MODEL_SPEED_REF_TPS) def model_latency_reward(latency_ms): if not latency_ms or latency_ms <= 0: return 1.0 return 1.0 / (1.0 + latency_ms / MODEL_LATENCY_REF_MS) def model_outcome_reward(latency_ms=None, tokens_per_second=None): return model_speed_reward(tokens_per_second) * model_latency_reward(latency_ms) def model_weight(entry): entry = entry or {} attempts = (entry.get("s") or 0) + (entry.get("f") or 0) return ((entry.get("r") or 0.0) + MODEL_NEUTRAL_REWARD * MODEL_WEIGHT_PRIOR) / (attempts + MODEL_WEIGHT_PRIOR) class ModelHealth: def __init__(self, home): self.path = os.path.join(home, MODEL_HEALTH_FILE) if home else "" self.data = {"models": {}, "lists": {}} if self.path: try: with open(self.path, "r", encoding="utf-8") as handle: raw = json.load(handle) if isinstance(raw, dict): if isinstance(raw.get("models"), dict): self.data["models"] = raw["models"] if isinstance(raw.get("lists"), dict): self.data["lists"] = raw["lists"] except (OSError, ValueError): pass def _save(self): if not self.path: return try: tmp = self.path + ".tmp" with open(tmp, "w", encoding="utf-8") as handle: json.dump(self.data, handle) os.replace(tmp, self.path) except OSError: pass def entry(self, provider, model): return self.data["models"].get("%s|%s" % (provider or "default", model or "")) or {} def weight(self, provider, model): return model_weight(self.entry(provider, model)) def circuit_open(self, provider, model): return (self.entry(provider, model).get("until") or 0.0) > time.time() def record(self, provider, model, success, latency_ms=None, tokens_per_second=None): if not model: return key = "%s|%s" % (provider or "default", model or "") entry = self.data["models"].setdefault(key, {"s": 0, "f": 0, "r": 0.0, "lat": 0.0, "tps": 0.0, "cf": 0, "until": 0.0, "used": 0.0, "n": 0}) entry["n"] += 1 entry["used"] = time.time() if success: reward = MODEL_NEUTRAL_REWARD if latency_ms is None and tokens_per_second is None else model_outcome_reward(latency_ms, tokens_per_second) entry["s"] += 1 entry["cf"] = 0 entry["until"] = 0.0 entry["r"] += reward if latency_ms is not None: entry["lat"] += latency_ms if tokens_per_second is not None: entry["tps"] += tokens_per_second else: entry["f"] += 1 entry["cf"] += 1 if entry["cf"] >= MODEL_CIRCUIT_THRESHOLD: entry["until"] = time.time() + MODEL_CIRCUIT_COOLDOWN self._save() def cached_list(self, name, ttl=OPENROUTER_FREE_CACHE_TTL): row = self.data["lists"].get(name) if not isinstance(row, dict): return None models = row.get("models") if not models or (time.time() - (row.get("at") or 0)) > ttl: return None return [str(item) for item in models if str(item).strip()] def store_list(self, name, models): cleaned = [str(item).strip() for item in models if str(item).strip()] if not cleaned: return self.data["lists"][name] = {"at": time.time(), "models": cleaned} self._save() def replace_list(self, name, provider, models): """Store a freshly discovered list and drop stats of this provider's models that are gone. Returns (added, removed).""" cleaned = [str(item).strip() for item in models if str(item).strip()] if not cleaned: return [], [] row = self.data["lists"].get(name) old = [str(item) for item in (row or {}).get("models") or []] if isinstance(row, dict) else [] prefix = provider + "|" for key in list(self.data["models"]): if key.startswith(prefix) and key[len(prefix):] not in cleaned: del self.data["models"][key] self.data["lists"][name] = {"at": time.time(), "models": cleaned} self._save() return [item for item in cleaned if item not in old], [item for item in old if item not in cleaned] def snapshot(self): rows = [] for key, entry in self.data["models"].items(): provider, _, model = key.partition("|") ok = entry.get("s") or 0 rows.append({ "provider": provider, "model": model, "weight": round(model_weight(entry), 4), "ok": ok, "fail": entry.get("f") or 0, "avg_ms": round((entry.get("lat") or 0.0) / ok) if ok and entry.get("lat") else None, "avg_tps": round((entry.get("tps") or 0.0) / ok, 1) if ok and entry.get("tps") else None, "circuit_open": (entry.get("until") or 0.0) > time.time(), "used": entry.get("used") or 0.0, }) rows.sort(key=lambda row: (-row["weight"], row["provider"], row["model"])) return rows def fetch_model_ids(models_url, key="", extra_headers=None, timeout=MODEL_LIST_TIMEOUT): headers = dict(extra_headers or {}) if key: headers["Authorization"] = "Bearer " + key try: request = urllib.request.Request(models_url, headers=headers, method="GET") with urllib.request.urlopen(request, timeout=timeout) as response: payload = json.loads(response.read().decode("utf-8", "replace")) except (OSError, ValueError): return None rows = payload.get("data") if isinstance(payload, dict) else None if not isinstance(rows, list): return None ids = [str(row.get("id")).strip() for row in rows if isinstance(row, dict) and str(row.get("id") or "").strip()] return ids or None def fetch_pollinations_free(): """Anonymous-tier model names from pollinations; None when unreachable.""" try: with urllib.request.urlopen(urllib.request.Request(POLLINATIONS_LIST_URL, method="GET"), timeout=MODEL_LIST_TIMEOUT) as response: rows = json.loads(response.read().decode("utf-8", "replace")) except (OSError, ValueError): return None if not isinstance(rows, list): return None names = [str(row.get("name")).strip() for row in rows if isinstance(row, dict) and row.get("tier") == "anonymous" and row.get("name") and (row.get("output_modalities") or ["text"]) == ["text"]] return names or None def is_free_openrouter(model): return model.endswith(":free") or model == "openrouter/free" def embedded_failure_message(data): if not isinstance(data, dict): return "" error = data.get("error") if isinstance(error, dict): message = error.get("message") or error.get("type") or "" return str(message).strip() if isinstance(error, str) and error.strip(): return error.strip() if data.get("type") == "error": return str(data.get("message") or "upstream error").strip() return "" def is_free_model(model): model = (model or "").lower() return model.endswith(":free") or model.endswith("-free") def prefer_muse(models): return sorted(models, key=lambda item: 0 if MUSE_LABEL in item.lower() else 1) def openrouter_free_tier(cand): model = (cand.get("model") or "").lower() muse = 0 if MUSE_LABEL in model else 1 if cand.get("provider") == OPENCODE_LABEL: return muse if cand.get("provider") == DEVPLACE_LABEL: return 2 + muse if cand.get("provider") == OPENROUTER_LABEL and is_free_model(model): return 4 + muse return 6 def models_report(chat): lines = ["label/model | weight ok/fail avg-ms avg-tps status"] seen = set() for cand in chat.ordered_candidates(): key = (cand["provider"], cand["model"]) if key in seen: continue seen.add(key) entry = chat.health.entry(cand["provider"], cand["model"]) ok = entry.get("s") or 0 fail = entry.get("f") or 0 avg_ms = str(round((entry.get("lat") or 0.0) / ok)) if ok and entry.get("lat") else "-" avg_tps = str(round((entry.get("tps") or 0.0) / ok, 1)) if ok and entry.get("tps") else "-" if cand["chat_url"] == MUSE_CHAT_URL: status = muse_health() elif chat.health.circuit_open(cand["provider"], cand["model"]): status = "circuit open" elif not ok and not fail: status = "untested" else: status = "ready" lines.append("%s/%s | %.4f %d/%d %s %s %s" % (cand["provider"], cand["model"], model_weight(entry), ok, fail, avg_ms, avg_tps, status)) return "\n".join(lines) class ChatClient: def __init__(self, config): self.config = config self.scope = ("default", "main") self.fixed_key = None self.provider_pin = None self.round_transient = False self.pin_fallback_said = False self.model_pin = None self.prefer = (DEVPLACE_LABEL, PREFERRED_MODEL) self.health = ModelHealth(getattr(config, "home", "") or "") def muse_key(self): if self.fixed_key is not None: return self.fixed_key return "%s/%s" % (self.scope[0], self.scope[1]) def reset_muse(self): home = getattr(self.config, "home", None) if home is None: return key = self.muse_key() def drop(data): data.pop(key, None) muse_transact(home, drop) def openrouter_models(self): models = [item for item in getattr(self.config, "openrouter_models", []) if item] explicit = getattr(self.config, "explicit_model", "") if explicit and explicit not in models: models.insert(0, explicit) free = self.health.cached_list("openrouter:free") if free is None: fetched = fetch_model_ids(OPENROUTER_BASE + "/models", self.config.openrouter_key) free = [item for item in fetched if is_free_openrouter(item)] if fetched else [] if free: self.health.store_list("openrouter:free", free) free = prefer_muse(free or []) models = [item for item in free if item not in models] + models if not models: models = [OPENROUTER_MODEL] return models def update_models(self): """Rediscover every model list from its source. A source that cannot be reached keeps its old list (reported, never emptied).""" sources = [("opencode:free", OPENCODE_LABEL, lambda: opencode_free_models() if opencode_binary() else None)] if getattr(self.config, "openrouter_enabled", True): def openrouter(): fetched = fetch_model_ids(OPENROUTER_BASE + "/models", self.config.openrouter_key) return [item for item in fetched if is_free_openrouter(item)] if fetched else None sources.append(("openrouter:free", OPENROUTER_LABEL, openrouter)) if self.config.devplace_key: def devplace(): fetched = fetch_model_ids(self.config.devplace_base + "/models", self.config.devplace_key) if not fetched: return None return [item for item in fetched if item in DEVPLACE_FREE_ROUTES or is_free_model(item)] sources.append(("devplace:free", DEVPLACE_LABEL, devplace)) if self.config.zen_enabled: sources.append(("zen:free", ZEN_LABEL, lambda: [item for item in fetch_model_ids(ZEN_BASE + "/models", extra_headers=opencode_headers()) or [] if is_free_model(item)] or None)) if self.config.pollinations_enabled: sources.append(("pollinations:free", POLLINATIONS_LABEL, fetch_pollinations_free)) if getattr(self.config, "ollama_enabled", True) is not False: sources.append(("ollama:local", OLLAMA_LABEL, lambda: fetch_model_ids(OLLAMA_BASE + "/models", "", timeout=3))) report = [] for name, label, fetch in sources: try: fresh = fetch() except Exception as exc: fresh = None print(paint("%s: discovery failed: %s" % (label, short_error(exc)), Ansi.DIM), file=sys.stderr) if not fresh: report.append((label, None, [], [])) continue added, removed = self.health.replace_list(name, label, fresh) report.append((label, len(fresh), added, removed)) return report def opencode_models(self): models = list(getattr(self.config, "opencode_models", []) or []) if not models: models = self.health.cached_list("opencode:free") if models is None: models = opencode_free_models() or [item for item in ZEN_FREE_MODELS if is_free_model(item)] self.health.store_list("opencode:free", models) return prefer_muse(models) def zen_models(self): models = list(getattr(self.config, "zen_models", []) or []) if models: return prefer_muse(models) cached = self.health.cached_list("zen:free") if cached is None: fetched = [item for item in fetch_model_ids(ZEN_BASE + "/models", extra_headers=opencode_headers()) or [] if is_free_model(item)] cached = fetched or list(ZEN_FREE_MODELS) if fetched: self.health.store_list("zen:free", fetched) return prefer_muse(cached) def pollinations_models(self): models = list(getattr(self.config, "pollinations_models", []) or []) if models: return models cached = self.health.cached_list("pollinations:free") if cached is None: fetched = fetch_pollinations_free() cached = fetched or list(POLLINATIONS_MODELS) if fetched: self.health.store_list("pollinations:free", fetched) return cached def ollama_models(self): if getattr(self.config, "ollama_enabled", True) is False: return [] models = list(getattr(self.config, "ollama_models", []) or []) if not models: models = self.health.cached_list("ollama:local", OLLAMA_LIST_TTL) if models is None: models = fetch_model_ids(OLLAMA_BASE + "/models", "", timeout=3) or [] if models: self.health.store_list("ollama:local", models) return models def devplace_models(self): models = list(getattr(self.config, "devplace_models", []) or []) if models: return prefer_muse(models) free = self.health.cached_list("devplace:free") if free is None: fetched = fetch_model_ids(self.config.devplace_base + "/models", self.config.devplace_key) if fetched is None: return list(DEVPLACE_FREE_ROUTES) free = [item for item in fetched if item in DEVPLACE_FREE_ROUTES or is_free_model(item)] if free: self.health.store_list("devplace:free", free) return prefer_muse(free or []) def candidates(self): found = [] if getattr(self.config, "opencode_enabled", True) and opencode_binary(): for model in self.opencode_models(): found.append({"provider": OPENCODE_LABEL, "chat_url": OPENCODE_CHAT_URL, "models_url": OPENCODE_MODELS_URL, "key": "", "model": model, "profile": ""}) for model in self.ollama_models(): found.append({"provider": OLLAMA_LABEL, "chat_url": OLLAMA_BASE + "/chat/completions", "models_url": OLLAMA_BASE + "/models", "key": "", "model": model, "profile": ""}) if getattr(self.config, "devplace_key", ""): for model in self.devplace_models(): found.append({"provider": DEVPLACE_LABEL, "chat_url": self.config.devplace_base + "/chat/completions", "models_url": self.config.devplace_base + "/models", "key": self.config.devplace_key, "model": model, "profile": ""}) for entry in self.config.backends: for model in split_models(entry.get("model")) or [self.config.model]: found.append({"provider": entry["label"], "chat_url": entry["base"] + "/chat/completions", "models_url": entry["base"] + "/models", "key": entry["key"], "model": model, "profile": entry.get("profile") or ""}) if getattr(self.config, "openrouter_enabled", True): for model in self.openrouter_models(): found.append({"provider": OPENROUTER_LABEL, "chat_url": OPENROUTER_BASE + "/chat/completions", "models_url": OPENROUTER_BASE + "/models", "key": self.config.openrouter_key, "model": model, "profile": ""}) if getattr(self.config, "pollinations_enabled", True): for model in self.pollinations_models(): found.append({"provider": POLLINATIONS_LABEL, "chat_url": POLLINATIONS_BASE + "/chat/completions", "models_url": POLLINATIONS_BASE + "/models", "key": "", "model": model, "profile": ""}) if getattr(self.config, "zen_enabled", False): for model in self.zen_models(): found.append({"provider": ZEN_LABEL, "chat_url": ZEN_BASE + "/chat/completions", "models_url": ZEN_BASE + "/models", "key": "", "model": model, "profile": "opencode"}) found.append({"provider": MUSE_LABEL, "chat_url": MUSE_CHAT_URL, "models_url": MUSE_MODELS_URL, "key": "", "model": self.config.model, "profile": ""}) if grok_binary(): found.append({"provider": GROK_LABEL, "chat_url": GROK_CHAT_URL, "models_url": GROK_MODELS_URL, "key": "", "model": self.config.model, "profile": ""}) if claude_binary(): found.append({"provider": CLAUDE_LABEL, "chat_url": CLAUDE_CHAT_URL, "models_url": CLAUDE_MODELS_URL, "key": "", "model": self.config.model, "profile": ""}) if codex_binary(): found.append({"provider": CODEX_LABEL, "chat_url": CODEX_CHAT_URL, "models_url": CODEX_MODELS_URL, "key": "", "model": self.config.model, "profile": ""}) if gemini_binary(): found.append({"provider": GEMINI_LABEL, "chat_url": GEMINI_CHAT_URL, "models_url": GEMINI_MODELS_URL, "key": "", "model": self.config.model, "profile": ""}) return found def ordered_candidates(self): http = [cand for cand in self.candidates() if cand["chat_url"] not in CLI_CHAT_URLS] cli = [cand for cand in self.candidates() if cand["chat_url"] in CLI_CHAT_URLS] ranked = sorted(enumerate(http), key=lambda pair: (openrouter_free_tier(pair[1]), -self.health.weight(pair[1]["provider"], pair[1]["model"]), pair[0])) usable = [cand for _, cand in ranked if not self.health.circuit_open(cand["provider"], cand["model"])] return self._apply_pins((usable or [cand for _, cand in ranked]) + cli) def _apply_pins(self, cands): pinned = cands if self.provider_pin: pinned = [cand for cand in pinned if (cand["provider"] or "").lower() == self.provider_pin] if self.model_pin: pinned = [cand for cand in pinned if cand["model"] == self.model_pin] if not pinned and self.provider_pin == DEVPLACE_LABEL and self.model_pin and getattr(self.config, "devplace_key", ""): base = self.config.devplace_base pinned = [{"provider": DEVPLACE_LABEL, "chat_url": base + "/chat/completions", "models_url": base + "/models", "key": self.config.devplace_key, "model": self.model_pin, "profile": ""}] if not pinned and cands and not self.pin_fallback_said: self.pin_fallback_said = True print(paint("%s: trying every other backend" % self.pin_note(), Ansi.DIM), file=sys.stderr) cands = pinned + [cand for cand in cands if not any(cand is item for item in pinned)] if self.prefer and not self.provider_pin and not self.model_pin: label, model = self.prefer first = [cand for cand in cands if cand["provider"] == label and cand["model"] == model] if not first and label == DEVPLACE_LABEL and getattr(self.config, "devplace_key", ""): base = self.config.devplace_base first = [{"provider": DEVPLACE_LABEL, "chat_url": base + "/chat/completions", "models_url": base + "/models", "key": self.config.devplace_key, "model": model, "profile": ""}] cands = first + [cand for cand in cands if not any(cand is item for item in first)] return cands def pin_note(self): bits = [] if self.provider_pin: bits.append("provider '%s'" % self.provider_pin) if self.model_pin: bits.append("model '%s'" % self.model_pin) return "no backend offers " + " and ".join(bits) if bits else "no backends available" def backends(self): rows = [] seen = set() for cand in self.candidates(): key = (cand["provider"], cand["chat_url"]) if key in seen: continue seen.add(key) rows.append((cand["provider"], cand["chat_url"], cand["models_url"], cand["key"], cand["model"])) return rows def headers(self, key, extra=None): headers = {"Content-Type": "application/json"} if key: headers["Authorization"] = "Bearer " + key if extra: headers.update(extra) return headers @staticmethod def summarize_errors(errors, shown=2, width=320): """One-line summary of a failed retry round. Missing-CLI entries are expected on machines without those CLIs, so actionable failures lead and the missing-CLI tail never takes the only display slot (CLI candidates run last, and the muse candidate is always appended even when its binary is absent). """ if not errors: return "no details" actionable = [entry for entry in errors if "CLI not found" not in entry] pool = actionable or errors unique = [] for entry in pool: if entry not in unique: unique.append(entry) text = "; ".join(unique[:shown]) if len(unique) > shown: text += "; +%d more" % (len(unique) - shown) skipped = len(errors) - len(pool) if skipped: text += " (%d missing CLI skipped)" % skipped return text[:width] def complete(self, messages, tools=None, stream_sink=None, ephemeral=False, deadline=None): try: return self._complete_inner(messages, tools, stream_sink, ephemeral, deadline) except BackendError as exc: profile, bot = self.scope LOG.event("ERROR", "llm", "fatal", "all backends failed: %s" % short_error(exc), profile=profile, bot=bot, meta={"error": short_error(exc)}) raise def _complete_inner(self, messages, tools=None, stream_sink=None, ephemeral=False, deadline=None): messages = clean_history(messages) payload = {"model": self.config.model, "messages": messages} if tools: payload["tools"] = tools payload["tool_choice"] = "auto" limit = getattr(self.config, "retry_seconds", 0) give_up = time.time() + limit if limit > 0 else None if deadline is not None: give_up = deadline if give_up is None else min(give_up, deadline) round_no = 0 while True: errors = [] self.round_transient = False cands = self.ordered_candidates() if round_no == 0 else self.all_candidates() if not cands: raise BackendError(self.pin_note()) for cand in cands: reply = self.try_candidate(cand, messages, tools, payload, stream_sink, ephemeral, errors) if reply is not None: return reply if not self.round_transient: raise BackendError("no backend can take this request, retrying will not help (%s)" % self.summarize_errors(errors, shown=6, width=900)) pause = RETRY_BACKOFF[min(round_no, len(RETRY_BACKOFF) - 1)] round_no += 1 if give_up is not None and time.time() + pause > give_up: raise BackendError("all backends failed after %d rounds (%s)" % (round_no, self.summarize_errors(errors, shown=6, width=900))) providers = len(set((cand["provider"], cand["chat_url"]) for cand in cands)) profile, bot = self.scope LOG.event("WARNING", "llm", "round_retry", "all %d candidates failed (round %d), retrying in %ds" % (len(cands), round_no, pause), profile=profile, bot=bot, meta={"round": round_no, "pause_s": pause, "candidates": len(cands), "errors": self.summarize_errors(errors)}) if sys.stderr.isatty(): sys.stderr.write("\n") print(paint("all %d candidate%s across %d backend%s failed (round %d), retrying in %ds: %s" % (len(cands), "" if len(cands) == 1 else "s", providers, "" if providers == 1 else "s", round_no, pause, self.summarize_errors(errors)), Ansi.DIM), file=sys.stderr) if round_no == 1 and not opencode_binary(): engine = container_engine() if engine and box_state(engine) == "missing": print(paint( "no local opencode CLI, and no sandbox box set up yet either -- that box " "would also work as a last-resort backend (see box_opencode_candidates()). " "Run `tai.py --env sandbox` once to build it: a small image, podman or " "docker, and it only ever touches that one container, never the rest of " "this machine.", Ansi.DIM ), file=sys.stderr) time.sleep(pause) def all_candidates(self): http = [cand for cand in self.candidates() if cand["chat_url"] not in CLI_CHAT_URLS] cli = [cand for cand in self.candidates() if cand["chat_url"] in CLI_CHAT_URLS] ranked = sorted(enumerate(http), key=lambda pair: (openrouter_free_tier(pair[1]), -self.health.weight(pair[1]["provider"], pair[1]["model"]), pair[0])) return self._apply_pins([cand for _, cand in ranked] + cli + self.box_opencode_candidates()) def box_opencode_candidates(self): """Last-resort only -- complete() reaches this via all_candidates(), never via ordered_candidates() (the round-0 fast path), so a normal turn that succeeds through a real backend never touches the box at all. If there's already a local opencode CLI, self.candidates() covers it directly and this stays empty; it only fires when that's missing AND the sandbox box is already running (never auto-created here -- see ensure_box(), which stays an explicit, approval-gated action via --env sandbox or the install tool).""" if not getattr(self.config, "opencode_enabled", True) or opencode_binary(): return [] engine = container_engine() if not engine or box_state(engine) != "running": return [] if not box_opencode_binary(engine): return [] models = self.box_opencode_models(engine) return [ {"provider": OPENCODE_LABEL, "chat_url": OPENCODE_CHAT_URL, "models_url": OPENCODE_MODELS_URL, "key": "", "model": model, "profile": "", "runner": "box", "engine": engine} for model in models ] def box_opencode_models(self, engine): cached = self.health.cached_list("opencode:box:free") if cached is not None: return prefer_muse(cached) try: done = box_exec(engine, ["opencode", "models", "opencode"], timeout=MODEL_LIST_TIMEOUT * 4) except (OSError, subprocess.SubprocessError): return [] text = done.stdout.decode("utf-8", "replace") models = [line.strip().split("/", 1)[1] for line in text.splitlines() if line.strip().startswith("opencode/")] models = [item for item in models if is_free_model(item)] if models: self.health.store_list("opencode:box:free", models) return prefer_muse(models) def call_candidate(self, cand, messages, tools, attempt, stream_sink, ephemeral): label, chat_url, key, model = cand["provider"], cand["chat_url"], cand["key"], cand["model"] extra = opencode_headers() if cand["profile"] == "opencode" else None timeout = STREAM_TIMEOUT if stream_sink is not None else HTTP_TIMEOUT if chat_url == MUSE_CHAT_URL: return self.muse_turn(messages, tools, stream_sink, label, timeout=timeout, ephemeral=ephemeral) if chat_url == GROK_CHAT_URL: return self.grok_turn(messages, tools, stream_sink, label, timeout=timeout) if chat_url == CLAUDE_CHAT_URL: return self.claude_turn(messages, tools, stream_sink, label, timeout=timeout) if chat_url == CODEX_CHAT_URL: return self.codex_turn(messages, tools, stream_sink, label, timeout=timeout) if chat_url == GEMINI_CHAT_URL: return self.gemini_turn(messages, tools, stream_sink, label, timeout=timeout) if chat_url == OPENCODE_CHAT_URL: if cand.get("runner") == "box": return self.box_opencode_turn(messages, tools, stream_sink, label, model, cand["engine"], timeout=timeout) return self.opencode_turn(messages, tools, stream_sink, label, model, timeout=timeout) if stream_sink is None: return self.single_shot(chat_url, key, attempt, label, extra) return self.streaming(chat_url, key, attempt, label, stream_sink, extra) def try_candidate(self, cand, messages, tools, payload, stream_sink, ephemeral, errors): label, model = cand["provider"], cand["model"] attempt = dict(payload, model=model) tries = [(tools, attempt)] if attempt.get("tools"): tries.append((None, {name: value for name, value in attempt.items() if name not in ("tools", "tool_choice")})) for pos, (use_tools, body) in enumerate(tries): started = time.time() try: reply = self.call_candidate(cand, messages, use_tools, body, stream_sink, ephemeral) if not isinstance(reply, dict): raise BackendError("backend returned no reply") self.health.record(label, model, True, (time.time() - started) * 1000, max(1, len(reply.get("content") or "") // 4) / max(time.time() - started, 0.001)) if LOG.enabled: self.log_completion(cand, messages, reply, use_tools, (time.time() - started) * 1000, pos > 0) return reply except BackendError as exc: if pos == 0 and exc.status == 400 and len(tries) > 1: continue self.health.record(label, model, False) transient = transient_failure(exc) self.round_transient = self.round_transient or transient errors.append("%s/%s: %s" % (label, model, exc)) profile, bot = self.scope LOG.event("WARNING", "llm", "failed", "%s/%s failed: %s" % (label, model, short_error(exc)), profile=profile, bot=bot, meta={"backend": label, "model": model, "error": short_error(exc), "transient": transient}) return None except Exception as exc: self.health.record(label, model, False) self.round_transient = True errors.append("%s/%s: %s" % (label, model, short_error(exc))) profile, bot = self.scope LOG.event("WARNING", "llm", "failed", "%s/%s failed: %s" % (label, model, short_error(exc)), profile=profile, bot=bot, meta={"backend": label, "model": model, "error": short_error(exc), "transient": True}) return None return None def log_completion(self, cand, messages, reply, use_tools, elapsed_ms, retried): profile, bot = self.scope def text_of(item): content = item.get("content") if isinstance(content, str): return content if content is None: return "" try: return json.dumps(content, sort_keys=True) except (TypeError, ValueError): return short_error(content) system_text = next((text_of(item) for item in messages if item.get("role") == "system"), "") user_text = next((text_of(item) for item in reversed(messages) if item.get("role") == "user"), "") assistant_text = reply.get("content") or "" tokens_in = conversation_tokens(messages, use_tools or []) tokens_out = estimate_tokens(assistant_text) + estimate_tokens(json.dumps(reply.get("tool_calls") or [])) calls = reply.get("tool_calls") or [] names = [(schema.get("function") or {}).get("name", "") for schema in (use_tools or []) if isinstance(schema, dict)] native = set(schema["function"]["name"] for schema in TOOL_SCHEMAS) mcp_count = sum(1 for name in names if name not in native) core_count = sum(1 for name in names if name in CORE_TOOLS) lazy_count = len(names) - mcp_count - core_count LOG.event("INFO", "llm", "completion", "%s/%s answered in %dms (%d tokens in, %d out)" % (cand["provider"], cand["model"], int(elapsed_ms), tokens_in, tokens_out), profile=profile, bot=bot, meta={"backend": cand["provider"], "model": cand["model"], "exec_ms": int(elapsed_ms), "tokens_in": tokens_in, "tokens_out": tokens_out, "tokens_total": tokens_in + tokens_out, "cost_usd": None, "history_msgs": len(messages), "system_chars": len(system_text), "user_chars": len(user_text), "assistant_chars": len(assistant_text), "system_text": system_text, "user_text": user_text, "assistant_text": assistant_text, "tool_calls": len(calls), "retried_without_tools": retried, "tools_sent": len(names), "tools_mcp": mcp_count, "tools_core": core_count, "tools_lazy": lazy_count, "tool_names": sorted(names)}) def single_shot(self, url, key, payload, label, extra=None): body = json.dumps(payload).encode("utf-8") request = urllib.request.Request(url, data=body, headers=self.headers(key, extra), method="POST") try: with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT) as response: data = json.loads(response.read().decode("utf-8", "replace")) except urllib.error.HTTPError as exc: raise BackendError("HTTP %d: %s" % (exc.code, exc.read().decode("utf-8", "replace")[:300]), exc.code) except urllib.error.URLError as exc: raise BackendError(short_error(exc)) failure = embedded_failure_message(data) if failure: raise BackendError("upstream error: %s" % failure[:300]) try: message = data["choices"][0]["message"] except (KeyError, IndexError, TypeError): raise BackendError("upstream returned no choices: %s" % json.dumps(data)[:300]) return self.normalize(message, label) def normalize(self, message, label): calls = [] for call in message.get("tool_calls") or []: func = call.get("function") or {} calls.append({"id": call.get("id") or uuid.uuid4().hex, "name": tool_name_clean(func.get("name")), "arguments": tool_args_text(func.get("arguments"))}) return {"role": "assistant", "content": message.get("content") or "", "reasoning": message.get("reasoning") or message.get("reasoning_content") or "", "tool_calls": calls, "backend": label} def streaming(self, url, key, payload, label, sink, extra=None): body = json.dumps(dict(payload, stream=True)).encode("utf-8") request = urllib.request.Request(url, data=body, headers=self.headers(key, extra), method="POST") try: response = urllib.request.urlopen(request, timeout=STREAM_TIMEOUT) except urllib.error.HTTPError as exc: raise BackendError("HTTP %d: %s" % (exc.code, exc.read().decode("utf-8", "replace")[:300]), exc.code) except urllib.error.URLError as exc: raise BackendError(short_error(exc)) content_parts = [] reasoning_parts = [] calls = {} last_pos = 0 try: for raw in response: line = raw.decode("utf-8", "replace").strip() if not line.startswith("data:"): continue data = line[5:].strip() if data == "[DONE]": break try: event = json.loads(data) except ValueError: continue choices = event.get("choices") or [] if not choices: continue delta = choices[0].get("delta") or {} piece = delta.get("content") if piece: content_parts.append(piece) sink(piece) think = delta.get("reasoning") or delta.get("reasoning_content") if think: reasoning_parts.append(think) for call in delta.get("tool_calls") or []: func = call.get("function") or {} pos = call.get("index", 0) if pos not in calls and calls and not call.get("id") and not func.get("name"): # Off-spec streams send an argument fragment under a fresh index # with no id or name: it continues the open call. pos = last_pos last_pos = pos slot = calls.setdefault(pos, {"id": "", "name": "", "arguments": ""}) if call.get("id"): slot["id"] = call["id"] if func.get("name"): slot["name"] += func["name"] if func.get("arguments"): slot["arguments"] += tool_args_text(func["arguments"]) except OSError as exc: if not content_parts and not calls: raise BackendError(short_error(exc)) finally: response.close() ordered = [calls[pos] for pos in sorted(calls)] for slot in ordered: slot["name"] = tool_name_clean(slot["name"]) slot["arguments"] = tool_args_valid(slot["arguments"]) if not slot["id"]: slot["id"] = uuid.uuid4().hex return {"role": "assistant", "content": "".join(content_parts), "reasoning": "".join(reasoning_parts), "tool_calls": ordered, "backend": label} def muse_turn(self, messages, tools, stream_sink, label, timeout=HTTP_TIMEOUT, ephemeral=False): binary = muse_binary() if not binary: raise BackendError("muse CLI not found (MUSE_BIN or PATH)") home = getattr(self.config, "home", None) if ephemeral or home is None: with tempfile.TemporaryDirectory(prefix="tai-muse-") as folder: schema_path = os.path.join(folder, "schema.json") prompt_path = os.path.join(folder, "prompt.txt") muse_write_file(schema_path, json.dumps(muse_output_schema())) muse_write_file(prompt_path, muse_prompt(messages, tools)) answer = muse_run(muse_argv(self.config, schema_path, prompt_path), folder, stream_sink, timeout) return self.muse_reply(answer, label) key = self.muse_key() release = muse_acquire(home, "session:" + key) def on_reap(ok): try: if not ok: self.reset_muse() finally: release() gated = muse_gate(on_reap) try: return self.muse_persistent(messages, tools, stream_sink, label, timeout, home, key, gated) except BaseException: if not gated.armed.is_set(): release() raise def muse_persistent(self, messages, tools, stream_sink, label, timeout, home, key, gated): def plan(data): entry = data.get(key) pending = None session_id = None if isinstance(entry, dict) and isinstance(entry.get("id"), str) and isinstance(entry.get("hashes"), list): pending = muse_session_split(messages, entry["hashes"]) if pending is not None and not muse_session_alive(entry["id"]): pending = None if pending is not None: session_id = entry["id"] if pending is None or not pending: session_id = str(uuid.uuid4()) pending = list(messages) return session_id, pending session_id, pending = muse_transact(home, plan) try: with tempfile.TemporaryDirectory(prefix="tai-muse-") as folder: schema_path = os.path.join(folder, "schema.json") prompt_path = os.path.join(folder, "prompt.txt") muse_write_file(schema_path, json.dumps(muse_output_schema())) muse_write_file(prompt_path, muse_prompt(pending, tools)) answer = muse_run(muse_argv(self.config, schema_path, prompt_path, session_id), folder, stream_sink, timeout, gated) except BackendError: self.reset_muse() raise def commit(data): data[key] = {"id": session_id, "hashes": [muse_message_hash(item) for item in messages], "updated": now_iso()} muse_transact(home, commit) return self.muse_reply(answer, label) def cli_json_turn(self, label, messages, tools, stream_sink, timeout, run, parse, raw_text): # Shared by the prompt-JSON CLI backends (opencode, grok, claude, # codex, gemini): the model must answer with one JSON object, and a # reply with no JSON envelope while tools are offered is a structural # contract miss, never a verdict on the prose itself. Repair once with # an explicit correction prompt, then accept prose as the final answer. # A failed repair keeps the first answer: it was already usable. raw = run(opencode_prompt(messages, tools), timeout) answer = parse(raw) text = raw_text(raw) if tools and text is not None and opencode_find_answer(text) is None: try: raw = run(cli_json_repair_prompt(messages, tools, text), timeout) except BackendError: pass else: answer = parse(raw) if stream_sink is not None and answer.get("content"): stream_sink(answer["content"]) return self.muse_reply(answer, label) def opencode_turn(self, messages, tools, stream_sink, label, model, timeout=HTTP_TIMEOUT): return self.cli_json_turn(label, messages, tools, stream_sink, timeout, lambda prompt, secs: opencode_run(model, prompt, secs), opencode_parse, lambda raw: raw) def box_opencode_turn(self, messages, tools, stream_sink, label, model, engine, timeout=HTTP_TIMEOUT): return self.cli_json_turn(label + " (box)", messages, tools, stream_sink, timeout, lambda prompt, secs: box_opencode_run(engine, model, prompt, secs), opencode_parse, lambda raw: raw) def grok_turn(self, messages, tools, stream_sink, label, timeout=HTTP_TIMEOUT): return self.cli_json_turn(label, messages, tools, stream_sink, timeout, grok_run, grok_parse_output, lambda raw: raw) def claude_turn(self, messages, tools, stream_sink, label, timeout=HTTP_TIMEOUT): return self.cli_json_turn(label, messages, tools, stream_sink, timeout, claude_run, claude_parse_output, lambda raw: raw) def codex_turn(self, messages, tools, stream_sink, label, timeout=HTTP_TIMEOUT): return self.cli_json_turn(label, messages, tools, stream_sink, timeout, codex_run, lambda raw: codex_parse_output(raw[0], raw[1], raw[2]), lambda raw: raw[1] if raw[1].strip() else raw[0]) def gemini_turn(self, messages, tools, stream_sink, label, timeout=HTTP_TIMEOUT): return self.cli_json_turn(label, messages, tools, stream_sink, timeout, gemini_run, gemini_parse_output, lambda raw: raw) def muse_reply(self, answer, label): calls = [] for call in answer.get("tool_calls") or []: calls.append({"id": uuid.uuid4().hex, "name": tool_name_clean(call.get("name")), "arguments": tool_args_text(call.get("arguments"))}) return {"role": "assistant", "content": answer.get("content") or "", "reasoning": "", "tool_calls": calls, "backend": label} def probe_backend(models_url, key): if models_url == MUSE_MODELS_URL: return muse_health() if models_url == GROK_MODELS_URL: return grok_health() if models_url == CLAUDE_MODELS_URL: return claude_health() if models_url == CODEX_MODELS_URL: return codex_health() if models_url == GEMINI_MODELS_URL: return gemini_health() if models_url == OPENCODE_MODELS_URL: return "ok, %s" % opencode_binary() if opencode_binary() else "opencode CLI not found" request = urllib.request.Request(models_url, headers={"Authorization": "Bearer " + key}) started = time.time() try: with urllib.request.urlopen(request, timeout=6) as response: try: payload = json.loads(response.read().decode("utf-8", "replace")) except ValueError: return "bad response" data = payload.get("data", []) if isinstance(payload, dict) else [] count = len(data) if isinstance(data, list) else 0 return "ok, %d models, %dms" % (count, (time.time() - started) * 1000) except urllib.error.HTTPError as exc: if exc.code == 401: return "needs key" return "http %d" % exc.code except OSError as exc: return "fail: %s" % short_error(exc)[:60] except Exception as exc: return "fail: %s" % short_error(exc)[:60] def edge_token(skew=0): ticks = int(time.time()) + skew + 11644473600 ticks -= ticks % 300 ticks *= 10000000 return hashlib.sha256(("%d%s" % (ticks, EDGE_TRUSTED_TOKEN)).encode("ascii")).hexdigest().upper() def edge_timestamp(): return time.strftime("%a %b %d %Y %H:%M:%S GMT+0000 (Coordinated Universal Time)", time.gmtime()) def edge_ssml(text, voice): safe = html.escape(text, quote=False).replace("\r", " ").replace("\n", " ") return "<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'><voice name='%s'><prosody pitch='+0Hz' rate='+0%%' volume='+0%%'>%s</prosody></voice></speak>" % (voice, safe) def edge_chunks(text, limit=4000): sentences = re.split(r"(?<=[.!?])\s+", text.strip()) parts = [] current = "" for sentence in sentences: trial = (current + " " + sentence).strip() if len(trial.encode("utf-8")) > limit and current: parts.append(current) current = sentence else: current = trial if current: parts.append(current) return parts or [text] def ws_send(sock, payload): if isinstance(payload, str): payload = payload.encode("utf-8") mask = os.urandom(4) header = bytes([0x81]) length = len(payload) if length < 126: header += bytes([0x80 | length]) elif length < 65536: header += bytes([0x80 | 126]) + struct.pack(">H", length) else: header += bytes([0x80 | 127]) + struct.pack(">Q", length) masked = bytes(piece ^ mask[pos % 4] for pos, piece in enumerate(payload)) sock.sendall(header + mask + masked) def ws_recv_exact(sock, count): data = b"" while len(data) < count: piece = sock.recv(count - len(data)) if not piece: raise BackendError("edge connection closed") data += piece return data def ws_recv(sock): opcode = 0 payload = b"" first = True while True: head = ws_recv_exact(sock, 2) done = head[0] & 0x80 part = head[0] & 0x0F masked = head[1] & 0x80 length = head[1] & 0x7F if length == 126: length = struct.unpack(">H", ws_recv_exact(sock, 2))[0] elif length == 127: length = struct.unpack(">Q", ws_recv_exact(sock, 8))[0] key = ws_recv_exact(sock, 4) if masked else b"" body = ws_recv_exact(sock, length) if length else b"" if masked: body = bytes(piece ^ key[pos % 4] for pos, piece in enumerate(body)) if part == 0x8: raise BackendError("edge closed stream") if part == 0x9: sock.sendall(bytes([0x8A, 0x00])) continue if part in (0x1, 0x2) and first: opcode = part first = False payload += body if done: return opcode, payload def edge_skew(header_text): match = re.search(r"Date:\s*(.+?)\r?$", header_text, re.MULTILINE | re.IGNORECASE) if not match: return 0 try: stamp = time.mktime(time.strptime(match.group(1).strip(), "%a, %d %b %Y %H:%M:%S %Z")) return int(stamp - time.time()) except (ValueError, OverflowError): return 0 def edge_connect(): skew = 0 for attempt in range(2): path = "%s?TrustedClientToken=%s&ConnectionId=%s&Sec-MS-GEC=%s&Sec-MS-GEC-Version=1-%s" % (EDGE_WS_PATH, EDGE_TRUSTED_TOKEN, uuid.uuid4().hex, edge_token(skew), EDGE_CHROMIUM) raw = socket.create_connection((EDGE_HOST, 443), timeout=20) sock = ssl.create_default_context().wrap_socket(raw, server_hostname=EDGE_HOST) sock.settimeout(30) key = base64.b64encode(os.urandom(16)).decode("ascii") muid = "".join(random.choice("0123456789ABCDEF") for _ in range(32)) handshake = "\r\n".join([ "GET %s HTTP/1.1" % path, "Host: %s" % EDGE_HOST, "Upgrade: websocket", "Connection: Upgrade", "Sec-WebSocket-Version: 13", "Sec-WebSocket-Key: %s" % key, "Pragma: no-cache", "Cache-Control: no-cache", "Origin: chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold", "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0", "Accept-Encoding: gzip, deflate, br, zstd", "Accept-Language: en-US,en;q=0.9", "Cookie: muid=%s;" % muid, "", "", ]) sock.sendall(handshake.encode("ascii")) answer = b"" while b"\r\n\r\n" not in answer: piece = sock.recv(4096) if not piece: break answer += piece header_text = answer.decode("latin-1", "replace") if " 101 " in header_text.split("\r\n", 1)[0]: return sock sock.close() if " 403 " in header_text and attempt == 0: skew = edge_skew(header_text) continue raise BackendError("edge handshake failed: %s" % header_text.split("\r\n", 1)[0][:200]) raise BackendError("edge handshake failed after retry") def edge_synthesize(text, voice): sock = edge_connect() audio = bytearray() try: config = "X-Timestamp:%s\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n{\"context\":{\"synthesis\":{\"audio\":{\"metadataoptions\":{\"sentenceBoundaryEnabled\":\"false\",\"wordBoundaryEnabled\":\"true\"},\"outputFormat\":\"audio-24khz-48kbitrate-mono-mp3\"}}}}" % edge_timestamp() ws_send(sock, config) for chunk in edge_chunks(text): message = "X-RequestId:%s\r\nContent-Type:application/ssml+xml\r\nX-Timestamp:%sZ\r\nPath:ssml\r\n\r\n%s" % (uuid.uuid4().hex, edge_timestamp(), edge_ssml(chunk, voice)) ws_send(sock, message) while True: opcode, payload = ws_recv(sock) if opcode == 0x2 and len(payload) > 2: head_len = int.from_bytes(payload[0:2], "big") audio += payload[2 + head_len:] elif opcode == 0x1 and "Path:turn.end" in payload.decode("utf-8", "replace"): break return bytes(audio) finally: try: sock.close() except OSError: pass def play_audio(path): if sys.platform == "darwin" and shutil.which("afplay"): runner = ["afplay", path] elif shutil.which("paplay"): runner = ["paplay", path] elif shutil.which("aplay"): runner = ["aplay", "-q", path] elif shutil.which("pw-play"): runner = ["pw-play", path] elif sys.platform == "win32": runner = ["powershell", "-c", "(New-Object Media.SoundPlayer '%s').PlaySync();" % path] else: return False try: subprocess.run(runner, timeout=300, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) return True except (OSError, subprocess.SubprocessError): return False def listen_audio(seconds, workdir): recorder = next((item for item in RECORDERS if shutil.which(item[0])), None) transcriber = next((name for name in TRANSCRIBERS if shutil.which(name)), "") if recorder is None or not transcriber: missing = [] if recorder is None: missing.append("audio recorder (install arecord, sox, or ffmpeg)") if not transcriber: missing.append("transcriber (install whisper-cpp or whisper)") return "listening unavailable, missing: " + ", ".join(missing) path = os.path.join(workdir, "listen-%d.wav" % int(time.time())) command = [part.replace("{seconds}", str(seconds)).replace("{path}", path) for part in recorder[1]] try: subprocess.run(command, timeout=seconds + 15, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) done = subprocess.run([transcriber, path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=180) except (OSError, subprocess.SubprocessError) as exc: return "listening failed: " + short_error(exc) finally: try: os.remove(path) except OSError: pass if done.returncode != 0: return "transcriber error: " + (done.stderr or "")[:500] text = (done.stdout or "").strip() return truncate(text, 3000, 500) if text else "heard nothing" def sysinfo_check_os(): try: uname = os.uname() base = "%s %s %s" % (uname.sysname, uname.release, uname.machine) except (AttributeError, OSError): base = sys.platform try: with open("/etc/os-release", "r", encoding="utf-8") as handle: fields = dict(line.strip().split("=", 1) for line in handle if "=" in line) pretty = fields.get("PRETTY_NAME", "").strip('"') if pretty: return "%s (%s)" % (pretty, base) except OSError: pass return base def sysinfo_check_python(): return "%s (%s)" % (sys.version.split()[0], sys.executable) def sysinfo_check_venv(): env_path = os.environ.get("VIRTUAL_ENV") or "" if env_path: return "yes (%s)" % env_path if sys.prefix != sys.base_prefix: return "yes (%s)" % sys.prefix return "no (system python at %s)" % sys.prefix def sysinfo_check_root(): geteuid = getattr(os, "geteuid", None) if geteuid is None: return "unknown (no geteuid on %s)" % sys.platform uid = geteuid() return "yes (uid 0)" if uid == 0 else "no (uid %d)" % uid def sysinfo_check_container(): engine = container_engine() if not engine: return "no engine (install podman or docker)" return "%s, box %s" % (engine, box_state(engine)) def sysinfo_check_binaries(): found = [] lost = [] for name in SYSINFO_BINARIES: (found if shutil.which(name) else lost).append(name) return "present: %s; missing: %s" % (", ".join(found) or "none", ", ".join(lost) or "none") def sysinfo_check_cpu(): count = os.cpu_count() or 0 try: one, five, fifteen = os.getloadavg() return "%d cores, load %.2f/%.2f/%.2f" % (count, one, five, fifteen) except OSError: return "%d cores" % count def sysinfo_check_disk(): usage = shutil.disk_usage(os.getcwd()) return "%.1f GiB free of %.1f GiB at %s" % (usage.free / 2 ** 30, usage.total / 2 ** 30, os.getcwd()) SYSINFO_CHECKS = ( ("os", sysinfo_check_os), ("python", sysinfo_check_python), ("venv", sysinfo_check_venv), ("root", sysinfo_check_root), ("container", sysinfo_check_container), ("binaries", sysinfo_check_binaries), ("cpu", sysinfo_check_cpu), ("disk", sysinfo_check_disk), ) class SysInfo: """Host inspection probes (static; tests patch collect_sysinfo live).""" @staticmethod def collect_sysinfo(names=None): wanted = [pair for pair in SYSINFO_CHECKS if names is None or pair[0] in names] results = {} def target(name, func): started = time.time() try: value = func() except Exception as exc: value = "error: " + short_error(exc) results[name] = (value, int((time.time() - started) * 1000)) workers = [threading.Thread(target=target, args=(name, func), daemon=True) for name, func in wanted] for worker in workers: worker.start() deadline = time.time() + Consts.SYSINFO_TIMEOUT for worker in workers: worker.join(timeout=max(0, deadline - time.time())) lines = [] for name, _func in wanted: if name in results: value, elapsed = results[name] lines.append("%s: %s (%dms)" % (name, value, elapsed)) else: lines.append("%s: timed out after %ds" % (name, Consts.SYSINFO_TIMEOUT)) return "\n".join(lines) def collect_sysinfo(names=None): if names is None: return SysInfo.collect_sysinfo() return SysInfo.collect_sysinfo(names) def tool_schema(name, description, properties, required): return {"type": "function", "function": {"name": name, "description": description, "parameters": {"type": "object", "properties": properties, "required": required}}} TOOL_SCHEMAS = [ tool_schema("shell", "Run a shell command. Output is truncated. On home, destructive commands ask the user first. On sandbox, commands run isolated in the container and need no approval. Pass secrets to expose named vault secrets as TAI_SECRET_<NAME> variables for that command only; using secrets asks the user first. For local commands only: use web_fetch for HTTP(S), never curl or wget.", {"command": {"type": "string"}, "workdir": {"type": "string"}, "secrets": {"type": "array"}}, ["command"]), tool_schema("read_file", "Read a text file. Large files are truncated.", {"path": {"type": "string"}}, ["path"]), tool_schema("write_file", "Write content to a file, creating parent directories. Overwrites existing files.", {"path": {"type": "string"}, "content": {"type": "string"}}, ["path", "content"]), tool_schema("edit_file", "Replace one exact text match in a file. Fails unless the match is unique.", {"path": {"type": "string"}, "find": {"type": "string"}, "replace": {"type": "string"}}, ["path", "find", "replace"]), tool_schema("web_search", "Search the web. Set images for image search, content to include fetched page text.", {"query": {"type": "string"}, "images": {"type": "boolean"}, "content": {"type": "boolean"}}, ["query"]), tool_schema("describe_image", "Describe an image with a vision model (rsearch). Pass an http(s) url or a local file path (PNG, JPEG, GIF, WebP, BMP, max 10 MB).", {"source": {"type": "string"}}, ["source"]), tool_schema("web_fetch", "HTTP client: fetch a URL with any method and return status, content type, size, and content. GET by default; pass method (POST, PUT, PATCH, DELETE, HEAD, OPTIONS), headers, and a string body for APIs. HTML becomes readable text, JSON stays raw, raw forces the raw body. Use this for all HTTP(S), never curl or wget via shell. Pass auth_secret to send a vault secret as an Authorization header; the value stays sealed and secret use asks the user first.", {"url": {"type": "string"}, "method": {"type": "string"}, "headers": {"type": "object"}, "body": {"type": "string"}, "timeout": {"type": "integer"}, "raw": {"type": "boolean"}, "auth_secret": {"type": "string"}, "auth_header": {"type": "string"}, "auth_prefix": {"type": "string"}}, ["url"]), tool_schema("speak", "Synthesize text to speech with a free voice, save the MP3, and play it when a player exists.", {"text": {"type": "string"}, "voice": {"type": "string"}}, ["text"]), tool_schema("listen", "Record from the microphone for some seconds and transcribe it. Needs OS audio tools.", {"seconds": {"type": "integer"}}, []), tool_schema("remember", "Update your own system message with new knowledge or behavior. The instruction is merged into the existing system message, unrelated parts stay intact. Use it to forget as well by instructing what to remove. Execute by default when behavior, preferences, or durable facts need updating. Never pass passwords, tokens, or secret values here; store those with store_secret instead.", {"instruction": {"type": "string"}}, ["instruction"]), tool_schema("recall", "Search past session memory of the current profile by keyword, optionally filtered by tags.", {"query": {"type": "string"}, "tags": {"type": "array"}}, ["query"]), tool_schema("load_skill", "Load a skill by name. Returns full instructions plus bundled file paths. Missing skills with a blueprint are researched and built on demand.", {"name": {"type": "string"}}, ["name"]), tool_schema("get_current_terminal_content", "Capture visible text of the user's current terminal pane including scrollback, plus recent shell history. With no target the pane resolves straight from the TMUX_PANE or TAI_PLEXER_PANE environment variable, no scan needed, so prefer this over list_terminals and tail_terminals whenever you need the current terminal. Pass target (a pane id like %3 or session:window.pane from list_terminals) to capture another open terminal instead. Works inside tmux, under tai's own multiplexer, or against a running tmux server.", {"lines": {"type": "integer"}, "target": {"type": "string"}}, []), tool_schema("list_terminals", "List every open tmux terminal: all panes in all sessions with ids, current commands, and working directories, plus session/window/pane counts. Only for an overview of ALL terminals; for the user's current terminal call get_current_terminal_content with no target instead (it resolves via env). Use a pane id as target for get_current_terminal_content. Needs a running tmux server.", {}, []), tool_schema("tail_terminals", "One overview of all open tmux terminals: the last lines (default 3) of each at once, labeled with pane ids so you can spot which terminal holds what, then capture it fully with get_current_terminal_content. Only for an overview of ALL terminals; for the user's current terminal call get_current_terminal_content with no target instead (it resolves via env). Needs a running tmux server.", {"lines": {"type": "integer"}}, []), tool_schema("export_terminal_log", "Only available inside a live tmux session. Exports the full pane scrollback (tmux capture-pane -pS -, the entire history, not just what's visible) to a log file under tai's home dir, then chunks it and indexes the chunks for retrieval with SQLite FTS5 + bm25 (lexical only, no embeddings). From then on, use the search tool (kind chunk) to answer questions about anything that happened in this terminal; it returns the actual chunk text, not just a snippet.", {"tags": {"type": "array"}}, []), tool_schema("plexer_send", "Only available when a tai-managed terminal session is active (either tai's own native wrapper, or an active tmux pane). Sends literal keystrokes into that session's shell, as if typed. Set enter to also press Enter and actually run it. No output is returned by this call; follow up with plexer_read.", {"text": {"type": "string"}, "enter": {"type": "boolean"}}, ["text"]), tool_schema("plexer_read", "Only available when a tai-managed terminal session is active. Reads back the last N lines the session's shell has produced (its actual rendered output, not a tool-call summary).", {"lines": {"type": "integer"}}, []), tool_schema("plexer_type", "Only available when a tai-managed terminal session is active. Types text into the session one character at a time with a delay between keystrokes, for a demo/animated-typing effect, then presses Enter unless enter is set to false.", {"text": {"type": "string"}, "delay": {"type": "number"}, "enter": {"type": "boolean"}}, ["text"]), tool_schema("plexer_status", "Only available when a tai-managed terminal session is active. Reports which backend is managing the current session (tai's own native pty wrapper, or tmux) and backend-specific details (pid/socket/log, or pane id).", {}, []), tool_schema("plexer_show", "Only available when a tai-managed terminal session is active. Shows an interactive UI flow on the user's terminal and waits for the outcome: one spec with screens, each with body text, fields and actions. Field types: choice (options, single select), text (free input), info (display only). Fields and screens take show_if conditions on earlier answers ({field: expected}); next routes on ({action: screen}, a screen id, $done, $cancel). Example: {screens: [{id: main, body: Deploy?, fields: [{id: env, type: choice, options: [staging, prod]}], actions: [{id: ok, label: OK}], next: $done}]}. Returns {status: done/cancelled/timeout, answers: {field: value}, action}. Full contract: uiflow.md. Only for real blockers a human must decide; in autonomous mode and for background workers it is unavailable, so decide yourself and state the assumption.", {"spec": {"type": "object"}, "timeout": {"type": "integer"}}, ["spec"]), tool_schema("tui_screen", "Only available while a grr TUI (tai.py --tui) runs on this machine. Returns what its screen shows now (route, size, focus, the text). Set until to wait for that text to appear (timeout seconds), set async to get a job id at once and collect it with tui_result.", {"until": {"type": "string"}, "timeout": {"type": "integer"}, "async": {"type": "boolean"}}, []), tool_schema("tui_keys", "Only available while a grr TUI runs. Presses keys in it like a user: names such as down, up, enter, escape, space, tab, f1, ctrl+n, ctrl+p, or single characters. Async calls return a job id for tui_result.", {"keys": {"type": "array", "items": {"type": "string"}}, "async": {"type": "boolean"}}, ["keys"]), tool_schema("tui_type", "Only available while a grr TUI runs. Types text into the focused box (a newline presses Enter). Async calls return a job id for tui_result.", {"text": {"type": "string"}, "async": {"type": "boolean"}}, ["text"]), tool_schema("tui_click", "Only available while a grr TUI runs. Clicks the cell at column x, row y (0 based, as in tui_screen text). Async calls return a job id for tui_result.", {"x": {"type": "integer"}, "y": {"type": "integer"}, "async": {"type": "boolean"}}, ["x", "y"]), tool_schema("tui_goto", "Only available while a grr TUI runs. Opens a route such as /chat/new, /bots, /settings, /automations, /bot/<id>. Async calls return a job id for tui_result.", {"route": {"type": "string"}, "async": {"type": "boolean"}}, ["route"]), tool_schema("tui_result", "Only available while a grr TUI runs. Collects an async tui_* job by id, waiting up to wait seconds; the state is running, done or error.", {"id": {"type": "integer"}, "wait": {"type": "integer"}}, ["id"]), tool_schema("fork", "Spawn a background subagent with its own context that works while you continue. Returns an agent id immediately. Collect its summarized result with poll. Subagents get a smaller step budget and a time limit.", {"task": {"type": "string"}, "timeout": {"type": "integer"}, "profile": {"type": "string"}}, ["task"]), tool_schema("poll", "Collect a background subagent result by id. Waits up to wait seconds, then reports running or the result.", {"id": {"type": "integer"}, "wait": {"type": "integer"}}, ["id"]), tool_schema("ask_user", "Ask the user one question mid-turn and wait for their answer. Use this instead of ending your turn with questions in prose: an empty tool_calls list ends the turn at once and one-shot runs then exit, so prose questions are never answered. Pass the question plus optional options; the user picks an option (by number or text) or types free input, and their answer comes back as the tool result. Only for real blockers a human must decide; in autonomous mode and for background workers it is unavailable, so decide yourself and state the assumption.", {"question": {"type": "string"}, "options": {"type": "array"}}, ["question"]), tool_schema("sysinfo", "Inspect the host machine where tai runs. Runs os, python, venv, root, container, binaries, cpu, and disk checks in parallel and reports each with timing. Pass checks to run a named subset.", {"checks": {"type": "array"}}, []), tool_schema("create_skill", "Create a new agent skill by name from a brief. Runs a dedicated deep-research worker on this machine (it calls sysinfo first, then researches with web search and shell until it has verified information) and writes SKILL.md plus supporting files into the skill directory. Runs synchronously and can take many minutes. Scope project writes under ./.tai/skills (the default), scope home under ~/.tai/skills.", {"name": {"type": "string"}, "brief": {"type": "string"}, "scope": {"type": "string"}, "timeout": {"type": "integer"}}, ["name", "brief"]), tool_schema("install_skill", "Install a skill that already exists somewhere else, instead of building one from scratch. source can be a local directory containing SKILL.md (or one subdirectory that does), a bare SKILL.md file, a local .zip, or a git URL (needs git installed; most sources need nothing beyond the standard library). Validates the SKILL.md frontmatter, then lands it under tai's own skill directories: copy vendors a private snapshot, symlink points at the source in place (the default when the source already sits in a recognized skills folder like .claude/skills or .agents/skills, so there is one canonical copy instead of a drifting duplicate). Scope defaults like create_skill: project when the cwd looks like a project, else home. Overwriting an existing skill of the same name asks the user first unless force is set.", {"source": {"type": "string"}, "name": {"type": "string"}, "scope": {"type": "string"}, "mode": {"type": "string"}, "force": {"type": "boolean"}}, ["source"]), tool_schema("store_secret", "Store a password, token, or secret in the sealed vault under a name. Values are encrypted at rest, never shown back, and only usable by reference; prefer /secret set in the REPL so the value never enters the conversation. Optional username, host, port, notes, expires (ISO datetime), and tags describe it; name and value suffice, never pester the user for more.", {"name": {"type": "string"}, "value": {"type": "string"}, "username": {"type": "string"}, "host": {"type": "string"}, "port": {"type": "integer"}, "notes": {"type": "string"}, "expires": {"type": "string"}, "tags": {"type": "array"}}, ["name", "value"]), tool_schema("list_secrets", "List vault secret names with metadata and tags. Values are never revealed.", {}, []), tool_schema("delete_secret", "Delete a vault secret by name. Asks the user first.", {"name": {"type": "string"}}, ["name"]), tool_schema("schedule", "Schedule a prompt to run at a future time or on a repeating interval. Exactly one of at (ISO datetime, naive means local time) or every (interval seconds, minimum 60) is required. When due, a background subagent runs the prompt without blocking anyone; inspect outcomes with schedules. Asks the user first. Optional tags label it.", {"at": {"type": "string"}, "every": {"type": "integer"}, "prompt": {"type": "string"}, "name": {"type": "string"}, "timeout": {"type": "integer"}, "profile": {"type": "string"}, "tags": {"type": "array"}}, ["prompt"]), tool_schema("unschedule", "Delete a scheduled prompt by id. Asks the user first.", {"id": {"type": "integer"}}, ["id"]), tool_schema("schedules", "List scheduled prompts with due times and last outcomes.", {}, []), tool_schema("record_save", "Save text as a tagged record in the vault and get back a mem:<hash> id. Use for anything too big or too durable for chat: research notes, command output, transcripts. Kind is one of note, output, file, research, transcript. Tag rules: lowercase singular, reuse tags from the tags tool, at most 5; known words in the text attach automatically and the record links to recent same-tag records.", {"title": {"type": "string"}, "content": {"type": "string"}, "kind": {"type": "string"}, "tags": {"type": "array"}}, ["content"]), tool_schema("record_read", "Read a slice of a record by mem:<hash> id. Offset and limit page through huge records without loading them whole.", {"id": {"type": "string"}, "offset": {"type": "integer"}, "limit": {"type": "integer"}}, ["id"]), tool_schema("record_search", "Search records by text, kind, and tags. Returns mem:<hash> ids with sizes so you can page in only what you need.", {"query": {"type": "string"}, "kind": {"type": "string"}, "tags": {"type": "array"}, "limit": {"type": "integer"}}, []), tool_schema("record_delete", "Delete a record by mem:<hash> id. Asks the user first.", {"id": {"type": "string"}}, ["id"]), tool_schema("graph_link", "Link two vault nodes with a relation: mem:<hash> records, secret:<name> secrets, sched:<id> schedules. Both endpoints must exist.", {"src": {"type": "string"}, "dst": {"type": "string"}, "relation": {"type": "string"}}, ["src", "dst"]), tool_schema("graph_query", "Show the neighborhood of a vault node (mem:<hash>, secret:<name>, sched:<id>) by breadth-first traversal. Depth caps at 4.", {"node": {"type": "string"}, "depth": {"type": "integer"}, "limit": {"type": "integer"}}, ["node"]), tool_schema("delete_file", "Delete a file. Requires reading it first; the full pre-image stays in the audit trail so restore can undelete. Asks the user first.", {"path": {"type": "string"}}, ["path"]), tool_schema("audit", "Show the file audit trail for time travel: every write, edit, delete, shell snapshot, release, and restore with sizes and messages. Filter by exact path or tag, or rank by a full-text query over paths and messages.", {"path": {"type": "string"}, "tag": {"type": "string"}, "query": {"type": "string"}, "limit": {"type": "integer"}}, []), tool_schema("restore", "Restore a file to an audit row: the post-image for writes and edits, the pre-image for deletes and shell snapshots. Refuses truncated images. Asks the user first.", {"id": {"type": "integer"}}, ["id"]), tool_schema("release", "Cut a release of the running script: bump VERSION by part (patch for fixes, minor for features, major for breaking changes), keep a versioned backup, log the message in the audit trail. Asks the user first.", {"part": {"type": "string"}, "message": {"type": "string"}}, ["part", "message"]), tool_schema("tags", "List vault tags with usage counts, most used first. Consult before tagging so new items reuse established tags instead of inventing synonyms.", {"prefix": {"type": "string"}, "limit": {"type": "integer"}}, []), tool_schema("install", "Manage tai installations: binary, command-not-found hook and plexer auto-wrap hook for bash/zsh/fish, venv, scheduler service, telegram service, container. Status reports what exists. Install adds missing pieces (skipping a shell hook loudly if that shell isn't on the machine), upgrade refreshes in place and restarts services, reinstall rebuilds artifacts, uninstall removes them. Service changes take effect immediately. The vault is never touched by any action. Non-status actions ask the user first.", {"action": {"type": "string"}, "targets": {"type": "array"}}, ["action"]), tool_schema("create_bot", "Create a bot in the current profile: a name plus its own system message and history. Give at least one of description, rules, or behavior; optional nicknames register as @mention aliases (a short form of the name registers automatically). Names are lowercase.", {"name": {"type": "string"}, "description": {"type": "string"}, "rules": {"type": "string"}, "behavior": {"type": "string"}, "nicknames": {"type": "array"}}, ["name"]), tool_schema("search", "Search everything at once with ranked full-text matching (SQLite FTS5 + bm25, no vectors): records, episodic events, the audit trail, and RAG chunks. Chunks are the retrieval-grade granular hits (e.g. one slice of an exported terminal log) and come back with their actual text, not just a snippet; use the chunk's parent id with record_read to see more surrounding context. One call replaces paging through each store separately. expand adds one graph hop per record hit.", {"query": {"type": "string"}, "kinds": {"type": "array"}, "limit": {"type": "integer"}, "expand": {"type": "boolean"}}, ["query"]), tool_schema("mcp_add", "Register an MCP server from pasted JSON/TOML config or a github URL. Normalizes the config, shows the exact spawn for approval, verifies with a live handshake, stores the connection, and enables it for the profile. Github URLs resolve via registry, package files, and README run blocks, verified live before storing.", {"source": {"type": "string"}, "name": {"type": "string"}, "profile": {"type": "string"}}, ["source"]), tool_schema("mcp_list", "List registered MCP servers with transport, enabled profiles, tool counts, and origin.", {}, []), tool_schema("mcp_enable", "Enable an MCP server for a profile (current when omitted).", {"server": {"type": "string"}, "profile": {"type": "string"}}, ["server"]), tool_schema("mcp_disable", "Disable an MCP server for a profile (current when omitted).", {"server": {"type": "string"}, "profile": {"type": "string"}}, ["server"]), tool_schema("mcp_remove", "Delete an MCP server with its profile rows, pins, and cached tools. Asks first.", {"server": {"type": "string"}}, ["server"]), tool_schema("mcp_tools", "List cached MCP tools with descriptions and pin state, for one server or all.", {"server": {"type": "string"}}, []), tool_schema("mcp_call", "Call one MCP tool explicitly: full path with drift check, approval, caps, and audit. Bridged mcp_<server>_<tool> names call the same path.", {"server": {"type": "string"}, "tool": {"type": "string"}, "args": {"type": "object"}}, ["server", "tool"]), tool_schema("mcp_login", "OAuth login to a remote MCP server: opens the browser, waits for approval, stores tokens sealed in the vault, then verifies and enables. Run in a REPL, not headless.", {"server": {"type": "string"}, "profile": {"type": "string"}}, ["server"]), ] CORE_TOOLS = ("shell", "read_file", "write_file", "remember", "recall", "load_skill", "fork", "poll", "ask_user") ASK_USER_MAX_OPTIONS = 12 ASK_USER_MAX_ANSWER = 2000 LAZY_NAME_STOP = ("get", "set", "list", "add") # Tools gated on top of the lazy keyword match: a gate must pass before a tool can even be # offered to the model, regardless of relevance. Used for tools that only make sense in a # specific environment (e.g. a tmux pane to capture). TOOL_ENV_GATES = { "export_terminal_log": lambda: bool(os.environ.get("TMUX")), "plexer_send": lambda: PlexerControllerFactory.detect() is not None, "plexer_read": lambda: PlexerControllerFactory.detect() is not None, "plexer_type": lambda: PlexerControllerFactory.detect() is not None, "plexer_status": lambda: PlexerControllerFactory.detect() is not None, "plexer_show": lambda: PlexerControllerFactory.detect() is not None, "tui_screen": lambda: os.path.exists(grr_control_path()), "tui_keys": lambda: os.path.exists(grr_control_path()), "tui_type": lambda: os.path.exists(grr_control_path()), "tui_click": lambda: os.path.exists(grr_control_path()), "tui_goto": lambda: os.path.exists(grr_control_path()), "tui_result": lambda: os.path.exists(grr_control_path()), } def tool_env_ok(name): gate = TOOL_ENV_GATES.get(name) return gate is None or gate() TOOL_TAGS = { "shell": ("run", "execute", "command", "bash", "terminal", "script"), "read_file": ("read", "open", "view", "contents"), "write_file": ("write", "create", "overwrite"), "edit_file": ("edit", "change", "modify", "replace", "patch", "fix", "alter"), "web_search": ("search", "web", "internet", "lookup", "google"), "describe_image": ("image", "describe", "vision", "picture", "photo", "screenshot"), "web_fetch": ("fetch", "download", "url", "link", "page", "curl", "wget"), "speak": ("speak", "say", "voice", "talk", "tts"), "listen": ("listen", "hear", "microphone", "mic", "transcribe", "stt", "dictate"), "remember": ("remember", "memorize", "preference"), "recall": ("recall", "memory", "history", "past"), "load_skill": ("skill", "capability"), "get_current_terminal_content": ("terminal", "tmux", "pane", "scrollback", "screen", "history", "bash"), "list_terminals": ("terminal", "tmux", "panes", "sessions", "windows", "list", "open", "count"), "tail_terminals": ("terminal", "tmux", "panes", "tail", "peek", "overview", "every"), "export_terminal_log": ("terminal", "tmux", "export", "log", "capture", "history", "rag", "index"), "plexer_send": ("plexer", "terminal", "tmux", "keys", "send-keys", "type", "keystroke"), "plexer_read": ("plexer", "terminal", "tmux", "pane", "read", "capture"), "plexer_type": ("plexer", "terminal", "tmux", "type", "animate", "keystroke", "demo"), "plexer_status": ("plexer", "terminal", "tmux", "status", "session"), "plexer_show": ("plexer", "dialog", "flow", "menu", "form", "wizard", "screen", "ui"), "tui_screen": ("tui", "gui", "screen", "interface", "ui"), "tui_keys": ("tui", "gui", "keys", "press", "keyboard", "navigate"), "tui_type": ("tui", "gui", "type", "input", "text"), "tui_click": ("tui", "gui", "click", "mouse"), "tui_goto": ("tui", "gui", "open", "route", "navigate", "goto"), "tui_result": ("tui", "gui", "result", "async", "job"), "fork": ("fork", "subagent", "background", "parallel", "delegate", "spawn"), "poll": ("poll", "collect"), "ask_user": ("ask", "question", "clarify", "confirm", "choose", "answer", "input"), "sysinfo": ("sysinfo", "system", "host", "machine", "hardware", "specs", "installed", "python", "container"), "create_skill": ("skill", "create", "author", "blueprint"), "install_skill": ("skill", "install", "import", "adopt", "copy", "symlink"), "store_secret": ("secret", "password", "token", "credential", "passwd", "vault", "apikey"), "list_secrets": ("secrets", "vault", "credentials"), "delete_secret": ("secret", "remove", "revoke"), "schedule": ("schedule", "cron", "appointment", "reminder", "recurring", "periodic", "later", "daily", "hourly", "interval"), "unschedule": ("unschedule", "cancel"), "schedules": ("schedules", "upcoming", "planned", "agenda"), "record_save": ("record", "note", "document", "memo"), "record_read": ("record", "page"), "record_search": ("record", "find"), "record_delete": ("record", "remove"), "graph_link": ("graph", "link", "connect", "relate", "edge", "relation"), "graph_query": ("graph", "neighbors", "neighborhood", "traverse", "related"), "delete_file": ("delete", "remove", "erase"), "audit": ("audit", "trail", "changes", "blame", "history", "version"), "restore": ("restore", "undo", "revert", "recover", "rollback", "undelete", "previous"), "release": ("release", "version", "bump", "changelog", "publish"), "tags": ("tags", "tag", "label", "categorize", "taxonomy", "vocabulary"), "install": ("install", "uninstall", "reinstall", "upgrade", "setup", "service", "systemd", "venv", "hook", "deploy", "zsh", "fish"), "create_bot": ("bot", "bots", "persona", "character", "mention"), "search": ("search", "find", "lookup", "everything"), "mcp_add": ("mcp", "server", "register", "connect", "integrate"), "mcp_list": ("mcp", "servers", "connections"), "mcp_enable": ("mcp", "enable", "activate"), "mcp_disable": ("mcp", "disable", "deactivate"), "mcp_remove": ("mcp", "remove", "delete", "unregister"), "mcp_tools": ("mcp", "tools"), "mcp_call": ("mcp", "call", "invoke"), "mcp_login": ("mcp", "login", "oauth", "auth"), } def tool_catalog(): lines = ["", "", "## Tool catalog (core loads always; name a lazy tool or topic to load it)"] for schema in TOOL_SCHEMAS: name = schema["function"]["name"] if not tool_env_ok(name): continue if name in CORE_TOOLS: continue first = schema["function"]["description"].split(".")[0][:80] lines.append("- %s: %s" % (name, first)) return "\n".join(lines) def conversation_text(messages): parts = [] for item in messages: content = item.get("content") if item.get("role") in ("user", "assistant", "tool") and isinstance(content, str) and content: parts.append(content) return "\n".join(parts)[-6000:] def select_tools(text, extra=None): words = set(re.findall(r"[a-z0-9]+", (text or "").lower())) picked = [] for schema in TOOL_SCHEMAS: name = schema["function"]["name"] if not tool_env_ok(name): continue if name in CORE_TOOLS: picked.append(schema) continue tokens = [item for item in name.split("_") if len(item) >= 4 and item not in LAZY_NAME_STOP] if tokens and set(tokens) & words: picked.append(schema) continue if set(TOOL_TAGS.get(name, ())) & words: picked.append(schema) continue desc = set(word for word in re.findall(r"[a-z0-9]+", schema["function"]["description"].lower()) if len(word) >= 6) if desc & words: picked.append(schema) for schema in extra or []: name = schema["function"]["name"] tokens = [item for item in name.split("_") if len(item) >= 4 and item not in LAZY_NAME_STOP] if tokens and set(tokens) & words: picked.append(schema) continue desc = set(word for word in re.findall(r"[a-z0-9]+", schema["function"]["description"].lower()) if len(word) >= 6) if desc & words: picked.append(schema) return picked def secret_env_name(name): return "TAI_SECRET_" + re.sub(r"[^A-Z0-9_]", "_", name.upper()) def normalize_tag(raw): return re.sub(r"[^a-z0-9]+", "-", str(raw or "").strip().lower()).strip("-")[:32] def normalize_tags(items): seen = [] for item in items: cleaned = normalize_tag(item) if cleaned and cleaned not in seen: seen.append(cleaned) return seen[:20] TAG_AUTO_SKIP = ("record", "file") TAG_AUTO_MAX = 5 TAG_LINK_MAX = 3 SINGULAR_KEEP = ("news", "means", "series", "species", "physics") def singular_noun(word): if word in SINGULAR_KEEP or len(word) <= 3: return word if word.endswith("ies"): return word[:-3] + "y" if word.endswith(("ses", "xes", "zes", "ches", "shes")): return word[:-2] if word.endswith("s") and not word.endswith(("ss", "us", "is", "os")): return word[:-1] return word def tag_variants(tag): found = {tag, singular_noun(tag)} if not tag.endswith("s"): found.add(tag + "s") return sorted(found) def parse_node_id(raw): match = re.fullmatch(r"(mem|secret|sched):(.+)", str(raw or "").strip()) if not match: return None kind, key = match.group(1), match.group(2) if kind == "mem" and re.fullmatch(r"[0-9a-f]{16}", key): return kind, key if kind == "secret" and SECRET_RE.match(key): return kind, key if kind == "sched" and key.isdigit(): return kind, key return None def create_skill_prompt(name, brief, skill_dir): return ( "Create a new agent skill named '%s' in %s.\n" "Brief: %s\n\n" "Follow this process exactly:\n" "1. Call sysinfo first so the skill matches this machine (os, installed tools, root, container, venv).\n" "2. Research the subject deeply with web_search, web_fetch, and shell. Keep researching until you literally " "have enough verified information to write complete instructions. Verify factual claims against at least two " "independent sources. Never invent commands, paths, versions, or API details.\n" "3. Write SKILL.md in the skill directory with this exact frontmatter:\n" "---\nname: %s\ndescription: <one sentence saying what the skill does and when to use it>\n---\n" "followed by clear markdown instructions grounded in your research.\n" "4. Add scripts/, references/, or assets/ files only when they carry real weight; keep every file focused.\n" "5. Read back every file you wrote, fix mistakes, and confirm the frontmatter holds a name plus a description.\n" "6. Reply with a short summary: what the skill covers, which sources verified it, and which files you wrote.\n" "Do not stop early: an incomplete or unverified skill is a failure. Do not ask questions; decide and record assumptions in the skill body." % (name, skill_dir, brief, name) ) PLEXER_DIR_NAME = "plexer" PLEXER_PANE_ENV = "TAI_PLEXER_PANE" PLEXER_SOCKET_ENV = "TAI_PLEXER_SOCKET" PLEXER_ACTIVE_ENV = "TAI_PLEXER_ACTIVE" PLEXER_RECV_MAX = 1 << 20 PLEXER_FLOW_VERSION = 1 PLEXER_FLOW_SCREENS_MAX = 32 PLEXER_FLOW_FIELDS_MAX = 12 PLEXER_FLOW_OPTIONS_MAX = 12 PLEXER_FLOW_ACTIONS_MAX = 12 PLEXER_FLOW_VISITS_MAX = 64 PLEXER_FLOW_ID_MAX = 64 PLEXER_FLOW_TITLE_MAX = 200 PLEXER_FLOW_TEXT_MAX = 2000 PLEXER_FLOW_LABEL_MAX = 200 PLEXER_FLOW_ANSWER_MAX = 2000 PLEXER_FLOW_TIMEOUT_DEFAULT = 300 PLEXER_FLOW_TIMEOUT_MIN = 5 PLEXER_FLOW_TIMEOUT_MAX = 600 PLEXER_FLOW_POLL = 0.25 PLEXER_FLOW_TYPES = ("choice", "text", "info") PLEXER_FLOW_KINDS = ("message", "choice", "text") MPLEX_FLOW_MAX = 16 MPLEX_FLOW_KEEP = 300 def plexer_dir(home): path = os.path.join(home, PLEXER_DIR_NAME) os.makedirs(path, exist_ok=True) try: os.chmod(path, 0o700) except OSError: pass return path class PlexerError(Exception): """Raised by a PlexerController when the backend can't be reached or refuses a command.""" # --------------------------------------------------------------------------- # # Controller side: what Tools.run_plexer_* actually calls. One small # interface, two backends, chosen by which environment variable is set — # TAI_PLEXER_SOCKET (our own NativePlexerBackend already wrapping this shell) # or TMUX_PANE (tmux is already the active manager of this shell). Neither # set means no plexer session is active and the plexer_* tools stay hidden # (see TOOL_ENV_GATES below) rather than erroring. # --------------------------------------------------------------------------- # class PlexerController: kind = "abstract" def send_keys(self, text, enter=False): raise NotImplementedError def read_pane(self, lines=200): raise NotImplementedError def type_text(self, text, delay=0.03, enter=True): for ch in text: self.send_keys(ch) time.sleep(delay) if enter: self.send_keys("", enter=True) def status(self): raise NotImplementedError def ask(self, screen, timeout): """One blocking question on the user's terminal. screen is {"title", "text", "kind" (message/choice/text), "options" ([labels] for choice), "initial" (for text)}. Returns {"status": "ok", "value": ...} (choice: option index, text: string, message: True), {"status": "cancelled"} or {"status": "timeout"}. Transport or backend failures raise PlexerError. PlexerFlow decomposes a whole app spec into ask calls; the backends stay dumb renderers of one screen at a time.""" raise NotImplementedError class NativePlexerController(PlexerController): kind = "native" def __init__(self, socket_path, pane=None): self.socket_path = socket_path self.pane = pane def _call(self, payload): if self.pane: payload = dict(payload, pane=self.pane) try: conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) conn.settimeout(10) conn.connect(self.socket_path) except OSError as exc: raise PlexerError("cannot reach native plexer backend: " + short_error(exc)) try: conn.sendall((json.dumps(payload) + "\n").encode("utf-8")) chunks = [] while True: chunk = conn.recv(65536) if not chunk: break chunks.append(chunk) if chunk.endswith(b"\n"): break except OSError as exc: raise PlexerError("native plexer backend read/write failed: " + short_error(exc)) finally: conn.close() raw = b"".join(chunks).decode("utf-8", "replace").strip() try: reply = json.loads(raw) if raw else {} except ValueError: raise PlexerError("native plexer backend sent an unparseable reply") if not reply.get("ok", False): raise PlexerError(reply.get("error") or "native plexer backend refused the command") return reply.get("data") def send_keys(self, text, enter=False): return self._call({"cmd": "send", "text": text, "enter": bool(enter)}) def read_pane(self, lines=200): return self._call({"cmd": "read", "lines": int(lines)}) def status(self): return self._call({"cmd": "status"}) def ask(self, screen, timeout): handle = self._call({"cmd": "show", "screen": screen, "timeout": timeout}) flow_id = handle["id"] deadline = time.monotonic() + max(1, timeout) while True: reply = self._call({"cmd": "result", "id": flow_id}) if reply.get("done"): status = reply.get("status") if status == "ok": return {"status": "ok", "value": reply.get("value")} if status in ("cancelled", "timeout"): return {"status": status} raise PlexerError("flow %s ended: %s" % (flow_id, status or "unknown")) if time.monotonic() >= deadline: try: self._call({"cmd": "cancel", "id": flow_id}) except PlexerError: pass return {"status": "timeout"} time.sleep(PLEXER_FLOW_POLL) class TmuxPlexerController(PlexerController): kind = "tmux" def __init__(self, pane): self.pane = pane def _tmux(self, *args): try: done = subprocess.run(["tmux"] + list(args), stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=15) except (OSError, subprocess.SubprocessError) as exc: raise PlexerError("tmux call failed: " + short_error(exc)) if done.returncode != 0: raise PlexerError("tmux error: " + (done.stderr or "").strip()[:300]) return done.stdout def send_keys(self, text, enter=False): if text: self._tmux("send-keys", "-t", self.pane, "-l", text) if enter: self._tmux("send-keys", "-t", self.pane, "Enter") return True def read_pane(self, lines=200): return self._tmux("capture-pane", "-p", "-t", self.pane, "-S", "-%d" % max(1, int(lines))) def status(self): # list-panes, not display-message -p: display-message -p silently # falls back to tmux's default/current pane when -t names a target # that doesn't exist (confirmed empirically -- it still exits 0 with # blank format substitutions), while list-panes -t actually validates # the target and errors like send-keys/capture-pane already do. info = self._tmux("list-panes", "-t", self.pane, "-F", "#{session_name}:#{window_index}.#{pane_index} #{pane_current_command}") return {"backend": "tmux", "pane": self.pane, "info": info.strip()} def ask(self, screen, timeout): kind = screen.get("kind") if kind == "choice": return self.ask_choice(screen, timeout) if kind == "text": return self.ask_text(screen, timeout) if kind == "message": return self.ask_message(screen, timeout) raise PlexerError("unknown screen kind %r" % (kind,)) def ask_result_path(self): handle, path = tempfile.mkstemp(prefix="tai-flow-", suffix=".answer") os.close(handle) os.unlink(path) return path def await_answer(self, path, budget=1.0): """run-shell answers land asynchronously; poll briefly, then give up.""" deadline = time.monotonic() + max(0.0, budget) while True: try: with open(path, "r", encoding="utf-8") as handle: return handle.read() except OSError: pass if time.monotonic() >= deadline: return None time.sleep(0.05) def ask_dismiss(self): """Best-effort cleanup after a timeout: popups close server-side, but a display-menu has no server-side close, it stays until the user presses Escape (the flow is already over, so nothing follows it).""" try: subprocess.run(["tmux", "display-popup", "-C"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=5) except (OSError, subprocess.SubprocessError): pass def ask_run(self, argv, path, timeout): """Run one blocking tmux ask command. Returns the answer file content, or None when the user dismissed it, or the string "timeout" on expiry.""" try: done = subprocess.run(["tmux"] + argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=max(1, timeout)) except subprocess.TimeoutExpired: self.ask_dismiss() return "timeout" except (OSError, subprocess.SubprocessError) as exc: raise PlexerError("tmux call failed: " + short_error(exc)) if done.returncode != 0 and (done.stderr or "").strip(): raise PlexerError("tmux %s failed: %s" % (argv[0], done.stderr.strip()[:300])) if done.returncode != 0: return None return self.await_answer(path) def ask_choice(self, screen, timeout): options = screen.get("options") or [] path = self.ask_result_path() try: argv = ["display-menu", "-T", screen.get("title") or "tai"] for index, label in enumerate(options): key = "123456789abcdefghijklmnopqrstuvwxyz"[index] argv += [label, key, "run-shell \"printf '%%s' '%d' > %s\"" % (index, shlex.quote(path))] raw = self.ask_run(argv, path, timeout) finally: try: os.unlink(path) except OSError: pass if raw == "timeout": return {"status": "timeout"} if raw is None or not raw.strip().isdigit(): return {"status": "cancelled"} index = int(raw.strip()) if 0 <= index < len(options): return {"status": "ok", "value": index} return {"status": "cancelled"} def ask_popup(self, title, body, reader, timeout): """body shown, reader (a shell fragment reading one line, then writing the answer file) decides ok versus dismissed. Returns file content/None/"timeout".""" prompt_handle, prompt_path = tempfile.mkstemp(prefix="tai-flow-", suffix=".prompt") try: with os.fdopen(prompt_handle, "w", encoding="utf-8") as handle: handle.write(body) except OSError as exc: raise PlexerError("cannot stage prompt text: " + short_error(exc)) path = self.ask_result_path() try: command = "cat %s; %s" % (shlex.quote(prompt_path), reader % shlex.quote(path)) raw = self.ask_run(["display-popup", "-w", "62", "-h", "10", "-T", title or "tai", "-E", command], path, timeout) finally: for staged in (prompt_path, path): try: os.unlink(staged) except OSError: pass return raw def ask_text(self, screen, timeout): body = "%s\n\n> " % (screen.get("text") or "") raw = self.ask_popup(screen.get("title"), body, "read -r TAI_V && printf '%%s' \"$TAI_V\" > %s", timeout) if raw == "timeout": return {"status": "timeout"} if raw is None: return {"status": "cancelled"} return {"status": "ok", "value": raw[:PLEXER_FLOW_ANSWER_MAX]} def ask_message(self, screen, timeout): body = "%s\n\n[Enter] " % (screen.get("text") or "") raw = self.ask_popup(screen.get("title"), body, "read -r _ && printf ok > %s", timeout) if raw == "timeout": return {"status": "timeout"} if raw is None: return {"status": "cancelled"} return {"status": "ok", "value": True} class PlexerControllerFactory: """The whole detection flow lives in one place: which env var is set decides which backend is already managing this shell. Nothing here ever spawns a backend — spawning only happens via `tai.py --plexer-wrap`, invoked by the bashrc hook, and only when neither var is already set.""" @staticmethod def detect(): socket_path = os.environ.get(PLEXER_SOCKET_ENV) if socket_path: return NativePlexerController(socket_path, os.environ.get(PLEXER_PANE_ENV)) pane = os.environ.get("TMUX_PANE") if pane: return TmuxPlexerController(pane) return None def validate_flow_id(raw, what): """Screen, field and action ids: short, non-empty, no whitespace. Returns (value, error).""" text = str(raw or "").strip() if not text: return "", "%s id must be a non-empty string" % what if len(text) > PLEXER_FLOW_ID_MAX: return "", "%s id %r is longer than %d chars" % (what, text[:72], PLEXER_FLOW_ID_MAX) if any(char.isspace() for char in text): return "", "%s id %r must not contain whitespace" % (what, text) return text, "" def validate_flow_text(raw, what, limit): """Display strings: must be a string, capped. Returns (value, error).""" if not isinstance(raw, str): return "", "%s must be a string" % what if len(raw) > limit: return "", "%s is longer than %d chars" % (what, limit) return raw, "" def validate_flow_options(raw, what): """Choice options: 1..12 strings or {"label", "value"} dicts with scalar values. Returns (normalized [{"label", "value"}], error).""" if not isinstance(raw, list) or not raw: return [], "%s needs 1 to %d options" % (what, PLEXER_FLOW_OPTIONS_MAX) if len(raw) > PLEXER_FLOW_OPTIONS_MAX: return [], "%s has %d options, at most %d" % (what, len(raw), PLEXER_FLOW_OPTIONS_MAX) options = [] for pos, item in enumerate(raw): if isinstance(item, str): label, value = item, item elif isinstance(item, dict): label, value = item.get("label"), item.get("value", item.get("label")) else: return [], "%s option %d must be a string or {label, value}" % (what, pos + 1) if not isinstance(label, str) or not label.strip(): return [], "%s option %d needs a non-empty label" % (what, pos + 1) if len(label) > PLEXER_FLOW_LABEL_MAX: return [], "%s option %d label is longer than %d chars" % (what, pos + 1, PLEXER_FLOW_LABEL_MAX) if not isinstance(value, (str, int, float, bool)) and value is not None: return [], "%s option %d value must be a JSON scalar" % (what, pos + 1) options.append({"label": label, "value": value}) return options, "" def validate_flow_condition(raw, what, known): """show_if: {field id: expected scalar or list of scalars}. known holds every choice/text field id in the app. Returns (normalized, error).""" if raw is None: return {}, "" if not isinstance(raw, dict) or not raw: return {}, "%s show_if must be a non-empty {field id: expected} object" % what clauses = {} for key, expected in raw.items(): if key not in known: return {}, "%s show_if refers to unknown field %r" % (what, key) accepted = expected if isinstance(expected, list) else [expected] if not accepted: return {}, "%s show_if for %r needs at least one expected value" % (what, key) for value in accepted: if not isinstance(value, (str, int, float, bool)) and value is not None: return {}, "%s show_if for %r must hold JSON scalars" % (what, key) clauses[key] = accepted return clauses, "" def validate_flow_spec(spec): """The plexer_show JSON contract (uiflow.md), version PLEXER_FLOW_VERSION. Returns (normalized app, "") or (None, reason); every rejection is loud.""" if not isinstance(spec, dict): return None, "spec must be a JSON object" version = spec.get("v", PLEXER_FLOW_VERSION) if version != PLEXER_FLOW_VERSION: return None, "spec v must be %d, got %r" % (PLEXER_FLOW_VERSION, version) title, error = validate_flow_text(spec.get("title", ""), "title", PLEXER_FLOW_TITLE_MAX) if error: return None, error try: timeout = int(spec.get("timeout", PLEXER_FLOW_TIMEOUT_DEFAULT)) except (TypeError, ValueError): return None, "timeout must be an integer number of seconds" if not PLEXER_FLOW_TIMEOUT_MIN <= timeout <= PLEXER_FLOW_TIMEOUT_MAX: return None, "timeout must be %d to %d seconds" % (PLEXER_FLOW_TIMEOUT_MIN, PLEXER_FLOW_TIMEOUT_MAX) raw_screens = spec.get("screens") if not isinstance(raw_screens, list) or not raw_screens: return None, "screens must be a non-empty list" if len(raw_screens) > PLEXER_FLOW_SCREENS_MAX: return None, "%d screens, at most %d" % (len(raw_screens), PLEXER_FLOW_SCREENS_MAX) screens = [] screen_ids = set() field_ids = set() for pos, item in enumerate(raw_screens): what = "screen %d" % (pos + 1) if not isinstance(item, dict): return None, "%s must be an object" % what ident, error = validate_flow_id(item.get("id"), what) if error: return None, error if ident in screen_ids: return None, "duplicate screen id %r" % ident screen_ids.add(ident) title_s, error = validate_flow_text(item.get("title", ""), "%s title" % ident, PLEXER_FLOW_TITLE_MAX) if error: return None, error body, error = validate_flow_text(item.get("body", ""), "%s body" % ident, PLEXER_FLOW_TEXT_MAX) if error: return None, error raw_fields = item.get("fields", []) if not isinstance(raw_fields, list): return None, "%s fields must be a list" % ident if len(raw_fields) > PLEXER_FLOW_FIELDS_MAX: return None, "%s has %d fields, at most %d" % (ident, len(raw_fields), PLEXER_FLOW_FIELDS_MAX) fields = [] for fpos, field in enumerate(raw_fields): fwhat = "%s field %d" % (ident, fpos + 1) if not isinstance(field, dict): return None, "%s must be an object" % fwhat kind = field.get("type") if kind not in PLEXER_FLOW_TYPES: return None, "%s type must be one of %s, got %r" % (fwhat, "/".join(PLEXER_FLOW_TYPES), kind) fid, error = validate_flow_id(field.get("id"), fwhat) if error: return None, error if fid in field_ids: return None, "duplicate field id %r" % fid field_ids.add(fid) label, error = validate_flow_text(field.get("label", ""), "%s label" % fid, PLEXER_FLOW_LABEL_MAX) if error: return None, error entry = {"id": fid, "type": kind, "label": label, "show_if": field.get("show_if")} if kind == "choice": options, error = validate_flow_options(field.get("options"), fid) if error: return None, error entry["options"] = options try: default = int(field.get("default", 0)) except (TypeError, ValueError): return None, "%s default must be an option index" % fid if not 0 <= default < len(options): return None, "%s default %d is outside its %d options" % (fid, default, len(options)) entry["default"] = default elif kind == "text": initial, error = validate_flow_text(field.get("initial", ""), "%s initial" % fid, PLEXER_FLOW_ANSWER_MAX) if error: return None, error entry["initial"] = initial else: text, error = validate_flow_text(field.get("text", ""), "%s text" % fid, PLEXER_FLOW_TEXT_MAX) if error: return None, error entry["text"] = text fields.append(entry) raw_actions = item.get("actions", []) if not isinstance(raw_actions, list): return None, "%s actions must be a list" % ident if len(raw_actions) > PLEXER_FLOW_ACTIONS_MAX: return None, "%s has %d actions, at most %d" % (ident, len(raw_actions), PLEXER_FLOW_ACTIONS_MAX) actions = [] action_ids = set() for apos, action in enumerate(raw_actions): awhat = "%s action %d" % (ident, apos + 1) if not isinstance(action, dict): return None, "%s must be an object" % awhat aid, error = validate_flow_id(action.get("id"), awhat) if error: return None, error if aid in action_ids: return None, "%s has a duplicate action id %r" % (ident, aid) action_ids.add(aid) label, error = validate_flow_text(action.get("label", ""), "%s label" % aid, PLEXER_FLOW_LABEL_MAX) if error: return None, error if not label.strip(): return None, "%s needs a non-empty label" % aid actions.append({"id": aid, "label": label}) if not body.strip() and not fields and not actions: return None, "%s has nothing to show (no body, fields or actions)" % ident screens.append({"id": ident, "title": title_s, "body": body, "fields": fields, "actions": actions, "next": item.get("next"), "show_if": item.get("show_if")}) known_fields = set() for screen in screens: for field in screen["fields"]: if field["type"] in ("choice", "text"): known_fields.add(field["id"]) for screen in screens: for field in screen["fields"]: clauses, error = validate_flow_condition(field.get("show_if"), field["id"], known_fields) if error: return None, error field["show_if"] = clauses clauses, error = validate_flow_condition(screen["show_if"], screen["id"], known_fields) if error: return None, error screen["show_if"] = clauses target = screen["next"] if target is None: screen["next"] = None elif isinstance(target, str): if target not in ("$done", "$cancel") and target not in screen_ids: return None, "%s next refers to unknown screen %r" % (screen["id"], target) screen["next"] = target elif isinstance(target, dict): if not screen["actions"]: return None, "%s next is a map but the screen has no actions" % screen["id"] for key, dest in target.items(): if key not in [action["id"] for action in screen["actions"]]: return None, "%s next maps unknown action %r" % (screen["id"], key) if dest not in ("$done", "$cancel") and dest not in screen_ids: return None, "%s next for %r refers to unknown screen %r" % (screen["id"], key, dest) screen["next"] = dict(target) else: return None, "%s next must be a screen id or an {action: screen} map" % screen["id"] start = spec.get("start", screens[0]["id"]) if start not in screen_ids: return None, "start refers to unknown screen %r" % (start,) return {"title": title, "timeout": timeout, "start": start, "screens": screens}, "" class PlexerFlow: """The separate agent that runs one UI flow: the main agent hands over a whole app spec in a single plexer_show call, this driver decomposes it into ask calls, evaluates conditions and branches locally, and reports only the final outcome. The main agent's turn stays lean no matter how complex the flow gets.""" MISSING = object() def __init__(self, app): self.app = app self.by_id = {screen["id"]: screen for screen in app["screens"]} self.order = [screen["id"] for screen in app["screens"]] self.answers = {} self.action = None self.visits = 0 self.deadline = 0.0 @classmethod def condition_holds(cls, clauses, answers): for key, accepted in clauses.items(): if answers.get(key, cls.MISSING) not in accepted: return False return True def follow_up(self, screen): """Where a screen without a taken action goes: its string next, else the next screen in order, else done.""" target = screen["next"] if isinstance(target, str): return target pos = self.order.index(screen["id"]) if pos + 1 < len(self.order): return self.order[pos + 1] return "$done" def screen_text(self, screen, field=None): """First ask of a screen carries the context: title, body, info fields.""" if field is not None and field is not self.first_askable(screen): return field["label"] or screen["title"] or self.app["title"] parts = [screen["title"] or self.app["title"], screen["body"]] parts += [item["text"] for item in screen["fields"] if item["type"] == "info" and self.condition_holds(item["show_if"], self.answers)] if field is not None and field["label"]: parts.append(field["label"]) return "\n\n".join(part for part in parts if part and part.strip()) def first_askable(self, screen): for field in screen["fields"]: if field["type"] in ("choice", "text") and self.condition_holds(field["show_if"], self.answers): return field return None def remaining(self): return self.deadline - time.monotonic() def run(self, controller): self.deadline = time.monotonic() + self.app["timeout"] current = self.app["start"] while True: self.visits += 1 if self.visits > PLEXER_FLOW_VISITS_MAX: return {"status": "error", "error": "flow passed %d screens (loop?), aborting" % PLEXER_FLOW_VISITS_MAX, "at": current, "answers": dict(self.answers)} screen = self.by_id[current] if self.condition_holds(screen["show_if"], self.answers): outcome = self.run_screen(controller, screen) if isinstance(outcome, dict): return outcome target = outcome if isinstance(outcome, str) else self.follow_up(screen) else: target = self.follow_up(screen) if target == "$done": return {"status": "done", "answers": dict(self.answers), "action": self.action} if target == "$cancel": return {"status": "cancelled", "at": current, "answers": dict(self.answers)} current = target def run_screen(self, controller, screen): """Ask every visible field, then the action. Returns None to continue the flow with follow_up, a screen id to jump there, or a final outcome dict when the user cancelled, time ran out, or an action's next map ends it.""" asked = False for field in screen["fields"]: if field["type"] == "info" or not self.condition_holds(field["show_if"], self.answers): continue if self.remaining() <= 0: return {"status": "timeout", "at": screen["id"], "answers": dict(self.answers)} if field["type"] == "choice": reply = controller.ask({"title": screen["title"] or self.app["title"], "text": self.screen_text(screen, field), "kind": "choice", "options": [opt["label"] for opt in field["options"]], "default": field["default"]}, self.remaining()) if reply["status"] != "ok": return {"status": reply["status"], "at": screen["id"], "field": field["id"], "answers": dict(self.answers)} self.answers[field["id"]] = field["options"][reply["value"]]["value"] else: reply = controller.ask({"title": screen["title"] or self.app["title"], "text": self.screen_text(screen, field), "kind": "text", "initial": field["initial"]}, self.remaining()) if reply["status"] != "ok": return {"status": reply["status"], "at": screen["id"], "field": field["id"], "answers": dict(self.answers)} self.answers[field["id"]] = reply["value"] asked = True if not screen["actions"]: if not asked: if self.remaining() <= 0: return {"status": "timeout", "at": screen["id"], "answers": dict(self.answers)} reply = controller.ask({"title": screen["title"] or self.app["title"], "text": self.screen_text(screen), "kind": "message"}, self.remaining()) if reply["status"] != "ok": return {"status": reply["status"], "at": screen["id"], "answers": dict(self.answers)} return None if self.remaining() <= 0: return {"status": "timeout", "at": screen["id"], "answers": dict(self.answers)} if self.first_askable(screen) is None and not screen["body"].strip(): text = screen["title"] or self.app["title"] elif self.first_askable(screen) is None: text = self.screen_text(screen) else: text = screen["title"] or self.app["title"] reply = controller.ask({"title": screen["title"] or self.app["title"], "text": text, "kind": "choice", "options": [action["label"] for action in screen["actions"]], "default": 0}, self.remaining()) if reply["status"] != "ok": return {"status": reply["status"], "at": screen["id"], "answers": dict(self.answers)} self.action = screen["actions"][reply["value"]]["id"] target = screen["next"] if isinstance(target, dict): dest = target.get(self.action) if dest is None: return None if dest == "$done": return {"status": "done", "answers": dict(self.answers), "action": self.action} if dest == "$cancel": return {"status": "cancelled", "at": screen["id"], "answers": dict(self.answers)} return dest return None # --------------------------------------------------------------------------- # # Backend side: PlexerBackend is the extension point the shell hook execs into. # NativePlexerBackend (bottom of the Mplex block) starts or joins the mplex # server and attaches this terminal to a fresh session. tmux itself needs no # backend: when it is active there is nothing for tai to wrap (see # PlexerControllerFactory). # --------------------------------------------------------------------------- # class PlexerBackend: def run(self): raise NotImplementedError # --------------------------------------------------------------------------- # # Mplex: tai's own terminal multiplexer. One server process per TAI_HOME owns # every pty, window layout and scrollback; clients attach over a Unix socket # and receive finished frames. Key bindings, command names and option names # follow tmux 3.6 (the default tables below were dumped from `tmux list-keys`), # and ~/.tai/mplex.toml rebinds or retunes any of them. Spec: mplex.md. # --------------------------------------------------------------------------- # MPLEX_SOCKET_NAME = "server.sock" MPLEX_LOCK_NAME = "server.lock" MPLEX_LOG_NAME = "server.log" MPLEX_CONFIG_NAME = "mplex.toml" MPLEX_MAGIC = b"\x01" MPLEX_FRAME_MAX = 1 << 22 MPLEX_CLIENT_OUT_MAX = 1 << 22 MPLEX_PANE_IN_MAX = 1 << 22 MPLEX_MAX_SESSIONS = 64 MPLEX_MAX_WINDOWS = 256 MPLEX_MAX_PANES = 256 MPLEX_MAX_CLIENTS = 32 MPLEX_MESSAGES_MAX = 200 MPLEX_HISTORY_CAP = 200000 MPLEX_BUFFERS_CAP = 100 MPLEX_PROMPT_HISTORY_CAP = 100 MPLEX_COMMAND_DEPTH_MAX = 16 MPLEX_SHELL_TIMEOUT = 5.0 MPLEX_SHELL_OUTPUT_MAX = 65536 MPLEX_PASTE_MAX = 1 << 22 MPLEX_RENDER_INTERVAL = 0.012 MPLEX_REAP_KILL_AFTER = 2.0 MPLEX_IDLE_EXIT = 15.0 MPLEX_LOOP_ERRORS_MAX = 50 MPLEX_LOG_MAX = 1 << 24 MPLEX_CONFIG_MAX = 1 << 20 MPLEX_UNSUPPORTED = ("display-menu", "display-popup", "customize-mode", "lock-server", "lock-session", "lock-client", "pipe-pane", "join-pane", "move-pane", "link-window", "unlink-window", "respawn-pane", "respawn-window", "resize-window", "set-hook", "show-hooks", "wait-for", "server-access", "choose-tree-mouse") # (table, key, command, repeat, note): tmux 3.6 defaults minus mouse bindings. MPLEX_DEFAULT_KEYS = ( ('copy-mode', 'Escape', 'send-keys -X cancel', False, ''), ('copy-mode', 'Space', 'send-keys -X page-down', False, ''), ('copy-mode', ',', 'send-keys -X jump-reverse', False, ''), ('copy-mode', ';', 'send-keys -X jump-again', False, ''), ('copy-mode', 'F', 'command-prompt -1 -p "(jump backward)" { send-keys -X jump-backward -- "%%" }', False, ''), ('copy-mode', 'N', 'send-keys -X search-reverse', False, ''), ('copy-mode', 'P', 'send-keys -X toggle-position', False, ''), ('copy-mode', 'R', 'send-keys -X rectangle-toggle', False, ''), ('copy-mode', 'T', 'command-prompt -1 -p "(jump to backward)" { send-keys -X jump-to-backward -- "%%" }', False, ''), ('copy-mode', 'X', 'send-keys -X set-mark', False, ''), ('copy-mode', 'f', 'command-prompt -1 -p "(jump forward)" { send-keys -X jump-forward -- "%%" }', False, ''), ('copy-mode', 'g', 'command-prompt -p "(goto line)" { send-keys -X goto-line -- "%%" }', False, ''), ('copy-mode', 'n', 'send-keys -X search-again', False, ''), ('copy-mode', 'q', 'send-keys -X cancel', False, ''), ('copy-mode', 'r', 'send-keys -X refresh-from-pane', False, ''), ('copy-mode', 't', 'command-prompt -1 -p "(jump to forward)" { send-keys -X jump-to-forward -- "%%" }', False, ''), ('copy-mode', 'Home', 'send-keys -X start-of-line', False, ''), ('copy-mode', 'End', 'send-keys -X end-of-line', False, ''), ('copy-mode', 'NPage', 'send-keys -X page-down', False, ''), ('copy-mode', 'PPage', 'send-keys -X page-up', False, ''), ('copy-mode', 'Up', 'send-keys -X cursor-up', False, ''), ('copy-mode', 'Down', 'send-keys -X cursor-down', False, ''), ('copy-mode', 'Left', 'send-keys -X cursor-left', False, ''), ('copy-mode', 'Right', 'send-keys -X cursor-right', False, ''), ('copy-mode', 'M-1', 'command-prompt -N -I 1 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode', 'M-2', 'command-prompt -N -I 2 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode', 'M-3', 'command-prompt -N -I 3 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode', 'M-4', 'command-prompt -N -I 4 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode', 'M-5', 'command-prompt -N -I 5 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode', 'M-6', 'command-prompt -N -I 6 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode', 'M-7', 'command-prompt -N -I 7 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode', 'M-8', 'command-prompt -N -I 8 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode', 'M-9', 'command-prompt -N -I 9 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode', 'M-<', 'send-keys -X history-top', False, ''), ('copy-mode', 'M->', 'send-keys -X history-bottom', False, ''), ('copy-mode', 'M-R', 'send-keys -X top-line', False, ''), ('copy-mode', 'M-b', 'send-keys -X previous-word', False, ''), ('copy-mode', 'M-f', 'send-keys -X next-word-end', False, ''), ('copy-mode', 'M-l', 'send-keys -X cursor-centre-horizontal', False, ''), ('copy-mode', 'M-m', 'send-keys -X back-to-indentation', False, ''), ('copy-mode', 'M-r', 'send-keys -X middle-line', False, ''), ('copy-mode', 'M-v', 'send-keys -X page-up', False, ''), ('copy-mode', 'M-w', 'send-keys -X copy-pipe-and-cancel', False, ''), ('copy-mode', 'M-x', 'send-keys -X jump-to-mark', False, ''), ('copy-mode', 'M-{', 'send-keys -X previous-paragraph', False, ''), ('copy-mode', 'M-}', 'send-keys -X next-paragraph', False, ''), ('copy-mode', 'M-Up', 'send-keys -X halfpage-up', False, ''), ('copy-mode', 'M-Down', 'send-keys -X halfpage-down', False, ''), ('copy-mode', 'C-Space', 'send-keys -X begin-selection', False, ''), ('copy-mode', 'C-a', 'send-keys -X start-of-line', False, ''), ('copy-mode', 'C-b', 'send-keys -X cursor-left', False, ''), ('copy-mode', 'C-c', 'send-keys -X cancel', False, ''), ('copy-mode', 'C-e', 'send-keys -X end-of-line', False, ''), ('copy-mode', 'C-f', 'send-keys -X cursor-right', False, ''), ('copy-mode', 'C-g', 'send-keys -X clear-selection', False, ''), ('copy-mode', 'C-k', 'send-keys -X copy-pipe-end-of-line-and-cancel', False, ''), ('copy-mode', 'C-l', 'send-keys -X cursor-centre-vertical', False, ''), ('copy-mode', 'C-n', 'send-keys -X cursor-down', False, ''), ('copy-mode', 'C-p', 'send-keys -X cursor-up', False, ''), ('copy-mode', 'C-r', 'command-prompt -i -I "#{pane_search_string}" -T search -p "(search up)" { send-keys -X search-backward-incremental -- "%%" }', False, ''), ('copy-mode', 'C-s', 'command-prompt -i -I "#{pane_search_string}" -T search -p "(search down)" { send-keys -X search-forward-incremental -- "%%" }', False, ''), ('copy-mode', 'C-v', 'send-keys -X page-down', False, ''), ('copy-mode', 'C-w', 'send-keys -X copy-pipe-and-cancel', False, ''), ('copy-mode', 'C-Up', 'send-keys -X scroll-up', False, ''), ('copy-mode', 'C-Down', 'send-keys -X scroll-down', False, ''), ('copy-mode', 'C-M-b', 'send-keys -X previous-matching-bracket', False, ''), ('copy-mode', 'C-M-f', 'send-keys -X next-matching-bracket', False, ''), ('copy-mode-vi', 'Enter', 'send-keys -X copy-pipe-and-cancel', False, ''), ('copy-mode-vi', 'Escape', 'send-keys -X clear-selection', False, ''), ('copy-mode-vi', 'Space', 'send-keys -X begin-selection', False, ''), ('copy-mode-vi', '#', 'send-keys -FX search-backward -- "#{copy_cursor_word}"', False, ''), ('copy-mode-vi', '$', 'send-keys -X end-of-line', False, ''), ('copy-mode-vi', '%', 'send-keys -X next-matching-bracket', False, ''), ('copy-mode-vi', '*', 'send-keys -FX search-forward -- "#{copy_cursor_word}"', False, ''), ('copy-mode-vi', ',', 'send-keys -X jump-reverse', False, ''), ('copy-mode-vi', '/', 'command-prompt -T search -p "(search down)" { send-keys -X search-forward -- "%%" }', False, ''), ('copy-mode-vi', '0', 'send-keys -X start-of-line', False, ''), ('copy-mode-vi', '1', 'command-prompt -N -I 1 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode-vi', '2', 'command-prompt -N -I 2 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode-vi', '3', 'command-prompt -N -I 3 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode-vi', '4', 'command-prompt -N -I 4 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode-vi', '5', 'command-prompt -N -I 5 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode-vi', '6', 'command-prompt -N -I 6 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode-vi', '7', 'command-prompt -N -I 7 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode-vi', '8', 'command-prompt -N -I 8 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode-vi', '9', 'command-prompt -N -I 9 -p (repeat) { send-keys -N "%%" }', False, ''), ('copy-mode-vi', ':', 'command-prompt -p "(goto line)" { send-keys -X goto-line -- "%%" }', False, ''), ('copy-mode-vi', ';', 'send-keys -X jump-again', False, ''), ('copy-mode-vi', '?', 'command-prompt -T search -p "(search up)" { send-keys -X search-backward -- "%%" }', False, ''), ('copy-mode-vi', 'A', 'send-keys -X append-selection-and-cancel', False, ''), ('copy-mode-vi', 'B', 'send-keys -X previous-space', False, ''), ('copy-mode-vi', 'D', 'send-keys -X copy-pipe-end-of-line-and-cancel', False, ''), ('copy-mode-vi', 'E', 'send-keys -X next-space-end', False, ''), ('copy-mode-vi', 'F', 'command-prompt -1 -p "(jump backward)" { send-keys -X jump-backward -- "%%" }', False, ''), ('copy-mode-vi', 'G', 'send-keys -X history-bottom', False, ''), ('copy-mode-vi', 'H', 'send-keys -X top-line', False, ''), ('copy-mode-vi', 'J', 'send-keys -X scroll-down', False, ''), ('copy-mode-vi', 'K', 'send-keys -X scroll-up', False, ''), ('copy-mode-vi', 'L', 'send-keys -X bottom-line', False, ''), ('copy-mode-vi', 'M', 'send-keys -X middle-line', False, ''), ('copy-mode-vi', 'N', 'send-keys -X search-reverse', False, ''), ('copy-mode-vi', 'P', 'send-keys -X toggle-position', False, ''), ('copy-mode-vi', 'T', 'command-prompt -1 -p "(jump to backward)" { send-keys -X jump-to-backward -- "%%" }', False, ''), ('copy-mode-vi', 'V', 'send-keys -X select-line', False, ''), ('copy-mode-vi', 'W', 'send-keys -X next-space', False, ''), ('copy-mode-vi', 'X', 'send-keys -X set-mark', False, ''), ('copy-mode-vi', '^', 'send-keys -X back-to-indentation', False, ''), ('copy-mode-vi', 'b', 'send-keys -X previous-word', False, ''), ('copy-mode-vi', 'e', 'send-keys -X next-word-end', False, ''), ('copy-mode-vi', 'f', 'command-prompt -1 -p "(jump forward)" { send-keys -X jump-forward -- "%%" }', False, ''), ('copy-mode-vi', 'g', 'send-keys -X history-top', False, ''), ('copy-mode-vi', 'h', 'send-keys -X cursor-left', False, ''), ('copy-mode-vi', 'j', 'send-keys -X cursor-down', False, ''), ('copy-mode-vi', 'k', 'send-keys -X cursor-up', False, ''), ('copy-mode-vi', 'l', 'send-keys -X cursor-right', False, ''), ('copy-mode-vi', 'n', 'send-keys -X search-again', False, ''), ('copy-mode-vi', 'o', 'send-keys -X other-end', False, ''), ('copy-mode-vi', 'q', 'send-keys -X cancel', False, ''), ('copy-mode-vi', 'r', 'send-keys -X refresh-from-pane', False, ''), ('copy-mode-vi', 't', 'command-prompt -1 -p "(jump to forward)" { send-keys -X jump-to-forward -- "%%" }', False, ''), ('copy-mode-vi', 'v', 'send-keys -X rectangle-toggle', False, ''), ('copy-mode-vi', 'w', 'send-keys -X next-word', False, ''), ('copy-mode-vi', 'z', 'send-keys -X scroll-middle', False, ''), ('copy-mode-vi', '{', 'send-keys -X previous-paragraph', False, ''), ('copy-mode-vi', '}', 'send-keys -X next-paragraph', False, ''), ('copy-mode-vi', 'BSpace', 'send-keys -X cursor-left', False, ''), ('copy-mode-vi', 'Home', 'send-keys -X start-of-line', False, ''), ('copy-mode-vi', 'End', 'send-keys -X end-of-line', False, ''), ('copy-mode-vi', 'NPage', 'send-keys -X page-down', False, ''), ('copy-mode-vi', 'PPage', 'send-keys -X page-up', False, ''), ('copy-mode-vi', 'Up', 'send-keys -X cursor-up', False, ''), ('copy-mode-vi', 'Down', 'send-keys -X cursor-down', False, ''), ('copy-mode-vi', 'Left', 'send-keys -X cursor-left', False, ''), ('copy-mode-vi', 'Right', 'send-keys -X cursor-right', False, ''), ('copy-mode-vi', 'M-x', 'send-keys -X jump-to-mark', False, ''), ('copy-mode-vi', 'C-b', 'send-keys -X page-up', False, ''), ('copy-mode-vi', 'C-c', 'send-keys -X cancel', False, ''), ('copy-mode-vi', 'C-d', 'send-keys -X halfpage-down', False, ''), ('copy-mode-vi', 'C-e', 'send-keys -X scroll-down', False, ''), ('copy-mode-vi', 'C-f', 'send-keys -X page-down', False, ''), ('copy-mode-vi', 'C-h', 'send-keys -X cursor-left', False, ''), ('copy-mode-vi', 'C-j', 'send-keys -X copy-pipe-and-cancel', False, ''), ('copy-mode-vi', 'C-u', 'send-keys -X halfpage-up', False, ''), ('copy-mode-vi', 'C-v', 'send-keys -X rectangle-toggle', False, ''), ('copy-mode-vi', 'C-y', 'send-keys -X scroll-up', False, ''), ('copy-mode-vi', 'C-Up', 'send-keys -X scroll-up', False, ''), ('copy-mode-vi', 'C-Down', 'send-keys -X scroll-down', False, ''), ('prefix', 'Space', 'next-layout', False, 'Select next layout'), ('prefix', '!', 'break-pane', False, 'Break pane to a new window'), ('prefix', '"', 'split-window', False, 'Split window vertically'), ('prefix', '#', 'list-buffers', False, 'List all paste buffers'), ('prefix', '$', 'command-prompt -I "#S" { rename-session "%%" }', False, 'Rename current session'), ('prefix', '%', 'split-window -h', False, 'Split window horizontally'), ('prefix', '&', 'confirm-before -p "kill-window #W? (y/n)" kill-window', False, 'Kill current window'), ('prefix', "'", 'command-prompt -T window-target -p index { select-window -t ":%%" }', False, 'Prompt for window index to select'), ('prefix', '(', 'switch-client -p', False, 'Switch to previous client'), ('prefix', ')', 'switch-client -n', False, 'Switch to next client'), ('prefix', ',', 'command-prompt -I "#W" { rename-window "%%" }', False, 'Rename current window'), ('prefix', '-', 'delete-buffer', False, 'Delete the most recent paste buffer'), ('prefix', '.', 'command-prompt -T target { move-window -t "%%" }', False, 'Move the current window'), ('prefix', '/', 'command-prompt -k -p key { list-keys -1N "%%" }', False, 'Describe key binding'), ('prefix', '0', 'select-window -t :=0', False, 'Select window 0'), ('prefix', '1', 'select-window -t :=1', False, 'Select window 1'), ('prefix', '2', 'select-window -t :=2', False, 'Select window 2'), ('prefix', '3', 'select-window -t :=3', False, 'Select window 3'), ('prefix', '4', 'select-window -t :=4', False, 'Select window 4'), ('prefix', '5', 'select-window -t :=5', False, 'Select window 5'), ('prefix', '6', 'select-window -t :=6', False, 'Select window 6'), ('prefix', '7', 'select-window -t :=7', False, 'Select window 7'), ('prefix', '8', 'select-window -t :=8', False, 'Select window 8'), ('prefix', '9', 'select-window -t :=9', False, 'Select window 9'), ('prefix', ':', 'command-prompt', False, 'Prompt for a command'), ('prefix', ';', 'last-pane', False, 'Move to the previously active pane'), ('prefix', '<', 'display-menu', False, 'Display window menu'), ('prefix', '=', 'choose-buffer -Z', False, 'Choose a paste buffer from a list'), ('prefix', '>', 'display-menu', False, 'Display pane menu'), ('prefix', '?', 'list-keys -N', False, 'List key bindings'), ('prefix', 'C', 'customize-mode -Z', False, 'Customize options'), ('prefix', 'D', 'choose-client -Z', False, 'Choose and detach a client from a list'), ('prefix', 'E', 'select-layout -E', False, 'Spread panes out evenly'), ('prefix', 'L', 'switch-client -l', False, 'Switch to the last client'), ('prefix', 'M', 'select-pane -M', False, 'Clear the marked pane'), ('prefix', '[', 'copy-mode', False, 'Enter copy mode'), ('prefix', ']', 'paste-buffer -p', False, 'Paste the most recent paste buffer'), ('prefix', 'c', 'new-window', False, 'Create a new window'), ('prefix', 'd', 'detach-client', False, 'Detach the current client'), ('prefix', 'f', 'command-prompt { find-window -Z "%%" }', False, 'Search for a pane'), ('prefix', 'i', 'display-message', False, 'Display window information'), ('prefix', 'l', 'last-window', False, 'Select the previously current window'), ('prefix', 'm', 'select-pane -m', False, 'Toggle the marked pane'), ('prefix', 'n', 'next-window', False, 'Select the next window'), ('prefix', 'o', 'select-pane -t :.+', False, 'Select the next pane'), ('prefix', 'p', 'previous-window', False, 'Select the previous window'), ('prefix', 'q', 'display-panes', False, 'Display pane numbers'), ('prefix', 'r', 'refresh-client', False, 'Redraw the current client'), ('prefix', 's', 'choose-tree -Zs', False, 'Choose a session from a list'), ('prefix', 't', 'clock-mode', False, 'Show a clock'), ('prefix', 'w', 'choose-tree -Zw', False, 'Choose a window from a list'), ('prefix', 'x', 'confirm-before -p "kill-pane #P? (y/n)" kill-pane', False, 'Kill the active pane'), ('prefix', 'z', 'resize-pane -Z', False, 'Zoom the active pane'), ('prefix', '{', 'swap-pane -U', False, 'Swap the active pane with the pane above'), ('prefix', '}', 'swap-pane -D', False, 'Swap the active pane with the pane below'), ('prefix', '~', 'show-messages', False, 'Show messages'), ('prefix', 'DC', 'refresh-client -c', True, 'Reset so the visible part of the window follows the cursor'), ('prefix', 'PPage', 'copy-mode -u', False, 'Enter copy mode and scroll up'), ('prefix', 'Up', 'select-pane -U', True, 'Select the pane above the active pane'), ('prefix', 'Down', 'select-pane -D', True, 'Select the pane below the active pane'), ('prefix', 'Left', 'select-pane -L', True, 'Select the pane to the left of the active pane'), ('prefix', 'Right', 'select-pane -R', True, 'Select the pane to the right of the active pane'), ('prefix', 'M-1', 'select-layout even-horizontal', False, 'Set the even-horizontal layout'), ('prefix', 'M-2', 'select-layout even-vertical', False, 'Set the even-vertical layout'), ('prefix', 'M-3', 'select-layout main-horizontal', False, 'Set the main-horizontal layout'), ('prefix', 'M-4', 'select-layout main-vertical', False, 'Set the main-vertical layout'), ('prefix', 'M-5', 'select-layout tiled', False, 'Select the tiled layout'), ('prefix', 'M-6', 'select-layout main-horizontal-mirrored', False, 'Set the main-horizontal-mirrored layout'), ('prefix', 'M-7', 'select-layout main-vertical-mirrored', False, 'Set the main-vertical-mirrored layout'), ('prefix', 'M-n', 'next-window -a', False, 'Select the next window with an alert'), ('prefix', 'M-o', 'rotate-window -D', False, 'Rotate through the panes in reverse'), ('prefix', 'M-p', 'previous-window -a', False, 'Select the previous window with an alert'), ('prefix', 'M-Up', 'resize-pane -U 5', True, 'Resize the pane up by 5'), ('prefix', 'M-Down', 'resize-pane -D 5', True, 'Resize the pane down by 5'), ('prefix', 'M-Left', 'resize-pane -L 5', True, 'Resize the pane left by 5'), ('prefix', 'M-Right', 'resize-pane -R 5', True, 'Resize the pane right by 5'), ('prefix', 'C-b', 'send-prefix', False, 'Send the prefix key'), ('prefix', 'C-o', 'rotate-window', False, 'Rotate through the panes'), ('prefix', 'C-z', 'suspend-client', False, 'Suspend the current client'), ('prefix', 'C-Up', 'resize-pane -U', True, 'Resize the pane up'), ('prefix', 'C-Down', 'resize-pane -D', True, 'Resize the pane down'), ('prefix', 'C-Left', 'resize-pane -L', True, 'Resize the pane left'), ('prefix', 'C-Right', 'resize-pane -R', True, 'Resize the pane right'), ('prefix', 'S-Up', 'refresh-client -U 10', True, 'Move the visible part of the window up'), ('prefix', 'S-Down', 'refresh-client -D 10', True, 'Move the visible part of the window down'), ('prefix', 'S-Left', 'refresh-client -L 10', True, 'Move the visible part of the window left'), ('prefix', 'S-Right', 'refresh-client -R 10', True, 'Move the visible part of the window right'), ) MPLEX_LAYOUT_NAMES = ("even-horizontal", "even-vertical", "main-horizontal", "main-horizontal-mirrored", "main-vertical", "main-vertical-mirrored", "tiled") class MplexError(Exception): """A user-facing failure: the text is shown verbatim as the command's error.""" class MplexKeys: """Key names in tmux notation (C-b, M-x, S-Up, PPage, F5 ...), byte decoding of what a terminal sends, and byte encoding for send-keys.""" ALIASES = {"PageUp": "PPage", "PgUp": "PPage", "PageDown": "NPage", "PgDn": "NPage", "Insert": "IC", "Delete": "DC", "Return": "Enter", "Backspace": "BSpace", "Esc": "Escape", "Backtab": "BTab"} NAMED = ("Up", "Down", "Left", "Right", "Home", "End", "PPage", "NPage", "IC", "DC", "Enter", "Tab", "BTab", "BSpace", "Escape", "Space") + tuple("F%d" % n for n in range(1, 13)) FROM_TUI = {"up": "Up", "down": "Down", "left": "Left", "right": "Right", "home": "Home", "end": "End", "pageup": "PPage", "pagedown": "NPage", "insert": "IC", "delete": "DC", "backtab": "BTab", "enter": "Enter", "tab": "Tab", "backspace": "BSpace", "escape": "Escape", "space": "Space"} CSI_FINAL = {"Up": "A", "Down": "B", "Right": "C", "Left": "D", "Home": "H", "End": "F"} CSI_TILDE = {"IC": 2, "DC": 3, "PPage": 5, "NPage": 6, "F5": 15, "F6": 17, "F7": 18, "F8": 19, "F9": 20, "F10": 21, "F11": 23, "F12": 24} SS3_FUNC = {"F1": "P", "F2": "Q", "F3": "R", "F4": "S"} CTRL_PUNCT = {"\\": 0x1C, "]": 0x1D, "^": 0x1E, "_": 0x1F, "@": 0x00, "[": 0x1B, "?": 0x7F} @classmethod def canonical(cls, spec): if spec == " ": return "Space" text = str(spec).strip() if not text: raise MplexError("empty key name") mods = set() while len(text) >= 3 and text[1] == "-" and text[0] in "CMS": mods.add(text[0]) text = text[2:] if len(text) == 2 and text[0] == "^": mods.add("C") text = text[1] text = cls.ALIASES.get(text, text) if len(text) == 1: if text == " ": text = "Space" elif "C" in mods and text.isalpha(): text = text.lower() elif "S" in mods and text.isalpha(): text = text.upper() mods.discard("S") elif text not in cls.NAMED: raise MplexError("unknown key: %s" % spec) if "C" in mods: if text == "i": mods.discard("C") text = "Tab" elif text == "m": mods.discard("C") text = "Enter" elif text == "[": mods.discard("C") text = "Escape" return ("C-" if "C" in mods else "") + ("M-" if "M" in mods else "") + ("S-" if "S" in mods else "") + text @classmethod def name_of_char(cls, raw): """Canonical name for one decoded character (no escape prefix).""" if len(raw) == 1: byte = raw[0] if byte == 0x00: return "C-Space" if byte == 0x09: return "Tab" if byte == 0x0D: return "Enter" if byte == 0x1B: return "Escape" if byte == 0x20: return "Space" if byte == 0x7F: return "BSpace" if 0x01 <= byte <= 0x1A: return "C-" + chr(byte + 0x60) if 0x1C <= byte <= 0x1F: return "C-" + "\\]^_"[byte - 0x1C] return chr(byte) return raw.decode("utf-8", "replace") @classmethod def split(cls, data, final=False): """bytes -> ([(name, raw)], rest). name is "" for sequences that are not keys (they are still forwarded). A lone ESC waits until final=True.""" tokens = [] buf = bytes(data) while buf: if buf[0] != 0x1B: length = 1 lead = buf[0] if lead >= 0xF0: length = 4 elif lead >= 0xE0: length = 3 elif lead >= 0xC0: length = 2 if length > len(buf): if not final: break length = len(buf) raw = buf[:length] tokens.append((cls.name_of_char(raw), raw)) buf = buf[length:] continue if len(buf) == 1: if not final: break tokens.append(("Escape", b"\x1b")) buf = b"" break event, rest = tui_parse_one(buf, final) if event == "need_more": if not final: break tokens.append(("Escape", b"\x1b")) buf = buf[1:] continue if event == "escape": tokens.append(("Escape", b"\x1b")) buf = buf[1:] continue consumed = len(buf) - len(rest) if consumed <= 0: consumed = 1 raw = buf[:consumed] buf = buf[consumed:] tokens.append((cls._name_of_event(event, raw), raw)) return tokens, buf @classmethod def _name_of_event(cls, event, raw): if not isinstance(event, TuiKey): return "" if len(raw) >= 2 and raw[1:2] not in (b"[", b"O"): inner = cls.name_of_char(raw[1:]) try: return cls.canonical("M-" + inner) except MplexError: return "" key = event.key base = key.split("+")[-1] if not key.endswith("+") else "+" if base in cls.FROM_TUI: base = cls.FROM_TUI[base] elif len(base) >= 2 and base[0] == "f" and base[1:].isdigit(): base = "F" + base[1:] else: return "" prefix = "" mods = set(getattr(event, "modifiers", None) or []) if "ctrl" in mods: prefix += "C-" if "alt" in mods or "meta" in mods: prefix += "M-" if "shift" in mods: prefix += "S-" try: return cls.canonical(prefix + base) except MplexError: return "" @classmethod def to_bytes(cls, name, app_cursor=False): """Bytes a terminal would send for a canonical key name.""" mods = "" text = name while len(text) >= 3 and text[1] == "-" and text[0] in "CMS": mods += text[0] text = text[2:] if len(text) == 1 or text == "Space": char = " " if text == "Space" else text data = char.encode("utf-8") if "C" in mods: if char == " ": data = b"\x00" elif char.isalpha() and char.isascii(): data = bytes([ord(char.upper()) & 0x1F]) elif char in cls.CTRL_PUNCT: data = bytes([cls.CTRL_PUNCT[char]]) return (b"\x1b" if "M" in mods else b"") + data simple = {"Enter": b"\r", "Tab": b"\t", "BSpace": b"\x7f", "Escape": b"\x1b", "BTab": b"\x1b[Z"} if text in simple and not ("S" in mods and text not in ("BTab",)): return (b"\x1b" if "M" in mods else b"") + simple[text] mod_code = 1 + (1 if "S" in mods else 0) + (2 if "M" in mods else 0) + (4 if "C" in mods else 0) if text in cls.CSI_FINAL: if mod_code > 1: return ("\x1b[1;%d%s" % (mod_code, cls.CSI_FINAL[text])).encode("ascii") return (b"\x1bO" if app_cursor else b"\x1b[") + cls.CSI_FINAL[text].encode("ascii") if text in cls.CSI_TILDE: if mod_code > 1: return ("\x1b[%d;%d~" % (cls.CSI_TILDE[text], mod_code)).encode("ascii") return ("\x1b[%d~" % cls.CSI_TILDE[text]).encode("ascii") if text in cls.SS3_FUNC: if mod_code > 1: return ("\x1b[1;%d%s" % (mod_code, cls.SS3_FUNC[text])).encode("ascii") return ("\x1bO" + cls.SS3_FUNC[text]).encode("ascii") raise MplexError("unknown key: %s" % name) @classmethod def app_cursor_form(cls, raw): """Plain CSI cursor keys become SS3 when the pane asked for application cursor mode.""" if len(raw) == 3 and raw[:2] == b"\x1b[" and raw[2:3] in (b"A", b"B", b"C", b"D", b"H", b"F"): return b"\x1bO" + raw[2:3] return raw class MplexArgs: """getopt-style tmux argument parsing. spec "bdt:" => -b and -d are flags, -t takes a value.""" def __init__(self, flags, positional): self.flags = flags self.positional = positional def has(self, flag): return flag in self.flags def get(self, flag, default=None): values = self.flags.get(flag) if not values: return default return values[-1] def all(self, flag): return list(self.flags.get(flag) or []) def int(self, flag, default=None): value = self.get(flag) if value is None or value is True: return default try: return int(value) except ValueError: raise MplexError("-%s expects a number: %s" % (flag, value)) @classmethod def parse(cls, name, spec, words, min_args=0, max_args=None): takes = set() known = set() index = 0 while index < len(spec): known.add(spec[index]) if index + 1 < len(spec) and spec[index + 1] == ":": takes.add(spec[index]) index += 1 index += 1 flags = {} positional = [] pos = 0 while pos < len(words): word = words[pos] if word == "--": positional.extend(words[pos + 1:]) break if len(word) < 2 or word[0] != "-": positional.extend(words[pos:]) break inner = 1 while inner < len(word): letter = word[inner] if letter not in known: raise MplexError("command %s: unknown flag -%s" % (name, letter)) if letter in takes: if inner + 1 < len(word): value = word[inner + 1:] else: pos += 1 if pos >= len(words): raise MplexError("command %s: -%s expects an argument" % (name, letter)) value = words[pos] flags.setdefault(letter, []).append(value) break flags.setdefault(letter, []).append(True) inner += 1 pos += 1 if len(positional) < min_args or (max_args is not None and len(positional) > max_args): raise MplexError("command %s: wrong number of arguments" % name) return cls(flags, positional) class MplexCommandParser: """Splits tmux-syntax command text into commands of words: quotes, backslash, {braces} as one argument, ';' and newlines between commands.""" @staticmethod def parse(text): commands = [] words = [] cur = [] has = False i = 0 n = len(text) def end_word(): nonlocal cur, has if has: words.append("".join(cur)) cur = [] has = False def end_command(): nonlocal words end_word() if words: commands.append(words) words = [] while i < n: c = text[i] if c in " \t\r": end_word() i += 1 elif c == "\n" or c == ";": end_command() i += 1 elif c == "\\": if i + 1 < n: if text[i + 1] == "\n": i += 2 continue cur.append(text[i + 1]) has = True i += 2 elif c == "'": j = text.find("'", i + 1) if j < 0: raise MplexError("unterminated single quote") cur.append(text[i + 1:j]) has = True i = j + 1 elif c == '"': j = i + 1 piece = [] while j < n and text[j] != '"': if text[j] == "\\" and j + 1 < n: nxt = text[j + 1] piece.append({"n": "\n", "t": "\t", "e": "\x1b", "r": "\r"}.get(nxt, nxt)) j += 2 else: piece.append(text[j]) j += 1 if j >= n: raise MplexError("unterminated double quote") cur.append("".join(piece)) has = True i = j + 1 elif c == "{" and not has: depth = 0 j = i quote = "" while j < n: ch = text[j] if quote: if ch == "\\" and quote == '"': j += 1 elif ch == quote: quote = "" elif ch in "'\"": quote = ch elif ch == "\\": j += 1 elif ch == "{": depth += 1 elif ch == "}": depth -= 1 if depth == 0: break j += 1 if depth != 0 or j >= n: raise MplexError("unterminated { block") cur.append(text[i + 1:j].strip()) has = True i = j + 1 else: cur.append(c) has = True i += 1 end_command() return commands @staticmethod def quote(word): word = str(word) if word and all(ch.isalnum() or ch in "-_./:=%@+," for ch in word): return word return '"' + word.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$") + '"' class MplexStyle: """tmux style strings ("bg=green,fg=black,bold") to (fg, bg, attr) in the cell format TuiVtScreen uses: SGR fragments for colors, a bitmask for attributes.""" NAMES = ("black", "red", "green", "yellow", "blue", "magenta", "cyan", "white") ATTRS = {"bold": 1, "bright": 1, "dim": 2, "italics": 32, "underscore": 4, "underline": 4, "blink": 64, "reverse": 8, "hidden": 128, "strikethrough": 16} @classmethod def color(cls, text, background): text = text.strip().lower() if text in ("default", "terminal", ""): return None base = 40 if background else 30 if text in cls.NAMES: return str(base + cls.NAMES.index(text)) if text.startswith("bright") and text[6:] in cls.NAMES: return str(base + 60 + cls.NAMES.index(text[6:])) if text.startswith("colour") or text.startswith("color"): digits = text[6:] if text.startswith("colour") else text[5:] if digits.isdigit() and int(digits) < 256: return "%d;5;%d" % (48 if background else 38, int(digits)) if text.startswith("#") and len(text) == 7: try: red, green, blue = int(text[1:3], 16), int(text[3:5], 16), int(text[5:7], 16) except ValueError: raise MplexError("bad colour: %s" % text) return "%d;2;%d;%d;%d" % (48 if background else 38, red, green, blue) raise MplexError("bad colour: %s" % text) @classmethod def parse(cls, text, base=(None, None, 0), reset=None): """Apply a style string on top of base; "default" returns to reset (base when omitted).""" fg, bg, attr = base reset = base if reset is None else reset for item in re.split(r"[,\s]+", str(text).strip()): if not item: continue low = item.lower() if low == "default": fg, bg, attr = reset elif low in ("none", "noattr"): attr = 0 elif low.startswith("fg="): fg = cls.color(item[3:], False) elif low.startswith("bg="): bg = cls.color(item[3:], True) elif low in cls.ATTRS: attr |= cls.ATTRS[low] elif low.startswith("no") and low[2:] in cls.ATTRS: attr &= ~cls.ATTRS[low[2:]] elif low.startswith(("align=", "list=", "range=", "push-default", "pop-default", "fill=", "norange", "nolist")): continue else: raise MplexError("bad style: %s" % item) return (fg, bg, attr) @staticmethod def sgr(fg, bg, attr): codes = ["0"] for bit, code in ((1, "1"), (2, "2"), (32, "3"), (4, "4"), (64, "5"), (8, "7"), (128, "8"), (16, "9")): if attr & bit: codes.append(code) if fg: codes.append(fg) if bg: codes.append(bg) return "\x1b[" + ";".join(codes) + "m" class MplexOptions: """Typed option store with a parent chain: window, session, then global.""" WORD_SEPARATORS = "!\"#$%&'()*+,-./:;<=>?@[\\]^`{|}~" DEFS = { "prefix": ("key", "C-b"), "prefix2": ("key", "None"), "base-index": ("int:0:9999", 0), "pane-base-index": ("int:0:9999", 0), "escape-time": ("int:0:10000", 10), "repeat-time": ("int:0:10000", 500), "history-limit": ("int:0:%d" % MPLEX_HISTORY_CAP, 2000), "mode-keys": ("choice:vi|emacs", ""), "status-keys": ("choice:vi|emacs", ""), "status": ("bool", True), "status-position": ("choice:top|bottom", "bottom"), "status-interval": ("int:0:3600", 15), "status-justify": ("choice:left|centre|center|right", "left"), "status-left": ("str", "[#{session_name}] "), "status-right": ("str", "\"#{=21:pane_title}\" %H:%M %d-%b-%y"), "status-left-length": ("int:0:1000", 10), "status-right-length": ("int:0:1000", 40), "status-style": ("style", "bg=green,fg=black"), "status-left-style": ("style", "default"), "status-right-style": ("style", "default"), "message-style": ("style", "bg=yellow,fg=black"), "message-command-style": ("style", "bg=black,fg=yellow"), "mode-style": ("style", "noattr,bg=yellow,fg=black"), "copy-mode-match-style": ("style", "bg=cyan,fg=black"), "copy-mode-current-match-style": ("style", "bg=magenta,fg=black"), "copy-mode-mark-style": ("style", "bg=red,fg=black"), "display-time": ("int:0:600000", 750), "display-panes-time": ("int:1:600000", 1000), "display-panes-colour": ("colour", "blue"), "display-panes-active-colour": ("colour", "red"), "window-status-format": ("str", "#I:#W#{?window_flags,#{window_flags}, }"), "window-status-current-format": ("str", "#I:#W#{?window_flags,#{window_flags}, }"), "window-status-separator": ("str", " "), "window-status-style": ("style", "default"), "window-status-current-style": ("style", "default"), "window-status-last-style": ("style", "default"), "pane-border-style": ("style", "default"), "pane-active-border-style": ("str", "#{?pane_in_mode,fg=yellow,#{?synchronize-panes,fg=red,fg=green}}"), "pane-border-lines": ("choice:single|double|heavy|simple", "single"), "clock-mode-colour": ("colour", "blue"), "clock-mode-style": ("choice:12|24", "24"), "main-pane-width": ("int:1:10000", 80), "main-pane-height": ("int:1:10000", 24), "renumber-windows": ("bool", False), "automatic-rename": ("bool", True), "automatic-rename-format": ("str", "#{?pane_in_mode,[mplex],#{pane_current_command}}#{?pane_dead,[dead],}"), "allow-set-title": ("bool", True), "default-shell": ("str", ""), "default-command": ("str", ""), "default-terminal": ("str", "tmux-256color"), "detach-on-destroy": ("bool", True), "wrap-search": ("bool", True), "word-separators": ("str", WORD_SEPARATORS), "remain-on-exit": ("bool", False), "synchronize-panes": ("bool", False), "copy-command": ("str", ""), "buffer-limit": ("int:1:%d" % MPLEX_BUFFERS_CAP, 50), "set-clipboard": ("choice:on|external|off", "off"), } def __init__(self, parent=None): self.parent = parent self.values = {} @classmethod def check(cls, name, raw): if name not in cls.DEFS: raise MplexError("unknown or unsupported option: %s" % name) kind = cls.DEFS[name][0] text = str(raw) if kind == "str": return text if kind == "bool": low = text.strip().lower() if low in ("on", "yes", "true", "1"): return True if low in ("off", "no", "false", "0"): return False raise MplexError("value is invalid: %s" % text) if kind == "key": return "None" if text.strip() in ("None", "none", "") else MplexKeys.canonical(text) if kind == "colour": MplexStyle.color(text, False) return text if kind == "style": if "#{" not in text: MplexStyle.parse(text) return text if kind.startswith("int:"): _tag, low, high = kind.split(":") try: value = int(text) except ValueError: raise MplexError("value is invalid: %s" % text) if value < int(low) or value > int(high): raise MplexError("value out of range %s..%s: %s" % (low, high, text)) return value if kind.startswith("choice:"): choices = kind.split(":", 1)[1].split("|") if text not in choices: raise MplexError("value is invalid (use %s): %s" % ("|".join(choices), text)) return text raise MplexError("bad option kind for %s" % name) def get(self, name): node = self while node is not None: if name in node.values: return node.values[name] node = node.parent default = self.DEFS[name][1] if name in ("mode-keys", "status-keys"): editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "" return "vi" if "vi" in editor else "emacs" return default def is_set(self, name): return name in self.values def set(self, name, raw, append=False): value = self.check(name, raw) if append and isinstance(value, str) and name in self.values: value = self.values[name] + value self.values[name] = value return value def toggle(self, name): if self.DEFS.get(name, ("",))[0] != "bool": raise MplexError("only boolean options can be toggled: %s" % name) self.values[name] = not self.get(name) def unset(self, name): if name not in self.DEFS: raise MplexError("unknown or unsupported option: %s" % name) self.values.pop(name, None) @staticmethod def show(value): if value is True: return "on" if value is False: return "off" return str(value) class MplexBinding: __slots__ = ("command", "repeat", "note") def __init__(self, command, repeat=False, note=""): self.command = command self.repeat = repeat self.note = note class MplexConfig: """Defaults (tmux 3.6) plus ~/.tai/mplex.toml. Problems never abort the load: each bad entry is skipped, counted in errors, and shown to the first client.""" ERRORS_MAX = 50 TOP_KEYS = ("options", "keys") def __init__(self): self.options = MplexOptions() self.tables = {} self.errors = [] self.path = "" self.skipped = 0 for table, key, command, repeat, note in MPLEX_DEFAULT_KEYS: self.bind(table, MplexKeys.canonical(key), command, repeat, note) def error(self, text): self.skipped += 1 if len(self.errors) < self.ERRORS_MAX: self.errors.append(text) def bind(self, table, key, command, repeat=False, note=""): self.tables.setdefault(table, {})[key] = MplexBinding(command, repeat, note) def unbind(self, table, key): return self.tables.get(table, {}).pop(key, None) is not None def lookup(self, table, key): return self.tables.get(table, {}).get(key) @staticmethod def default_path(home): return os.path.join(home, MPLEX_CONFIG_NAME) @classmethod def load(cls, path): config = cls() config.path = path config.apply_file(path) return config def apply_file(self, path): try: if os.path.getsize(path) > MPLEX_CONFIG_MAX: self.error("%s: larger than %d bytes, ignored" % (path, MPLEX_CONFIG_MAX)) return with open(path, "rb") as handle: raw = handle.read() except FileNotFoundError: return except OSError as exc: self.error("%s: cannot read: %s" % (path, short_error(exc))) return try: import tomllib except ImportError: self.error("%s: needs Python 3.11 or newer (tomllib), file ignored" % path) return try: data = tomllib.loads(raw.decode("utf-8")) except (ValueError, UnicodeDecodeError) as exc: self.error("%s: not valid TOML: %s" % (path, exc)) return self.apply_data(data) def apply_data(self, data): for key in data: if key not in self.TOP_KEYS: self.error("unknown top-level key [%s] (use options, keys)" % key) options = data.get("options", {}) if not isinstance(options, dict): self.error("[options] must be a table") options = {} for name, value in options.items(): try: if isinstance(value, bool): value = "on" if value else "off" elif not isinstance(value, (str, int)): raise MplexError("value must be a string, number or boolean") self.options.set(name, value) except MplexError as exc: self.error("options.%s: %s" % (name, exc)) keys = data.get("keys", {}) if not isinstance(keys, dict): self.error("[keys] must be a table") return clear = keys.get("clear", []) if not isinstance(clear, list): self.error("keys.clear must be a list of table names") clear = [] for table in clear: self.tables[str(table)] = {} for table, entries in keys.items(): if table == "clear": continue if not isinstance(entries, dict): self.error("keys.%s must be a table of key = command" % table) continue for key, spec in entries.items(): self._apply_binding(table, key, spec) def _apply_binding(self, table, key, spec): where = "keys.%s.%s" % (table, key) try: name = MplexKeys.canonical(key) if spec is False or spec == "": self.unbind(table, name) return note = "" repeat = False if isinstance(spec, dict): unknown = [item for item in spec if item not in ("command", "repeat", "note")] if unknown: raise MplexError("unknown field %s (use command, repeat, note)" % unknown[0]) command = spec.get("command") repeat = bool(spec.get("repeat", False)) note = str(spec.get("note", "")) else: command = spec if not isinstance(command, str) or not command.strip(): raise MplexError("command must be a non-empty string, or false to unbind") for words in MplexCommandParser.parse(command): MplexCommands.resolve(words[0]) self.bind(table, name, command, repeat, note) except MplexError as exc: self.error("%s: %s" % (where, exc)) class MplexFormat: """tmux format strings: #S style shorthands, #{var}, #{?cond,a,b}, modifiers such as =N:, E:, q:, ==:, m:, s/a/b/: and #(shell). Style markers #[...] pass through.""" SHORT = {"D": "pane_id", "F": "window_flags", "H": "host", "I": "window_index", "P": "pane_index", "S": "session_name", "T": "pane_title", "W": "window_name", "h": "host_short"} DEPTH_MAX = 12 @staticmethod def match_close(text, start, opener, closer): """Index of the closer matching the opener at text[start] (nested #{ / #( count), or -1.""" depth = 1 pos = start + 1 while pos < len(text): ch = text[pos] if ch == "#" and pos + 1 < len(text): if text[pos + 1] == opener: depth += 1 pos += 2 continue if ch == closer: depth -= 1 if depth == 0: return pos pos += 1 return -1 @classmethod def split_commas(cls, text): parts = [] cur = [] depth = 0 pos = 0 while pos < len(text): ch = text[pos] if ch == "#" and pos + 1 < len(text): nxt = text[pos + 1] if nxt == "," and depth == 0: cur.append("#,") pos += 2 continue if nxt in "{(": depth += 1 cur.append(text[pos:pos + 2]) pos += 2 continue cur.append(text[pos:pos + 2]) pos += 2 continue if ch == "}" or ch == ")": depth = max(0, depth - 1) if ch == "," and depth == 0: parts.append("".join(cur)) cur = [] else: cur.append(ch) pos += 1 parts.append("".join(cur)) return parts @classmethod def expand(cls, text, resolve, depth=0): if depth > cls.DEPTH_MAX or not text: return "" out = [] pos = 0 n = len(text) while pos < n: ch = text[pos] if ch != "#" or pos + 1 >= n: out.append(ch) pos += 1 continue nxt = text[pos + 1] if nxt == "#": out.append("#") pos += 2 elif nxt == "{": end = cls.match_close(text, pos + 1, "{", "}") if end < 0: out.append(ch) pos += 1 else: out.append(cls.evaluate(text[pos + 2:end], resolve, depth)) pos = end + 1 elif nxt == "(": end = cls.match_close(text, pos + 1, "(", ")") if end < 0: out.append(ch) pos += 1 else: out.append(resolve("(" + text[pos + 2:end]) or "") pos = end + 1 elif nxt == "[": end = text.find("]", pos + 2) if end < 0: out.append(ch) pos += 1 else: out.append(text[pos:end + 1]) pos = end + 1 elif nxt in cls.SHORT: out.append(resolve(cls.SHORT[nxt]) or "") pos += 2 elif nxt in ",}": out.append(nxt) pos += 2 else: out.append(ch) pos += 1 return "".join(out) @staticmethod def truthy(value): return value not in ("", "0", None) @classmethod def value_of(cls, text, resolve, depth): """A variable name or a nested format: its text value.""" if "#" in text: return cls.expand(text, resolve, depth + 1) value = resolve(text) return "" if value is None else str(value) @classmethod def evaluate(cls, inner, resolve, depth): if inner.startswith("?"): parts = cls.split_commas(inner[1:]) index = 0 while index + 1 < len(parts): cond = cls.value_of(parts[index], resolve, depth) if cls.truthy(cond): return cls.expand(parts[index + 1], resolve, depth + 1) index += 2 if index < len(parts): return cls.expand(parts[index], resolve, depth + 1) return "" colon = cls.modifier_colon(inner) if colon < 0: value = resolve(inner) return "" if value is None else str(value) mods = inner[:colon] rest = inner[colon + 1:] return cls.apply_modifiers(mods, rest, resolve, depth) @staticmethod def modifier_colon(inner): """Index of the ':' that ends a modifier list, or -1 when inner is a plain name.""" colon = inner.find(":") if colon <= 0: return -1 head = inner[:colon] if "#" in head or "{" in head or " " in head: return -1 return colon @classmethod def apply_modifiers(cls, mods, rest, resolve, depth): specs = [item for item in mods.split(";") if item] text = None for spec in specs: if spec in ("==", "!=", "<", ">", "<=", ">=", "&&", "||") or spec.startswith("e|"): parts = cls.split_commas(rest) left = cls.expand(parts[0], resolve, depth + 1) if parts else "" right = cls.expand(parts[1], resolve, depth + 1) if len(parts) > 1 else "" return cls.compare(spec, left, right) if spec in ("m", "m/r", "m/ri", "m/i"): parts = cls.split_commas(rest) pattern = cls.expand(parts[0], resolve, depth + 1) if parts else "" subject = cls.expand(parts[1], resolve, depth + 1) if len(parts) > 1 else "" flags = re.IGNORECASE if spec.endswith("i") else 0 if spec.startswith("m/r"): try: return "1" if re.search(pattern, subject, flags) else "0" except re.error: return "0" import fnmatch return "1" if fnmatch.fnmatchcase(subject.lower() if flags else subject, pattern.lower() if flags else pattern) else "0" if spec == "l": return rest if text is None: text = cls.value_of(rest, resolve, depth) text = cls.apply_one(spec, text, resolve, depth) return text or "" @classmethod def apply_one(cls, spec, text, resolve, depth): if spec == "E" or spec == "T": return cls.expand(text, resolve, depth + 1) if spec == "q": return re.sub(r"([\\\"'$#;{}\[\]()*?<>&|~ ])", r"\\\1", text) if spec == "t": try: return time.strftime("%a %b %d %H:%M:%S %Y", time.localtime(float(text))) except (ValueError, OverflowError, OSError): return text if spec == "n": return str(len(text)) if spec == "b": return os.path.basename(text) if spec == "d": return os.path.dirname(text) if spec == "!": return "0" if cls.truthy(text) else "1" if spec == "!!": return "1" if cls.truthy(text) else "0" if spec.startswith("="): body = spec[1:] marker = "" if body.startswith("/"): _blank, count, marker = (body.split("/", 2) + ["", ""])[:3] body = count try: limit = int(body) except ValueError: return text if limit >= 0 and len(text) > limit: return text[:limit] + marker if limit < 0 and len(text) > -limit: return marker + text[limit:] return text if spec.startswith("s/"): fields = spec.split("/") if len(fields) >= 3: try: return re.sub(fields[1], fields[2], text) except re.error: return text return text @staticmethod def compare(op, left, right): if op.startswith("e|"): kind = op.split("|")[1] if "|" in op else "" try: a, b = float(left), float(right) except ValueError: return "0" table = {"+": a + b, "-": a - b, "*": a * b, "/": a / b if b else 0, "%": a % b if b else 0, "==": a == b, "!=": a != b, "<": a < b, ">": a > b, "<=": a <= b, ">=": a >= b} if kind not in table: return "0" value = table[kind] if isinstance(value, bool): return "1" if value else "0" return str(int(value)) if float(value).is_integer() else str(value) if op == "==": return "1" if left == right else "0" if op == "!=": return "1" if left != right else "0" if op == "&&": return "1" if MplexFormat.truthy(left) and MplexFormat.truthy(right) else "0" if op == "||": return "1" if MplexFormat.truthy(left) or MplexFormat.truthy(right) else "0" try: a, b = float(left), float(right) except ValueError: a, b = left, right return "1" if {"<": a < b, ">": a > b, "<=": a <= b, ">=": a >= b}[op] else "0" @staticmethod def with_time(text, stamp=None): if "%" not in text: return text try: return time.strftime(text, time.localtime(stamp)) except (ValueError, OverflowError): return text @staticmethod def strip_styles(text): return re.sub(r"#\[[^\]]*\]", "", text) class MplexLayoutNode: __slots__ = ("kind", "children", "parent", "pane", "x", "y", "w", "h", "reverse") def __init__(self, kind, pane=None, w=1, h=1): self.reverse = False self.kind = kind self.children = [] self.parent = None self.pane = pane self.x = 0 self.y = 0 self.w = w self.h = h def leaves(self): if self.kind == "pane": return [self] found = [] for child in self.children: found.extend(child.leaves()) return found def adopt(self, child): child.parent = self self.children.append(child) return child class MplexLayout: """Split tree: a node is a pane or a container laid out side by side ("lr") or stacked ("tb"); a one-cell border separates siblings. Sizes are absolute and are rescaled proportionally when the window changes size.""" @staticmethod def size_of(node, kind): return node.w if kind == "lr" else node.h @staticmethod def set_size(node, kind, value): if kind == "lr": node.w = value else: node.h = value @classmethod def assign(cls, node, x, y, w, h): node.x, node.y, node.w, node.h = x, y, w, h if node.kind == "pane": return kids = node.children count = len(kids) kind = node.kind total = (w if kind == "lr" else h) - (count - 1) if total < count: sizes = [1] * count else: sizes = [max(1, cls.size_of(kid, kind)) for kid in kids] weight = sum(sizes) or count if weight != total: sizes = [max(1, int(round(size * total / weight))) for size in sizes] diff = total - sum(sizes) while diff != 0: moved = False for index in (range(count) if diff > 0 else range(count - 1, -1, -1)): if diff > 0: sizes[index] += 1 diff -= 1 moved = True elif sizes[index] > 1: sizes[index] -= 1 diff += 1 moved = True if diff == 0: break if not moved: break order = list(zip(kids, sizes)) if node.reverse: order.reverse() pos = x if kind == "lr" else y for kid, size in order: if kind == "lr": cls.assign(kid, pos, y, size, h) else: cls.assign(kid, x, pos, w, size) pos += size + 1 @classmethod def split(cls, root, leaf, kind, before, new_pane, size=None): """Insert new_pane beside leaf. Returns (root, new_leaf).""" total = cls.size_of(leaf, kind) if total < 3: raise MplexError("no space for new pane") avail = total - 1 if size is None: new_size = avail - avail // 2 if before else avail // 2 else: new_size = max(1, min(avail - 1, size)) old_size = avail - new_size fresh = MplexLayoutNode("pane", pane=new_pane, w=leaf.w, h=leaf.h) parent = leaf.parent if parent is not None and parent.kind == kind: index = parent.children.index(leaf) parent.children.insert(index if before else index + 1, fresh) fresh.parent = parent else: box = MplexLayoutNode(kind, w=leaf.w, h=leaf.h) box.x, box.y = leaf.x, leaf.y box.parent = parent if parent is None: root = box else: parent.children[parent.children.index(leaf)] = box for item in ((fresh, leaf) if before else (leaf, fresh)): box.adopt(item) cls.set_size(leaf, kind, old_size) cls.set_size(fresh, kind, new_size) return root, fresh @classmethod def remove(cls, root, leaf): """Drop leaf, hand its space to a neighbour. Returns the new root (None when empty).""" parent = leaf.parent if parent is None: return None index = parent.children.index(leaf) parent.children.remove(leaf) neighbour = parent.children[index - 1] if index > 0 else parent.children[0] kind = parent.kind cls.set_size(neighbour, kind, cls.size_of(neighbour, kind) + cls.size_of(leaf, kind) + 1) if len(parent.children) == 1: only = parent.children[0] grand = parent.parent only.w, only.h = parent.w, parent.h only.parent = grand if grand is None: return only slot = grand.children.index(parent) if only.kind == grand.kind: grand.children[slot:slot + 1] = only.children for child in only.children: child.parent = grand else: grand.children[slot] = only return root @classmethod def resize_by(cls, leaf, kind, delta, prefer_after): """Move the border of leaf's nearest enclosing "kind" container by delta cells.""" node = leaf while node.parent is not None and node.parent.kind != kind: node = node.parent parent = node.parent if parent is None: raise MplexError("no border to resize") kids = parent.children index = kids.index(node) use_after = (index < len(kids) - 1) if prefer_after else (index == 0) first = index if use_after else index - 1 a, b = kids[first], kids[first + 1] size_a, size_b = cls.size_of(a, kind), cls.size_of(b, kind) new_a = max(1, min(size_a + size_b - 1, size_a + delta)) cls.set_size(a, kind, new_a) cls.set_size(b, kind, size_a + size_b - new_a) @staticmethod def shares(total, count, spare_first): """Split total cells among count panes with count-1 one-cell borders; the remainder goes first or last.""" room = total - (count - 1) base, extra = divmod(room, count) sizes = [base] * count for index in (range(extra) if spare_first else range(count - extra, count)): sizes[index] += 1 return sizes @classmethod def resize_to(cls, leaf, kind, size): """Make leaf exactly size cells along kind by moving the border it can move (the one after it, else the one before).""" change = size - cls.size_of(leaf, kind) node = leaf while node.parent is not None and node.parent.kind != kind: node = node.parent if node.parent is None: raise MplexError("no border to resize") has_next = node.parent.children.index(node) < len(node.parent.children) - 1 cls.resize_by(leaf, kind, change if has_next else -change, True) @classmethod def preset(cls, name, panes, width, height, main_width, main_height): count = len(panes) leaves = [MplexLayoutNode("pane", pane=pane, w=1, h=1) for pane in panes] mirrored = name.endswith("-mirrored") base = name[:-len("-mirrored")] if mirrored else name if count == 1: leaves[0].w, leaves[0].h = width, height return leaves[0] def box(kind, items, w, h, spare_first=True): node = MplexLayoutNode(kind, w=w, h=h) for item in items: node.adopt(item) for item, size in zip(items, cls.shares(w if kind == "lr" else h, len(items), spare_first)): if kind == "lr": item.w, item.h = size, h else: item.w, item.h = w, size return node if base == "even-horizontal": return box("lr", leaves, width, height) if base == "even-vertical": return box("tb", leaves, width, height) if base in ("main-horizontal", "main-vertical"): horizontal = base == "main-horizontal" span = height if horizontal else width main_size = max(1, min(span - 2, main_height if horizontal else main_width)) main = leaves[0] rest = leaves[1:] other_size = span - main_size - 1 if horizontal: others = rest[0] if len(rest) == 1 else box("lr", rest, width, other_size) main.w, main.h, others.w, others.h = width, main_size, width, other_size root = box("tb", [main, others], width, height) else: others = rest[0] if len(rest) == 1 else box("tb", rest, other_size, height) main.w, main.h, others.w, others.h = main_size, height, other_size, height root = box("lr", [main, others], width, height) if horizontal: main.h, others.h = main_size, other_size else: main.w, others.w = main_size, other_size root.reverse = mirrored return root rows = columns = 1 while rows * columns < count: rows += 1 if rows * columns < count: columns += 1 lines = [] for start in range(0, count, columns): chunk = leaves[start:start + columns] lines.append(chunk[0] if len(chunk) == 1 else box("lr", chunk, width, 1, spare_first=False)) if len(lines) == 1: return lines[0] root = box("tb", lines, width, height, spare_first=False) for line in lines: line.w = width return root @staticmethod def neighbour(panes, current, direction, width, height): left, top = current.x, current.y right, bottom = current.x + current.w, current.y + current.h def overlaps(other): if direction in ("L", "R"): return other.y < bottom and other.y + other.h > top return other.x < right and other.x + other.w > left wanted = [] for other in panes: if other is current or not overlaps(other): continue if direction == "L" and other.x + other.w + 1 == left: wanted.append(other) elif direction == "R" and other.x == right + 1: wanted.append(other) elif direction == "U" and other.y + other.h + 1 == top: wanted.append(other) elif direction == "D" and other.y == bottom + 1: wanted.append(other) if not wanted: for other in panes: if other is current or not overlaps(other): continue if direction == "L" and other.x + other.w >= width: wanted.append(other) elif direction == "R" and other.x == 0: wanted.append(other) elif direction == "U" and other.y + other.h >= height: wanted.append(other) elif direction == "D" and other.y == 0: wanted.append(other) if not wanted: return None return max(wanted, key=lambda item: item.last_active) class MplexPane: """One pty-backed shell: its screen, scrollback, output queue and mode stack.""" def __init__(self, ident, cols, rows, history_limit, home_dir, stamp="0"): self.id = ident self.window = None self.x = 0 self.y = 0 self.w = max(1, cols) self.h = max(1, rows) self.screen = TuiVtScreen(self.h, self.w) self.screen.history = collections.deque(maxlen=max(0, history_limit)) self.master_fd = None self.pid = None self.dead = False self.exit_status = None self.pending_in = bytearray() self.dropped = 0 self.modes = [] self.prefix_count = 1 self.created = time.time() self.last_active = 0.0 self.shell_name = "" self.last_search = None self.log_path = os.path.join(home_dir, "%s-pane%d.log" % (stamp, ident)) self._log = None self._log_size = 0 self.input_bytes = 0 @property def tag(self): return "%%%d" % self.id @property def title(self): return self.screen.title or socket.gethostname() def spawn(self, file, argv, cwd, env): master, slave = os.openpty() try: fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", self.h, self.w, 0, 0)) attrs = termios.tcgetattr(slave) attrs[0] |= getattr(termios, "IUTF8", 0) termios.tcsetattr(slave, termios.TCSANOW, attrs) except (OSError, termios.error): pass log_fd = os.open(self.log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) self._log = os.fdopen(log_fd, "ab", buffering=0) pid = os.fork() if pid == 0: try: os.setsid() fcntl.ioctl(slave, termios.TIOCSCTTY, 0) for fd in (0, 1, 2): os.dup2(slave, fd) if slave > 2: os.close(slave) os.close(master) for sig in (signal.SIGPIPE, signal.SIGINT, signal.SIGHUP, signal.SIGTERM): signal.signal(sig, signal.SIG_DFL) try: os.chdir(cwd) except OSError: pass os.execvpe(file, argv, env) except BaseException: pass finally: os._exit(127) os.close(slave) os.set_blocking(master, False) self.master_fd = master self.pid = pid self.shell_name = os.path.basename(file) def place(self, x, y, w, h): self.x, self.y = x, y w, h = max(1, w), max(1, h) if (w, h) == (self.w, self.h): return self.w, self.h = w, h self.screen.resize(h, w) self.apply_winsize() for mode in self.modes: mode.on_resize() def apply_winsize(self): if self.master_fd is None: return try: fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", self.h, self.w, 0, 0)) except OSError: pass def feed(self, data): history = self.screen.history before = self.screen.history_pushed before_len = len(history) if history is not None else 0 self.screen.feed(data) pushed = self.screen.history_pushed - before evicted = max(0, before_len + pushed - len(history)) if history is not None and pushed else 0 if self._log is not None: self.log(data) replies = self.screen.take_replies() if replies: self.write(replies) for mode in self.modes: mode.on_output(pushed, evicted) def log(self, data): """Append to the session log; at MPLEX_LOG_MAX it rolls over to <name>.1 (one generation kept).""" try: if self._log_size + len(data) > MPLEX_LOG_MAX: self._log.close() os.replace(self.log_path, self.log_path + ".1") fd = os.open(self.log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) self._log = os.fdopen(fd, "ab", buffering=0) self._log_size = 0 self._log.write(data) self._log_size += len(data) except OSError: pass def write(self, data): if not data: return if len(self.pending_in) + len(data) > MPLEX_PANE_IN_MAX: self.dropped += len(data) return self.pending_in += data def drain(self): if not self.pending_in or self.master_fd is None: return try: written = os.write(self.master_fd, bytes(self.pending_in[:4096])) del self.pending_in[:written] except BlockingIOError: pass except OSError: self.pending_in.clear() def top_mode(self): return self.modes[-1] if self.modes else None def push_mode(self, mode): self.modes.append(mode) return mode def pop_mode(self, mode=None): if mode is None: if self.modes: self.modes.pop() elif mode in self.modes: self.modes.remove(mode) def find_mode(self, cls): for mode in self.modes: if isinstance(mode, cls): return mode return None def foreground_command(self): try: group = os.tcgetpgrp(self.master_fd) with open("/proc/%d/comm" % group, "r") as handle: return handle.read().strip() or self.shell_name except (OSError, TypeError, ValueError): return self.shell_name def current_path(self): try: return os.readlink("/proc/%d/cwd" % self.pid) except (OSError, TypeError): return "" def history_size(self): history = self.screen.history return len(history) if history is not None else 0 def clear_history(self): if self.screen.history is not None: self.screen.history.clear() def close(self, hard=False): """Hang up the child: SIGHUP to its group, close the master; hard SIGKILLs instead.""" if self.pid is not None: sig = signal.SIGKILL if hard else signal.SIGHUP try: os.killpg(self.pid, sig) except OSError: try: os.kill(self.pid, sig) except OSError: pass if self.master_fd is not None: try: os.close(self.master_fd) except OSError: pass self.master_fd = None if self._log is not None: try: self._log.close() except OSError: pass self._log = None def tail_log(self, lines, chunk_size=65536, max_bytes=1 << 24): """Last lines of the raw session log, reading only the tail in growing chunks.""" try: size = os.path.getsize(self.log_path) except OSError: return "" with open(self.log_path, "rb") as handle: read_from = size data = b"" while True: read_from = max(0, read_from - chunk_size) handle.seek(read_from) data = handle.read(size - read_from) if data.count(b"\n") >= lines or read_from == 0 or len(data) >= max_bytes: break return "\n".join(data.decode("utf-8", "replace").splitlines()[-lines:]) class MplexWindow: def __init__(self, ident, name, width, height, parent_options): self.id = ident self.name = name self.root = None self.active = None self.last_pane = None self.zoomed = None self.width = width self.height = height self.layout_index = -1 self.options = MplexOptions(parent_options) self.auto_rename = True self.created = time.time() self.session = None @property def tag(self): return "@%d" % self.id def panes(self): return [leaf.pane for leaf in self.root.leaves()] if self.root is not None else [] def relayout(self): if self.root is None: return MplexLayout.assign(self.root, 0, 0, self.width, self.height) for leaf in self.root.leaves(): leaf.pane.place(leaf.x, leaf.y, leaf.w, leaf.h) if self.zoomed is not None and self.zoomed in self.panes(): self.zoomed.place(0, 0, self.width, self.height) else: self.zoomed = None def visible_panes(self): return [self.zoomed] if self.zoomed is not None else self.panes() def leaf_of(self, pane): for leaf in self.root.leaves(): if leaf.pane is pane: return leaf return None def set_active(self, pane): if pane is not self.active: self.last_pane = self.active self.active = pane pane.last_active = time.monotonic() class MplexSession: def __init__(self, ident, name, parent_options, cwd, env): self.id = ident self.name = name self.windows = {} self.current = None self.history = [] self.created = time.time() self.options = MplexOptions(parent_options) self.cwd = cwd self.env = env self.last_attached = time.time() @property def tag(self): return "$%d" % self.id def window(self): return self.windows.get(self.current) def indexes(self): return sorted(self.windows) def free_index(self, base): index = base while index in self.windows: index += 1 return index @property def last(self): """Index of the window that was current before this one, or None.""" for window in self.history: index = self.index_of(window) if index is not None: return index return None def select(self, index): if index not in self.windows: return False if index != self.current: previous = self.window() target = self.windows[index] self.history = [item for item in self.history if item is not previous and item is not target] if previous is not None: self.history.insert(0, previous) self.current = index return True def index_of(self, window): for index, item in self.windows.items(): if item is window: return index return None class MplexCanvas: """A client-sized grid of (char, fg, bg, attr) cells plus a cursor, and the diff that turns two successive grids into the escape sequences that repaint only what changed.""" BLANK = (" ", None, None, 0) GLYPHS = { "single": "─│┌┐└┘├┤┬┴┼", "double": "═║╔╗╚╝╠╣╦╩╬", "heavy": "━┃┏┓┗┛┣┫┳┻╋", "simple": "-|+++++++++", } def __init__(self, rows, cols): self.rows = rows self.cols = cols self.cells = [[self.BLANK] * cols for _row in range(rows)] self.cursor = None def put(self, x, y, text, style=(None, None, 0), limit=None): if y < 0 or y >= self.rows: return x fg, bg, attr = style row = self.cells[y] for char in text: if limit is not None and limit <= 0: break if 0 <= x < self.cols: row[x] = (char, fg, bg, attr) x += 1 if limit is not None: limit -= 1 return x def put_cells(self, x, y, cells): if y < 0 or y >= self.rows: return row = self.cells[y] for cell in cells: if 0 <= x < self.cols: row[x] = cell x += 1 def blit(self, x, y, block, width): for index, source in enumerate(block): target = y + index if target < 0 or target >= self.rows: continue row = self.cells[target] take = min(width, len(source), self.cols - x) if take > 0: row[x:x + take] = source[:take] def fill(self, x, y, w, h, style=(None, None, 0), char=" "): cell = (char, style[0], style[1], style[2]) for target in range(max(0, y), min(self.rows, y + h)): row = self.cells[target] for col in range(max(0, x), min(self.cols, x + w)): row[col] = cell @classmethod def border_char(cls, up, down, left, right, lines): glyphs = cls.GLYPHS.get(lines, cls.GLYPHS["single"]) horiz, vert, tl, tr, bl, br, tee_r, tee_l, tee_d, tee_u, cross = glyphs if (up or down) and not (left or right): return vert if (left or right) and not (up or down): return horiz if up and down and left and right: return cross if up and down and right: return tee_r if up and down and left: return tee_l if left and right and down: return tee_d if left and right and up: return tee_u if down and right: return tl if down and left: return tr if up and right: return bl if up and left: return br return cross def encode(self, previous): """Escape bytes repainting this grid over previous (a list of row tuples, or None).""" snapshot = [tuple(row) for row in self.cells] out = ["\x1b[?25l"] if previous is None or len(previous) != len(snapshot): out.append("\x1b[0m\x1b[2J") previous = None for index, row in enumerate(snapshot): if previous is not None and previous[index] == row: continue out.append("\x1b[%d;1H" % (index + 1)) current = None for char, fg, bg, attr in row: style = (fg, bg, attr) if style != current: out.append(MplexStyle.sgr(fg, bg, attr)) current = style out.append(char) out.append("\x1b[0m") if self.cursor is not None: out.append("\x1b[%d;%dH\x1b[?25h" % (self.cursor[1] + 1, self.cursor[0] + 1)) return "".join(out).encode("utf-8", "replace"), snapshot class MplexStatusText: """Format-expanded text with #[...] style markers turned into styled cells.""" @staticmethod def cells(text, base, reset=None): reset = base if reset is None else reset current = base out = [] pos = 0 while pos < len(text): if text.startswith("#[", pos): end = text.find("]", pos + 2) if end > 0: try: current = MplexStyle.parse(text[pos + 2:end], current, reset) except MplexError: pass pos = end + 1 continue out.append((text[pos], current[0], current[1], current[2])) pos += 1 return out class MplexMode: """Pane mode base: owns the pane's keyboard and its drawing while it is on top.""" name = "mode" def __init__(self, server, pane): self.server = server self.pane = pane def render(self, client): raise NotImplementedError def key(self, name, raw, client): return None def on_output(self, pushed, evicted): return None def on_resize(self): return None def close(self): self.pane.pop_mode(self) self.server.mark_dirty(self.pane.window) class MplexCopyMode(MplexMode): """Scrollback browser: movement, search, selection and copy, driven by the copy-mode / copy-mode-vi key tables through "send-keys -X <command>".""" name = "copy-mode" def __init__(self, server, pane, lines=None, view=False, exit_at_bottom=False, show_position=True): super().__init__(server, pane) self.static_rows = None if lines is not None: self.name = "view-mode" self.static_rows = [[(char, None, None, 0) for char in line] for line in lines] while len(self.static_rows) < pane.h: self.static_rows.append([]) self.oy = 0 self.cx = 0 if lines is not None else pane.screen.cx self.cy = 0 if lines is not None else pane.screen.cy self.anchor = None self.rect = False self.line_select = False self.mark = None self.jump = None self.inc_origin = None self.show_position = show_position self.exit_at_bottom = exit_at_bottom self.count = 1 self.lastcx = 0 self.lastsx = 0 # a search string outlives copy mode; re-entering it starts searching upward, as tmux does self.search = (pane.last_search[0], False, pane.last_search[2]) if lines is None and pane.last_search else None self._line_cache = (-1, "") if lines is not None: self.oy = self.hist() @property def rows(self): return self.pane.h @property def cols(self): return self.pane.w def hist(self): if self.static_rows is not None: return max(0, len(self.static_rows) - self.rows) return self.pane.history_size() def total(self): return self.hist() + self.rows def top(self): return self.hist() - self.oy def abs_y(self): return self.top() + self.cy def row(self, ay): if self.static_rows is not None: return self.static_rows[ay] if 0 <= ay < len(self.static_rows) else [] hist = self.hist() if ay < hist: return self.pane.screen.history[ay] grid = self.pane.screen.grid index = ay - hist return grid[index] if 0 <= index < len(grid) else [] def text(self, ay): return "".join(cell[0] if cell else " " for cell in self.row(ay)).rstrip() def clamp(self): self.oy = max(0, min(self.hist(), self.oy)) self.cy = max(0, min(self.rows - 1, self.cy)) self.cx = max(0, min(self.cols, self.cx)) def goto(self, x, ay): ay = max(0, min(self.total() - 1, ay)) top = self.top() if ay < top: top = ay elif ay >= top + self.rows: top = ay - self.rows + 1 self.oy = max(0, min(self.hist(), self.hist() - top)) self.cy = ay - self.top() self.cx = max(0, min(self.cols, x)) def on_output(self, pushed, evicted): if self.static_rows is not None: return if self.oy > 0 and pushed: self.oy += pushed if evicted: if self.anchor: self.anchor = (self.anchor[0], self.anchor[1] - evicted) if self.mark: self.mark = (self.mark[0], self.mark[1] - evicted) self.clamp() def on_resize(self): self.clamp() # ---- rendering ------------------------------------------------------- def vi_keys(self): return self.server.pane_options(self.pane).get("mode-keys") == "vi" def selected(self, x, ay): if self.anchor is None: return False ax, ay0 = self.anchor cx, cy = self.cx, self.abs_y() extra = 1 if self.vi_keys() else 0 if self.rect: return min(ay0, cy) <= ay <= max(ay0, cy) and min(ax, cx) <= x < max(ax, cx) + extra if self.line_select: return min(ay0, cy) <= ay <= max(ay0, cy) first, last = sorted(((ay0, ax), (cy, cx))) return first <= (ay, x) < (last[0], last[1] + extra) def pattern(self): if not self.search: return None return self.compile(self.search[0], len(self.search) > 2 and self.search[2]) def render(self, client): opts = self.server.pane_options(self.pane) mode_style = MplexStyle.parse(opts.get("mode-style")) match_style = MplexStyle.parse(opts.get("copy-mode-match-style")) pattern = self.pattern() top = self.top() out = [] for view_row in range(self.rows): ay = top + view_row source = self.row(ay) cells = [] for col in range(self.cols): cell = source[col] if col < len(source) and source[col] is not None else MplexCanvas.BLANK cells.append(cell) if pattern is not None: line = "".join(cell[0] for cell in cells) for match in pattern.finditer(line): if match.end() == match.start(): continue for col in range(match.start(), match.end()): cells[col] = self.tint(cells[col], match_style) if self.anchor is not None: for col in range(self.cols): if self.selected(col, ay): cells[col] = self.tint(cells[col], mode_style) out.append(cells) if self.show_position and out: label = "[%d/%d]" % (self.oy, self.hist()) start = max(0, self.cols - len(label)) for index, char in enumerate(label[:self.cols]): out[0][start + index] = (char, mode_style[0], mode_style[1], mode_style[2]) return out, (self.cx, self.cy) @staticmethod def tint(cell, style): char, fg, bg, attr = cell return (char, style[0] or fg, style[1] or bg, attr | style[2]) # ---- keys ------------------------------------------------------------ def key(self, name, raw, client): table = "copy-mode-vi" if self.server.pane_options(self.pane).get("mode-keys") == "vi" else "copy-mode" binding = self.server.config.lookup(table, name) if binding is not None: self.server.run_binding(client, binding, self.pane) return True def command(self, name, words, client): """Run one copy-mode command (send-keys -X name args) count times.""" method = getattr(self, "c_" + name.replace("-", "_"), None) if method is None: raise MplexError("unknown copy mode command: %s" % name) count = max(1, min(self.pane.prefix_count, 10000)) self.pane.prefix_count = 1 self.count = count self._line_cache = (-1, "") result = method(words, client) self.clamp() self.server.mark_dirty(self.pane.window) if result == "cancel": self.close() # ---- cursor and scrolling -------------------------------------------- # The cursor sits between characters like tmux's: it may stand one past the # last character of a line (x == line length), and vertical moves remember the # column they started from (lastcx / lastsx), snapping to the line end the way # tmux does. These rules were checked against tmux 3.6 with a differential fuzz. def llen(self, ay): return len(self.text(ay)) def snap(self): px = self.llen(self.abs_y()) if (self.cx >= self.lastsx and self.cx != px) or self.cx > px: self.cx = px def c_cancel(self, words, client): return "cancel" def step_up(self, scroll_only): ox = self.llen(self.abs_y()) if self.cx != ox: self.lastcx, self.lastsx = self.cx, ox self.cx = self.lastcx if scroll_only or self.cy == 0: if self.oy < self.hist(): self.oy += 1 else: self.cy -= 1 self.snap() def step_down(self, scroll_only): ox = self.llen(self.abs_y()) if self.cx != ox: self.lastcx, self.lastsx = self.cx, ox self.cx = self.lastcx if scroll_only or self.cy == self.rows - 1: if self.oy > 0: self.oy -= 1 else: self.cy += 1 self.snap() def c_cursor_up(self, words, client): for _i in range(self.count): self.step_up(False) def c_cursor_down(self, words, client): for _i in range(self.count): self.step_down(False) return self.bottom_exit() def c_cursor_down_and_cancel(self, words, client): for _i in range(self.count): self.step_down(False) if self.oy == 0 and self.cy == self.rows - 1: return "cancel" def bottom_exit(self): return "cancel" if self.exit_at_bottom and self.oy == 0 else None def c_cursor_left(self, words, client): for _i in range(self.count): ay = self.abs_y() if self.cx == 0 and ay > 0: self.goto(self.llen(ay - 1), ay - 1) elif self.cx > 0: self.cx -= 1 def c_cursor_right(self, words, client): for _i in range(self.count): ay = self.abs_y() if self.cx >= self.llen(ay): if ay < self.total() - 1: self.goto(0, ay + 1) else: self.cx += 1 def c_start_of_line(self, words, client): self.cx = 0 def c_end_of_line(self, words, client): self.cx = self.llen(self.abs_y()) def c_back_to_indentation(self, words, client): line = self.text(self.abs_y()) if line: self.cx = len(line) - len(line.lstrip(" ")) def c_top_line(self, words, client): self.cx, self.cy = 0, 0 def c_middle_line(self, words, client): self.cx, self.cy = 0, (self.rows - 1) // 2 def c_bottom_line(self, words, client): self.cx, self.cy = 0, self.rows - 1 def c_history_top(self, words, client): self.oy, self.cx, self.cy = self.hist(), 0, 0 def c_history_bottom(self, words, client): self.oy, self.cy = 0, self.rows - 1 self.cx = self.llen(self.abs_y()) return self.bottom_exit() def page_size(self, half): return max(1, self.rows // 2 if half else self.rows - 2) if self.rows > 2 else 1 def remember_column(self): ox = self.llen(self.abs_y()) if self.cx != ox: self.lastcx, self.lastsx = self.cx, ox self.cx = self.lastcx def page_up(self, half): size = self.page_size(half) self.remember_column() if self.oy + size > self.hist(): self.oy = self.hist() self.cy = self.cy - size if self.cy > size else 0 else: self.oy += size self.snap() def page_down(self, half): size = self.page_size(half) self.remember_column() if self.oy < size: self.cy = min(self.rows - 1, self.cy + size) self.oy = 0 else: self.oy -= size self.snap() def c_page_up(self, words, client): for _i in range(self.count): self.page_up(False) def c_page_down(self, words, client): for _i in range(self.count): self.page_down(False) return self.bottom_exit() def c_page_down_and_cancel(self, words, client): for _i in range(self.count): self.page_down(False) if self.oy == 0: return "cancel" def c_halfpage_up(self, words, client): for _i in range(self.count): self.page_up(True) def c_halfpage_down(self, words, client): for _i in range(self.count): self.page_down(True) return self.bottom_exit() def c_halfpage_down_and_cancel(self, words, client): for _i in range(self.count): self.page_down(True) if self.oy == 0: return "cancel" def c_scroll_up(self, words, client): for _i in range(self.count): self.step_up(True) def c_scroll_down(self, words, client): for _i in range(self.count): self.step_down(True) return self.bottom_exit() def c_scroll_down_and_cancel(self, words, client): for _i in range(self.count): self.step_down(True) if self.oy == 0: return "cancel" def c_scroll_middle(self, words, client): mid = (self.rows - 1) // 2 delta = self.cy - mid if delta > 0 and self.oy >= delta: self.oy -= delta self.cy = mid elif delta < 0 and self.hist() - self.oy >= -delta: self.oy += -delta self.cy = mid def c_cursor_centre_vertical(self, words, client): self.cy = self.rows // 2 def c_cursor_centre_horizontal(self, words, client): return None def c_scroll_top(self, words, client): if self.oy >= self.cy: self.oy -= self.cy self.cy = 0 def c_scroll_bottom(self, words, client): need = self.rows - 1 - self.cy if self.hist() - self.oy >= need: self.oy += need self.cy = self.rows - 1 def c_goto_line(self, words, client): try: line = int(words[0]) except (IndexError, ValueError): raise MplexError("goto-line needs a line number") self.oy = max(0, min(self.hist(), line)) def c_refresh_from_pane(self, words, client): return None def c_toggle_position(self, words, client): self.show_position = not self.show_position # ---- words, paragraphs, brackets ------------------------------------- def in_ws(self, x, ay): return self.char_at(x, ay) == " " def in_sep(self, x, ay, separators): return self.char_at(x, ay) in separators def separators(self, big): return "" if big else self.server.pane_options(self.pane).get("word-separators") def char_at(self, x, ay): if self._line_cache[0] != ay: self._line_cache = (ay, self.text(ay)) line = self._line_cache[1] return line[x] if x < len(line) else " " def advance_blanks(self, x, ay, xx): """Skip whitespace, running off the line end onto the next line. Returns (x, ay, xx, hit_bottom).""" last = self.total() - 1 while x > xx or self.in_ws(x, ay): if x > xx: if ay == last: return x, ay, xx, True ay += 1 x = 0 xx = self.llen(ay) else: x += 1 return x, ay, xx, False def run_end(self, x, ay, xx, separators): """x just past the run of separators (or of word characters) that starts at x.""" if self.in_sep(x, ay, separators) and not self.in_ws(x, ay): x += 1 while x <= xx and self.in_sep(x, ay, separators) and not self.in_ws(x, ay): x += 1 else: x += 1 while x <= xx and not (self.in_sep(x, ay, separators) or self.in_ws(x, ay)): x += 1 return x def c_next_word(self, words, client, big=False): separators = self.separators(big) x, ay = self.cx, self.abs_y() for _i in range(self.count): xx = self.llen(ay) if x > xx: # past the line end (reachable after centre-vertical or goto-line): tmux carries on skipping # the first word of the next line before it looks for a word start if ay == self.total() - 1: break ay += 1 x = 0 xx = self.llen(ay) if not self.in_ws(x, ay): x = self.run_end(x, ay, xx, separators) x, ay, xx, _bottom = self.advance_blanks(x, ay, xx) self.goto(x, ay) def c_next_space(self, words, client): self.c_next_word(words, client, True) def c_next_word_end(self, words, client, big=False): separators = self.separators(big) vi = self.server.pane_options(self.pane).get("mode-keys") == "vi" x, ay = self.cx, self.abs_y() for _i in range(self.count): xx = self.llen(ay) if vi: if x < xx: x += 1 elif ay < self.total() - 1: self.goto(0, ay + 1) x, ay = 0, ay + 1 xx = self.llen(ay) x, ay, xx, bottom = self.advance_blanks(x, ay, xx) if not bottom: x = self.run_end(x, ay, xx, separators) if vi and x > 0: x -= 1 if bottom: break self.goto(x, ay) def c_next_space_end(self, words, client): self.c_next_word_end(words, client, True) def c_previous_word(self, words, client, big=False): separators = self.separators(big) x, ay = self.cx, self.abs_y() for _i in range(self.count): # one step back, then over whitespace (and empty lines) to a word character while True: if x == 0: if ay == 0: break ay -= 1 x = self.llen(ay) else: x -= 1 if not self.in_ws(x, ay): break if self.in_ws(x, ay): break letters = not self.in_sep(x, ay, separators) while x > 0 and not self.in_ws(x - 1, ay) and letters == (not self.in_sep(x - 1, ay, separators)): x -= 1 self.goto(x, ay) def c_previous_space(self, words, client): self.c_previous_word(words, client, True) def c_next_paragraph(self, words, client): ay = self.abs_y() last = self.total() - 1 for _i in range(self.count): while ay < last and not self.llen(ay): ay += 1 while ay < last and self.llen(ay): ay += 1 self.scroll_to(self.llen(ay), ay) def c_previous_paragraph(self, words, client): ay = self.abs_y() for _i in range(self.count): while ay > 0 and not self.llen(ay): ay -= 1 while ay > 0 and self.llen(ay): ay -= 1 self.scroll_to(0, ay) def match_bracket(self, x, ay, forward): """Position of the bracket matching the one at (x, ay), or None.""" pairs = {"(": ")", "[": "]", "{": "}"} reverse = {value: key for key, value in pairs.items()} opener = self.char_at(x, ay) closer = pairs.get(opener) if forward else reverse.get(opener) if closer is None: return None depth = 0 pos = (x, ay) for _i in range(20000): char = self.char_at(*pos) if char == opener: depth += 1 elif char == closer: depth -= 1 if depth == 0: return pos pos = self.step(pos[0], pos[1], forward) if pos is None: return None return None def step(self, x, ay, forward): """Next position, line ends counting as one blank cell. None at the buffer edge.""" if forward: if x < self.llen(ay): return x + 1, ay return (0, ay + 1) if ay + 1 < self.total() else None if x > 0: return x - 1, ay return (self.llen(ay - 1), ay - 1) if ay > 0 else None def c_next_matching_bracket(self, words, client): ay = self.abs_y() if not self.llen(ay): return here = self.char_at(self.cx, ay) found = None if self.vi_keys(): if here in "([{": found = self.match_bracket(self.cx, ay, True) elif here in ")]}": found = self.match_bracket(self.cx, ay, False) else: for x in (self.cx, self.cx + 1): if self.char_at(x, ay) in "([{" and found is None and (x == self.cx or here == " "): found = self.match_bracket(x, ay, True) if found is not None: self.goto(*found) else: self.c_next_word_end([], None, True) def c_previous_matching_bracket(self, words, client): ay = self.abs_y() if not self.llen(ay): return here = self.char_at(self.cx, ay) found = None if here in ")]}": found = self.match_bracket(self.cx, ay, False) elif self.vi_keys(): if here in "([{" or self.char_at(self.cx + 1, ay) in "([{": return elif self.cx > 0 and self.char_at(self.cx - 1, ay) in ")]}": found = self.match_bracket(self.cx - 1, ay, False) if found is not None: self.goto(*found) else: self.c_previous_word([], None, True) # ---- jump ------------------------------------------------------------ def do_jump(self, kind, char): line = self.text(self.abs_y()) x = self.cx for _i in range(self.count): if kind == "f": found = line.find(char, x + 1) elif kind == "t": found = line.find(char, x + 2) found = found - 1 if found >= 0 else -1 elif kind == "F": found = line.rfind(char, 0, x) else: found = line.rfind(char, 0, max(0, x - 1)) found = found + 1 if found >= 0 else -1 if found < 0: return x = found self.cx = x def set_jump(self, kind, words): if not words or not words[0]: raise MplexError("jump needs a character") self.jump = (kind, words[0][0]) self.do_jump(*self.jump) def c_jump_forward(self, words, client): self.set_jump("f", words) def c_jump_backward(self, words, client): self.set_jump("F", words) def c_jump_to_forward(self, words, client): self.set_jump("t", words) def c_jump_to_backward(self, words, client): self.set_jump("T", words) def c_jump_again(self, words, client): if self.jump: self.do_jump(*self.jump) def c_jump_reverse(self, words, client): if self.jump: opposite = {"f": "F", "F": "f", "t": "T", "T": "t"}[self.jump[0]] self.do_jump(opposite, self.jump[1]) # ---- selection and copy ---------------------------------------------- def c_begin_selection(self, words, client): self.anchor = (self.cx, self.abs_y()) self.line_select = False def c_stop_selection(self, words, client): return None def c_clear_selection(self, words, client): self.anchor = None self.line_select = False def c_select_line(self, words, client): self.anchor = (0, self.abs_y()) self.line_select = True self.rect = False for _i in range(self.count - 1): self.step_down(False) self.cx = self.llen(self.abs_y()) def c_select_word(self, words, client): ay = self.abs_y() separators = self.separators(False) line = self.text(ay) if not line: return start = min(self.cx, len(line) - 1) while start > 0 and self.in_ws(start, ay): start -= 1 if self.in_ws(start, ay): return letters = not self.in_sep(start, ay, separators) end = start while start > 0 and not self.in_ws(start - 1, ay) and letters == (not self.in_sep(start - 1, ay, separators)): start -= 1 while end + 1 < len(line) and not self.in_ws(end + 1, ay) and letters == (not self.in_sep(end + 1, ay, separators)): end += 1 self.anchor = (start, ay) self.line_select = False self.cx = end if self.vi_keys() else end + 1 def c_rectangle_toggle(self, words, client): self.rect = not self.rect def c_rectangle_on(self, words, client): self.rect = True def c_rectangle_off(self, words, client): self.rect = False def c_other_end(self, words, client): if self.anchor is None: return old = (self.cx, self.abs_y()) self.goto(*self.anchor) self.anchor = old def c_set_mark(self, words, client): self.mark = (self.cx, self.abs_y()) def c_jump_to_mark(self, words, client): if self.mark is None: return old = (self.cx, self.abs_y()) self.goto(*self.mark) self.mark = old def selection_text(self): """The selected text. Emacs keys: the cursor stands between characters, so the end is exclusive; vi keys: the character under the cursor is included, and so is the line end (a newline) when the cursor stands on it. Both checked against tmux 3.6.""" if self.anchor is None: return "" vi = self.vi_keys() ax, ay0 = self.anchor cx, cy = self.cx, self.abs_y() extra = 1 if vi else 0 if self.line_select: lines = [self.text(ay) for ay in range(min(ay0, cy), max(ay0, cy) + 1)] return "\n".join(lines) + ("\n" if vi else "") if self.rect: left, right = min(ax, cx), max(ax, cx) + extra return "\n".join(self.text(ay)[left:right] for ay in range(min(ay0, cy), max(ay0, cy) + 1)) (sy, sx), (ey, ex) = sorted(((ay0, ax), (cy, cx))) parts = [] for ay in range(sy, ey + 1): line = self.text(ay) first = sx if ay == sy else 0 last = ex + extra if ay == ey else len(line) piece = line[first:last] if ay == ey and vi and last > len(line): piece += "\n" parts.append(piece) return "\n".join(parts) def deliver(self, text, words, append=False): if not text: return command = words[0] if words else "" self.server.store_copy(text, command, append) def c_copy_selection(self, words, client): self.deliver(self.selection_text(), []) self.c_clear_selection(words, client) def c_copy_selection_and_cancel(self, words, client): self.deliver(self.selection_text(), []) return "cancel" def c_copy_pipe(self, words, client): self.deliver(self.selection_text(), words) self.c_clear_selection(words, client) def c_copy_pipe_and_cancel(self, words, client): self.deliver(self.selection_text(), words) return "cancel" def c_append_selection(self, words, client): self.deliver(self.selection_text(), [], True) self.c_clear_selection(words, client) def c_append_selection_and_cancel(self, words, client): self.deliver(self.selection_text(), [], True) return "cancel" def copy_span(self, from_start, words, cancel): """copy-end-of-line / copy-line: select from the cursor (or the line start) to the line end, count lines.""" ay = self.abs_y() self.anchor = (0 if from_start else self.cx, ay) self.line_select = False self.rect = False for _i in range(self.count - 1): self.step_down(False) self.cx = self.llen(self.abs_y()) self.deliver(self.selection_text(), words) self.c_clear_selection(words, None) return "cancel" if cancel else None def c_copy_end_of_line(self, words, client): return self.copy_span(False, [], False) def c_copy_end_of_line_and_cancel(self, words, client): return self.copy_span(False, [], True) def c_copy_pipe_end_of_line(self, words, client): return self.copy_span(False, words, False) def c_copy_pipe_end_of_line_and_cancel(self, words, client): return self.copy_span(False, words, True) def c_copy_line(self, words, client): return self.copy_span(True, [], False) def c_copy_line_and_cancel(self, words, client): return self.copy_span(True, [], True) def c_copy_pipe_line(self, words, client): return self.copy_span(True, words, False) def c_copy_pipe_line_and_cancel(self, words, client): return self.copy_span(True, words, True) # ---- search ---------------------------------------------------------- def compile(self, text, literal=False): flags = re.IGNORECASE if text == text.lower() else 0 if literal: return re.compile(re.escape(text), flags) try: return re.compile(text, flags) except re.error: return None def find(self, pattern, forward, x, ay, wrap): """First match at or after (x, ay) going forward, or before it going backward; returns (start, end, line).""" total = self.total() def matches_of(index): return [m for m in pattern.finditer(self.text(index)) if m.end() > m.start()] order = range(ay, total) if forward else range(ay, -1, -1) for index in order: found = matches_of(index) if index == ay: found = [m for m in found if (m.start() >= x if forward else m.start() < x)] if found: match = found[0] if forward else found[-1] return match.start(), match.end(), index if wrap: for index in (range(0, ay + 1) if forward else range(total - 1, ay - 1, -1)): found = matches_of(index) if found: match = found[0] if forward else found[-1] return match.start(), match.end(), index return None def scroll_to(self, x, ay): """Place the cursor on (x, ay), scrolling the way tmux does when the line is out of view.""" hist, rows = self.hist(), self.rows self.cx = max(0, min(self.cols, x)) top = hist - self.oy if top <= ay < top + rows: self.cy = ay - top return gap = rows // 4 if ay < rows: offset, self.cy = 0, ay elif ay > hist + rows - gap: offset, self.cy = hist, ay - hist else: offset, self.cy = ay + gap - rows, rows - gap self.oy = max(0, min(hist, hist - offset)) def run_search(self, text, forward, literal=False, origin=None): if not text: return False self.search = (text, forward, literal) if self.static_rows is None: self.pane.last_search = self.search pattern = self.compile(text, literal) if pattern is None: return False wrap = bool(self.server.pane_options(self.pane).get("wrap-search")) vi = self.server.pane_options(self.pane).get("mode-keys") == "vi" x, ay = origin if origin is not None else (self.cx, self.abs_y()) if forward and vi and origin is None: x += 1 found = self.find(pattern, forward, x, ay, wrap) if found is None: return False start, end, line = found self.scroll_to(end if forward and not vi else start, line) return True def search_failed(self, client): """tmux reports a failed search as a status message, not as a command error.""" self.server.message(client, "Search failed") def search_command(self, words, forward, literal=False, client=None): text = words[0] if words else "" if not text and self.search: text = self.search[0] if not self.run_search(text, forward, literal): self.search_failed(client) def c_search_forward(self, words, client): self.search_command(words, True, False, client) def c_search_backward(self, words, client): self.search_command(words, False, False, client) def c_search_forward_text(self, words, client): self.search_command(words, True, True, client) def c_search_backward_text(self, words, client): self.search_command(words, False, True, client) def c_search_again(self, words, client): if not self.search: return text, forward, literal = self.search if not self.run_search(text, forward, literal): self.search_failed(client) def c_search_reverse(self, words, client): if not self.search: return text, forward, literal = self.search if not self.run_search(text, not forward, literal): self.search_failed(client) self.search = (text, forward, literal) def incremental(self, words, forward): arg = words[0] if words else "" if self.inc_origin is None: self.inc_origin = (self.cx, self.abs_y()) mark = arg[:1] if mark == "=": origin = (self.inc_origin[0] - 1 if forward else self.inc_origin[0] + 1, self.inc_origin[1]) self.run_search(arg[1:], forward, origin=origin) elif mark in ("+", "-"): self.run_search(arg[1:], mark == "+") else: self.inc_origin = None if arg: self.search = (arg, forward, False) def c_search_forward_incremental(self, words, client): self.incremental(words, True) def c_search_backward_incremental(self, words, client): self.incremental(words, False) def search_cancel(self): if self.inc_origin is not None: self.goto(*self.inc_origin) self.inc_origin = None self.server.mark_dirty(self.pane.window) class MplexPrompt: """The command line in the status row: emacs-style editing, history, and the single-key (-1), numeric (-N), key-name (-k) and incremental (-i) variants.""" def __init__(self, label, text, on_done, history=None, single=False, numeric=False, key_name=False, on_change=None, on_cancel=None, on_step=None, confirm=False): self.label = label self.text = text self.pos = len(text) self.on_done = on_done self.history = history if history is not None else [] self.hist_index = None self.single = single self.numeric = numeric self.key_name = key_name self.on_change = on_change self.on_cancel = on_cancel self.on_step = on_step self.confirm = confirm def view(self): return self.label + self.text, len(self.label) + self.pos def finish(self, server, client, value): client.prompt = None if value and not (self.single or self.numeric or self.key_name or self.confirm): self.history.append(value) del self.history[:-MPLEX_PROMPT_HISTORY_CAP] self.on_done(value) def cancel(self, client): client.prompt = None if self.on_cancel: self.on_cancel() def changed(self): if self.on_change: self.on_change(self.text) @staticmethod def printable(name, raw): if name == "Space": return " " if len(raw) >= 1 and (len(name) == 1 or raw.decode("utf-8", "ignore") == name) and name.isprintable(): return name return "" def key(self, server, client, name, raw): char = self.printable(name, raw) if self.confirm: if char == "y": self.finish(server, client, "y") else: self.cancel(client) return if self.key_name: self.finish(server, client, name) return if self.single: if char: self.finish(server, client, char) elif name in ("Escape", "C-c", "C-g"): self.cancel(client) return if self.numeric: if char.isdigit(): self.text += char self.pos = len(self.text) return self.finish(server, client, self.text) client.redispatch(name, raw) return text, pos = self.text, self.pos if name in ("Enter", "C-j"): self.finish(server, client, text) return if name in ("Escape", "C-c", "C-g"): self.cancel(client) return if name in ("C-a", "Home"): pos = 0 elif name in ("C-e", "End"): pos = len(text) elif name in ("C-b", "Left"): pos = max(0, pos - 1) elif name in ("C-f", "Right"): pos = min(len(text), pos + 1) elif name == "M-b": while pos > 0 and text[pos - 1] == " ": pos -= 1 while pos > 0 and text[pos - 1] != " ": pos -= 1 elif name == "M-f": while pos < len(text) and text[pos] == " ": pos += 1 while pos < len(text) and text[pos] != " ": pos += 1 elif name in ("BSpace", "C-h"): if pos > 0: text = text[:pos - 1] + text[pos:] pos -= 1 elif name in ("DC", "C-d"): text = text[:pos] + text[pos + 1:] elif name == "C-k": text = text[:pos] elif name == "C-u": text = text[pos:] pos = 0 elif name == "C-w": start = pos while start > 0 and text[start - 1] == " ": start -= 1 while start > 0 and text[start - 1] != " ": start -= 1 text = text[:start] + text[pos:] pos = start elif name == "C-y": piece = server.top_buffer_text() text = text[:pos] + piece + text[pos:] pos += len(piece) elif name == "C-t": if 0 < pos < len(text): text = text[:pos - 1] + text[pos] + text[pos - 1] + text[pos + 1:] pos += 1 elif name in ("Up", "C-p", "Down", "C-n"): if self.history: if name in ("Up", "C-p"): self.hist_index = len(self.history) - 1 if self.hist_index is None else max(0, self.hist_index - 1) elif self.hist_index is not None: self.hist_index += 1 if self.hist_index is not None and self.hist_index >= len(self.history): self.hist_index = None text = "" elif self.hist_index is not None: text = self.history[self.hist_index] pos = len(text) elif name in ("C-s", "C-r") and self.on_step: self.on_step(name, text) return elif char: text = text[:pos] + char + text[pos:] pos += len(char) else: return edited = text != self.text self.text, self.pos = text, pos if edited: self.changed() class MplexFont: GLYPHS = { "0": ("#####", "#...#", "#...#", "#...#", "#####"), "1": ("..#..", "..#..", "..#..", "..#..", "..#.."), "2": ("#####", "....#", "#####", "#....", "#####"), "3": ("#####", "....#", "#####", "....#", "#####"), "4": ("#...#", "#...#", "#####", "....#", "....#"), "5": ("#####", "#....", "#####", "....#", "#####"), "6": ("#####", "#....", "#####", "#...#", "#####"), "7": ("#####", "....#", "....#", "....#", "....#"), "8": ("#####", "#...#", "#####", "#...#", "#####"), "9": ("#####", "#...#", "#####", "....#", "#####"), ":": (".....", "..#..", ".....", "..#..", "....."), "A": ("#####", "#...#", "#####", "#...#", "#...#"), "P": ("#####", "#...#", "#####", "#....", "#...."), "M": ("#...#", "##.##", "#.#.#", "#...#", "#...#"), } HEIGHT = 5 @classmethod def width(cls, text): return len(text) * 6 - 1 if text else 0 @classmethod def draw(cls, text, bg): """Five rows of cells for text in big glyphs, lit pixels painted with background bg.""" rows = [] lit = (" ", None, bg, 0) dark = MplexCanvas.BLANK for line in range(cls.HEIGHT): row = [] for index, char in enumerate(text): glyph = cls.GLYPHS.get(char, cls.GLYPHS["0"]) row.extend(lit if pixel == "#" else dark for pixel in glyph[line]) if index != len(text) - 1: row.append(dark) rows.append(row) return rows class MplexClockMode(MplexMode): name = "clock-mode" def render(self, client): opts = self.server.pane_options(self.pane) bg = MplexStyle.color(opts.get("clock-mode-colour"), True) now = time.localtime() text = time.strftime("%H:%M", now) if opts.get("clock-mode-style") == "24" else time.strftime("%I:%M%p", now) grid = [[MplexCanvas.BLANK] * self.pane.w for _row in range(self.pane.h)] if self.pane.h >= MplexFont.HEIGHT and self.pane.w >= MplexFont.width(text): big = MplexFont.draw(text, bg) top = (self.pane.h - MplexFont.HEIGHT) // 2 left = (self.pane.w - MplexFont.width(text)) // 2 for index, row in enumerate(big): grid[top + index][left:left + len(row)] = row else: top = self.pane.h // 2 left = max(0, (self.pane.w - len(text)) // 2) for index, char in enumerate(text[:self.pane.w]): grid[top][left + index] = (char, None, bg, 0) return grid, None def key(self, name, raw, client): self.close() return True class MplexPaneNumbers: """display-panes overlay: a big index on every pane until a key or the timer ends it.""" def __init__(self, server, window, until): self.server = server self.window = window self.until = until def key(self, name, raw, client): client.overlay = None if len(name) == 1 and name.isdigit(): base = self.server.pane_options(self.window.active).get("pane-base-index") panes = self.window.panes() index = int(name) - base if 0 <= index < len(panes): self.server.select_pane(self.window, panes[index]) self.server.mark_dirty(self.window) def draw(self, canvas, area_y): opts = self.window.options base = opts.get("pane-base-index") normal = MplexStyle.color(opts.get("display-panes-colour"), True) active = MplexStyle.color(opts.get("display-panes-active-colour"), True) for index, pane in enumerate(self.window.visible_panes() if self.window.zoomed else self.window.panes()): bg = active if pane is self.window.active else normal text = str(index + base) if pane.h >= MplexFont.HEIGHT and pane.w >= MplexFont.width(text): big = MplexFont.draw(text, bg) top = pane.y + (pane.h - MplexFont.HEIGHT) // 2 left = pane.x + (pane.w - MplexFont.width(text)) // 2 for offset, row in enumerate(big): canvas.put_cells(left, area_y + top + offset, row) else: canvas.put(pane.x + max(0, (pane.w - len(text)) // 2), area_y + pane.y + pane.h // 2, text, (None, bg, 0)) size = "%dx%d" % (pane.w, pane.h) if pane.w > len(size) + 1: canvas.put(pane.x + pane.w - len(size), area_y + pane.y, size, (None, bg, 0)) class MplexFlowScreen: """One plexer_show screen on a native client: a centered dialog with wrapped text and, for kind choice, a list to move through. Answers land on the server's flow record; the control socket collects them (result/cancel).""" def __init__(self, server, flow_id, title, text, options, cursor, until): self.server = server self.flow_id = flow_id self.title = title self.text = text self.options = options self.cursor = max(0, min(cursor, max(0, len(options) - 1))) self.until = until self.box = MplexStyle.parse("fg=white,bg=blue") self.bold = MplexStyle.parse("fg=white,bg=blue,bold") self.sel = MplexStyle.parse("fg=black,bg=white") self.dim = MplexStyle.parse("fg=white,bg=blue,dim") def record(self): return self.server.flows.get(self.flow_id) def answer(self, client, status, value=None): found = self.record() if found is not None and found["answer"] is None: found["answer"] = {"status": status, "value": value} found["settled"] = time.monotonic() if client.overlay is self: client.overlay = None client.dirty = True def key(self, name, raw, client): if self.record() is None: if client.overlay is self: client.overlay = None client.dirty = True return if name in ("Escape", "C-c"): self.answer(client, "cancelled") return if not self.options: self.answer(client, "ok", True) return if name in ("Up", "k", "C-p"): self.cursor = max(0, self.cursor - 1) elif name in ("Down", "j", "C-n"): self.cursor = min(len(self.options) - 1, self.cursor + 1) elif name in ("Home", "g"): self.cursor = 0 elif name in ("End", "G"): self.cursor = len(self.options) - 1 elif name == "Enter": self.answer(client, "ok", self.cursor) return elif name == "q": self.answer(client, "cancelled") return elif len(name) == 1 and name in "1234567890": index = int(name) - 1 if name != "0" else 9 if index < len(self.options): self.answer(client, "ok", index) return client.dirty = True def rows(self, width): inner = max(8, width - 4) lines = [] for para in self.text.split("\n"): lines.extend(textwrap.wrap(para, inner) or [""]) return lines def draw(self, canvas, area_y): width = max(20, min(canvas.cols - 4, 68)) lines = self.rows(width) options = list(self.options) avail = canvas.rows - area_y - 3 want = 7 + len(lines) + len(options) while want > max(8, avail) and (len(lines) > 2 or len(options) > 1): if len(options) > 1 and len(options) >= len(lines): options.pop() elif len(lines) > 2: lines.pop() else: options.pop() want = 7 + len(lines) + len(options) height = min(want, max(8, avail)) left = max(0, (canvas.cols - width) // 2) top = area_y + max(0, (max(8, avail) - height) // 2) glyphs = MplexCanvas.GLYPHS["single"] horiz, vert, top_l, top_r, bot_l, bot_r = glyphs[0], glyphs[1], glyphs[2], glyphs[3], glyphs[4], glyphs[5] canvas.fill(left, top, width, height, self.box) canvas.put(left, top, top_l + horiz * (width - 2) + top_r, self.box) canvas.put(left, top + height - 1, bot_l + horiz * (width - 2) + bot_r, self.box) for row in range(1, height - 1): canvas.put(left, top + row, vert, self.box) canvas.put(left + width - 1, top + row, vert, self.box) title = self.title[:width - 4] canvas.put(left + (width - len(title)) // 2, top + 1, title, self.bold) row = top + 3 for line in lines: if row >= top + height - 1: break canvas.put(left + 2, row, line[:width - 4], self.box) row += 1 row += 1 for index, label in enumerate(options): if row >= top + height - 1: break num = str(index + 1) if index < 9 else ("0" if index == 9 else " ") entry = "%s %s %s" % (">" if index == self.cursor else " ", num, label) canvas.put(left + 2, row, entry[:width - 4], self.sel if index == self.cursor else self.box) row += 1 if len(options) < len(self.options) and row < top + height - 1: canvas.put(left + 2, row, "+%d more" % (len(self.options) - len(options)), self.dim) row += 1 hint = "Up/Down Enter 1-9 Esc" if self.options else "any key" if row < top + height - 1: row = top + height - 2 canvas.put(left + (width - len(hint)) // 2, min(row, top + height - 2), hint, self.dim) class MplexChoice: __slots__ = ("label", "payload", "children", "expanded", "depth", "tag") def __init__(self, label, payload=None, children=None, expanded=False): self.label = label self.payload = payload self.children = children or [] self.expanded = expanded self.depth = 0 self.tag = False class MplexChooser(MplexMode): """choose-tree / choose-buffer / choose-client: a list you move through, with an optional live preview of the selected item below it.""" name = "tree-mode" HOT_KEYS = "0123456789" def __init__(self, server, pane, kind, builder, on_choose, on_delete=None, preview=None, selected=None, on_close=None): super().__init__(server, pane) self.name = kind self.builder = builder self.items = builder() self.on_close = on_close self.on_choose = on_choose self.on_delete = on_delete self.preview = preview self.show_preview = preview is not None self.cursor = 0 self.visible = [] self.reflatten() if selected is not None: for index, item in enumerate(self.visible): if item.payload == selected: self.cursor = index def reflatten(self): self.visible = [] def walk(nodes, depth): for node in nodes: node.depth = depth self.visible.append(node) if node.children and node.expanded: walk(node.children, depth + 1) walk(self.items, 0) self.cursor = max(0, min(len(self.visible) - 1, self.cursor)) def selected(self): return self.visible[self.cursor] if self.visible else None def rebuild(self): opened = set() def collect(nodes): for node in nodes: if node.expanded: opened.add(repr(node.payload)) collect(node.children) def restore(nodes): for node in nodes: node.expanded = node.expanded or repr(node.payload) in opened restore(node.children) collect(self.items) self.items = self.builder() restore(self.items) self.reflatten() if not self.visible: self.close() def close(self): super().close() if self.on_close is not None: callback, self.on_close = self.on_close, None callback() def list_height(self): if self.show_preview and self.pane.h >= 8: return max(3, self.pane.h // 2) return self.pane.h def render(self, client): w, h = self.pane.w, self.pane.h opts = self.server.pane_options(self.pane) mode_style = MplexStyle.parse(opts.get("mode-style")) grid = [[MplexCanvas.BLANK] * w for _row in range(h)] list_h = self.list_height() top = max(0, min(self.cursor - list_h + 1, len(self.visible) - list_h)) if self.cursor >= list_h else 0 for row in range(list_h): index = top + row if index >= len(self.visible): break node = self.visible[index] hot = self.HOT_KEYS[index] if index < len(self.HOT_KEYS) else " " marker = ("- " if node.expanded else "+ ") if node.children else " " line = ("(%s) %s%s%s%s" % (hot, " " * node.depth, marker, "* " if node.tag else "", node.label))[:w].ljust(w) style = mode_style if index == self.cursor else (None, None, 0) grid[row] = [(char, style[0], style[1], style[2]) for char in line] if self.show_preview and list_h < h: border = MplexCanvas.GLYPHS["single"][0] grid[list_h] = [(border, None, None, 0)] * w chosen = self.selected() if chosen is not None: block = self.preview(chosen, w, h - list_h - 1) or [] for offset, cells in enumerate(block[:h - list_h - 1]): grid[list_h + 1 + offset] = (list(cells) + [MplexCanvas.BLANK] * w)[:w] return grid, None def move(self, delta): self.cursor = max(0, min(len(self.visible) - 1, self.cursor + delta)) def key(self, name, raw, client): node = self.selected() page = max(1, self.list_height() - 1) if name in ("q", "Escape", "C-c"): self.close() elif name in ("Up", "k", "C-p"): self.move(-1) elif name in ("Down", "j", "C-n"): self.move(1) elif name in ("PPage", "C-b"): self.move(-page) elif name in ("NPage", "C-f"): self.move(page) elif name in ("Home", "g"): self.cursor = 0 elif name in ("End", "G"): self.cursor = max(0, len(self.visible) - 1) elif name in ("Right", "l", "+") and node is not None and node.children: node.expanded = True self.reflatten() elif name in ("Left", "h", "-") and node is not None: if node.children and node.expanded: node.expanded = False else: for index in range(self.cursor - 1, -1, -1): if self.visible[index].depth < node.depth: self.cursor = index break self.reflatten() elif name == "t" and node is not None: node.tag = not node.tag elif name == "v" and self.preview is not None: self.show_preview = not self.show_preview elif name == "x" and node is not None and self.on_delete is not None: self.server.ask_confirm(client, "Kill %s? (y/n)" % node.label.split(":")[0], lambda: self.delete(node, client)) elif name == "Enter" and node is not None: self.close() self.on_choose(node, client) elif len(name) == 1 and name in self.HOT_KEYS: index = self.HOT_KEYS.index(name) if index < len(self.visible): self.cursor = index self.close() self.on_choose(self.visible[index], client) self.server.mark_dirty(self.pane.window) return True def delete(self, node, client): self.on_delete(node, client) self.server.refresh_choosers() class MplexRenderer: """Composes one client's whole screen: panes, borders, status row, prompt, overlays.""" def __init__(self, server): self.server = server def block_of(self, pane, client): mode = pane.top_mode() if mode is not None: return mode.render(client) grid = pane.screen.grid blank = MplexCanvas.BLANK block = [[cell if cell is not None else blank for cell in row] for row in grid] cursor = (pane.screen.cx, pane.screen.cy) if pane.screen.cursor_visible else None return block, cursor def frame(self, client): server = self.server canvas = MplexCanvas(client.rows, client.cols) session = client.session window = session.window() opts = window.options status_on = bool(opts.get("status")) on_top = opts.get("status-position") == "top" area_y = 1 if (status_on and on_top) else 0 cursor = None active = window.active for pane in window.visible_panes(): block, pane_cursor = self.block_of(pane, client) canvas.blit(pane.x, area_y + pane.y, block, pane.w) if pane is active and pane_cursor is not None: cursor = (pane.x + pane_cursor[0], area_y + pane.y + pane_cursor[1]) if window.zoomed is None and len(window.panes()) > 1: self.borders(canvas, window, area_y) if isinstance(client.overlay, (MplexPaneNumbers, MplexFlowScreen)): client.overlay.draw(canvas, area_y) cursor = None status_row = 0 if on_top else client.rows - 1 if status_on: self.status(canvas, client, session, window, status_row) line_row = status_row if status_on else client.rows - 1 if client.prompt is not None: text, pos = client.prompt.view() style = MplexStyle.parse(opts.get("message-style")) canvas.fill(0, line_row, client.cols, 1, style) canvas.put(0, line_row, text, style, client.cols) cursor = (min(client.cols - 1, pos), line_row) elif client.message is not None and time.monotonic() < client.message[1]: style = MplexStyle.parse(opts.get("message-style")) canvas.fill(0, line_row, client.cols, 1, style) canvas.put(0, line_row, client.message[0].replace("\n", " "), style, client.cols) canvas.cursor = cursor return canvas def borders(self, canvas, window, area_y): W, H = window.width, window.height panes = window.panes() cells = set() for pane in panes: if pane.x + pane.w < W: for row in range(pane.y, min(H, pane.y + pane.h + 1)): cells.add((pane.x + pane.w, row)) if pane.y + pane.h < H: for col in range(pane.x, min(W, pane.x + pane.w + 1)): cells.add((col, pane.y + pane.h)) active = window.active hot = set() if active is not None: for col in range(active.x - 1, active.x + active.w + 1): hot.add((col, active.y - 1)) hot.add((col, active.y + active.h)) for row in range(active.y - 1, active.y + active.h + 1): hot.add((active.x - 1, row)) hot.add((active.x + active.w, row)) hot &= cells opts = window.options lines = opts.get("pane-border-lines") plain = MplexStyle.parse(opts.get("pane-border-style")) hot_text = MplexFormat.expand(opts.get("pane-active-border-style"), self.server.resolver_for(window.session, window, active)) hot_style = MplexStyle.parse(hot_text, plain) for (x, y) in cells: style = hot_style if (x, y) in hot else plain glyph = MplexCanvas.border_char((x, y - 1) in cells, (x, y + 1) in cells, (x - 1, y) in cells, (x + 1, y) in cells, lines) canvas.put(x, area_y + y, glyph, style) def status(self, canvas, client, session, window, row): server = self.server opts = window.options base = MplexStyle.parse(opts.get("status-style")) canvas.fill(0, row, client.cols, 1, base) resolver = server.resolver_for(session, window, window.active, client) left = MplexStatusText.cells(MplexFormat.with_time(MplexFormat.expand(opts.get("status-left"), resolver)), MplexStyle.parse(opts.get("status-left-style"), base), base) right = MplexStatusText.cells(MplexFormat.with_time(MplexFormat.expand(opts.get("status-right"), resolver)), MplexStyle.parse(opts.get("status-right-style"), base), base) left = left[:opts.get("status-left-length")] right = right[:opts.get("status-right-length")] if len(left) > client.cols: left = left[:client.cols] room_right = max(0, client.cols - len(left)) right = right[-room_right:] if len(right) > room_right and room_right else right[:room_right] avail = client.cols - len(left) - len(right) entries = [] current_at = 0 total = 0 last_index = session.last separator = opts.get("window-status-separator") for index in session.indexes(): item = session.windows[index] wres = server.resolver_for(session, item, item.active, client) is_current = index == session.current fmt = item.options.get("window-status-current-format" if is_current else "window-status-format") style_name = "window-status-current-style" if is_current else ("window-status-last-style" if index == last_index else "window-status-style") style = MplexStyle.parse(item.options.get(style_name), base) cells = MplexStatusText.cells(MplexFormat.with_time(MplexFormat.expand(fmt, wres)), style, style) if entries: cells = [(char, base[0], base[1], base[2]) for char in separator] + cells if is_current: current_at = total + (len(separator) if entries else 0) entries.append(cells) total += len(cells) listing = [cell for cells in entries for cell in cells] if len(listing) > avail: listing = self.fit_list(listing, avail, current_at, base) justify = opts.get("status-justify") offset = 0 if justify in ("centre", "center"): offset = max(0, (avail - len(listing)) // 2) elif justify == "right": offset = max(0, avail - len(listing)) canvas.put_cells(0, row, left) canvas.put_cells(len(left) + offset, row, listing) canvas.put_cells(client.cols - len(right), row, right) @staticmethod def fit_list(cells, avail, current_at, base): """The slice of the window list around the current window, with < and > markers.""" if avail <= 0: return [] start = max(0, min(current_at - avail // 3, len(cells) - avail)) piece = cells[start:start + avail] marker = lambda char: (char, base[0], base[1], base[2] | 1) if start > 0 and piece: piece[0] = marker("<") if start + avail < len(cells) and piece: piece[-1] = marker(">") return piece class MplexCtx: """What a command runs against: the issuing client and its current session, window, pane.""" def __init__(self, server, client=None, pane=None, interactive=False): self.server = server self.client = client self.interactive = interactive self.depth = 0 self.pane = pane self.window = pane.window if pane is not None else None self.session = self.window.session if self.window is not None else None if client is not None and client.session is not None and pane is None: self.session = client.session self.window = self.session.window() self.pane = self.window.active if self.window is not None else None elif self.session is None and client is not None and client.env_pane is not None: found = server.panes.get(client.env_pane) if found is not None and found.window is not None: self.pane = found self.window = found.window self.session = found.window.session if self.session is None: self.session = server.latest_session() if self.session is not None: self.window = self.session.window() self.pane = self.window.active if self.window is not None else None class MplexTargets: """tmux target syntax: session, session:window, session:window.pane, %pane, @window, $session.""" @staticmethod def session(ctx, spec): server = ctx.server if not spec: if ctx.session is None: raise MplexError("no current session") return ctx.session found = None if spec.startswith("$") and spec[1:].isdigit(): found = next((s for s in server.sessions.values() if s.id == int(spec[1:])), None) else: spec = spec.lstrip("=") found = next((s for s in server.sessions.values() if s.name == spec), None) if found is None: matches = [s for s in server.sessions.values() if s.name.startswith(spec)] if len(matches) == 1: found = matches[0] if found is None: import fnmatch matches = [s for s in server.sessions.values() if fnmatch.fnmatchcase(s.name, spec)] if len(matches) == 1: found = matches[0] if found is None: raise MplexError("can't find session: %s" % spec) return found @classmethod def window(cls, ctx, spec): """Returns (session, index, window).""" server = ctx.server spec = spec or "" if spec.startswith("@") and spec[1:].isdigit(): window = server.windows.get(int(spec[1:])) if window is None: raise MplexError("can't find window: %s" % spec) return window.session, window.session.index_of(window), window if ":" in spec: sess_part, _sep, win_part = spec.partition(":") session = cls.session(ctx, sess_part) else: sess_part, win_part, session = "", spec, ctx.session if session is None: raise MplexError("no current session") index = cls.window_index(session, win_part) if index is None and ":" not in spec and win_part: try: session = cls.session(ctx, win_part) index = session.current except MplexError: index = None if index is None or index not in session.windows: raise MplexError("can't find window: %s" % (win_part or spec)) return session, index, session.windows[index] @staticmethod def window_index(session, text): indexes = session.indexes() if not indexes: return None text = text.lstrip("=") if not text.startswith("=") or text[1:].isdigit() else text if text == "": return session.current if text in ("!", "{last}"): return session.last if text in ("^", "{start}"): return indexes[0] if text in ("$", "{end}"): return indexes[-1] if text == "{next}": text = "+" if text == "{previous}": text = "-" if text[0] in "+-": step = int(text[1:]) if text[1:].isdigit() else 1 here = indexes.index(session.current) if session.current in indexes else 0 return indexes[(here + (step if text[0] == "+" else -step)) % len(indexes)] if text.isdigit(): return int(text) for index in indexes: if session.windows[index].name == text: return index matches = [index for index in indexes if session.windows[index].name.startswith(text)] if len(matches) == 1: return matches[0] import fnmatch matches = [index for index in indexes if fnmatch.fnmatchcase(session.windows[index].name, text)] return matches[0] if len(matches) == 1 else None @classmethod def pane(cls, ctx, spec): """Returns (session, window, pane).""" server = ctx.server spec = spec or "" if spec.startswith("%") and spec[1:].isdigit(): pane = server.panes.get(int(spec[1:])) if pane is None or pane.window is None: raise MplexError("can't find pane: %s" % spec) return pane.window.session, pane.window, pane if ":" in spec: sess_part, _sep, rest = spec.partition(":") if "." in rest: win_part, _dot, pane_part = rest.rpartition(".") else: win_part, pane_part = rest, "" ctx_for_window = sess_part + ":" + win_part session, _index, window = cls.window(ctx, ctx_for_window) elif "." in spec: win_part, _dot, pane_part = spec.rpartition(".") session, _index, window = cls.window(ctx, win_part) if win_part else (ctx.session, None, ctx.window) else: pane_part = spec session, window = ctx.session, ctx.window if pane_part and not (pane_part.isdigit() or pane_part[0] in "+-!{"): try: session, _index, window = cls.window(ctx, pane_part) pane_part = "" except MplexError: raise MplexError("can't find pane: %s" % spec) if window is None: raise MplexError("no current window") panes = window.panes() if not pane_part: return session, window, window.active base = window.options.get("pane-base-index") if pane_part.isdigit(): index = int(pane_part) - base if 0 <= index < len(panes): return session, window, panes[index] raise MplexError("can't find pane: %s" % pane_part) here = panes.index(window.active) if window.active in panes else 0 if pane_part in ("!", "{last}"): if window.last_pane in panes: return session, window, window.last_pane raise MplexError("can't find pane: %s" % pane_part) if pane_part[0] in "+-" or pane_part in ("{next}", "{previous}"): sign = "-" if pane_part in ("-", "{previous}") or pane_part.startswith("-") else "+" amount = int(pane_part[1:]) if pane_part[1:].isdigit() else 1 return session, window, panes[(here + (amount if sign == "+" else -amount)) % len(panes)] edges = {"{top}": lambda p: p.y == 0, "{bottom}": lambda p: p.y + p.h >= window.height, "{left}": lambda p: p.x == 0, "{right}": lambda p: p.x + p.w >= window.width, "{top-left}": lambda p: p.y == 0 and p.x == 0, "{top-right}": lambda p: p.y == 0 and p.x + p.w >= window.width, "{bottom-left}": lambda p: p.y + p.h >= window.height and p.x == 0, "{bottom-right}": lambda p: p.y + p.h >= window.height and p.x + p.w >= window.width} if pane_part in edges: for pane in panes: if edges[pane_part](pane): return session, window, pane raise MplexError("can't find pane: %s" % pane_part) class MplexClientConn: """Server-side state of one connection: framing, key table position, prompt, overlay, last frame.""" def __init__(self, server, sock, ident): self.server = server self.sock = sock self.id = ident self.inbuf = bytearray() self.out = bytearray() self.rows = 24 self.cols = 80 self.session = None self.attached = False self.table = "root" self.repeating = False self.repeat_until = 0.0 self.keybuf = b"" self.esc_deadline = None self.prompt = None self.message = None self.overlay = None self.prev = None self.cursor = None self.dirty = True self.last_render = 0.0 self.pid = 0 self.tty = "" self.term = "" self.cwd = os.getcwd() self.env = {} self.env_pane = None self.closing = False self.created = time.time() def send(self, kind, payload=b""): if len(self.out) > MPLEX_CLIENT_OUT_MAX: self.prev = None return False self.out += kind + struct.pack(">I", len(payload)) + payload return True def frames(self, data): """Complete (kind, payload) frames in what arrived; None when the stream is corrupt.""" self.inbuf += data found = [] while len(self.inbuf) >= 5: length = struct.unpack(">I", bytes(self.inbuf[1:5]))[0] if length > MPLEX_FRAME_MAX: return None if len(self.inbuf) < 5 + length: break found.append((bytes(self.inbuf[0:1]), bytes(self.inbuf[5:5 + length]))) del self.inbuf[:5 + length] return found def redispatch(self, name, raw): self.server.handle_key(self, name, raw) def flush(self): if not self.out: return try: sent = self.sock.send(bytes(self.out[:65536])) del self.out[:sent] except (BlockingIOError, InterruptedError): pass except OSError: self.out.clear() self.closing = True def close(self): try: self.sock.close() except OSError: pass class MplexServer: """Owns every session, window, pane and client. One select loop, no threads.""" def __init__(self, home): self.home = home self.dir = plexer_dir(home) self.sock_path = os.path.join(self.dir, MPLEX_SOCKET_NAME) self.config = MplexConfig.load(MplexConfig.default_path(home)) self.options = self.config.options self.sessions = {} self.windows = {} self.panes = {} self.clients = [] self.conns = [] self.buffers = [] self.messages = collections.deque(maxlen=MPLEX_MESSAGES_MAX) self.prompt_history = {} self.flows = {} self.flow_seq = 0 self.timers = [] self.reaping = [] self.marked = None self.ids = {"session": 0, "window": 0, "pane": 0, "client": 0, "buffer": 0} self.had_session = False self.running = False self.listener = None self.renderer = MplexRenderer(self) self.commands = MplexCommands(self) self.shell_cache = {} self.started = time.monotonic() self.stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S") self.next_tick = 0.0 self.notice = None for problem in self.config.errors: self.log_message("mplex.toml: " + problem) if self.config.skipped: self.notice = "mplex.toml: %d problem(s) skipped, see show-messages" % self.config.skipped # ---- bookkeeping ----------------------------------------------------- def next_id(self, kind): value = self.ids[kind] self.ids[kind] += 1 return value def log_message(self, text): self.messages.append((time.time(), text)) def latest_session(self): if not self.sessions: return None return max(self.sessions.values(), key=lambda item: (item.last_attached, item.id)) def attached_clients(self, session=None): return [c for c in self.clients if c.attached and (session is None or c.session is session)] def pane_options(self, pane): return pane.window.options if pane.window is not None else self.options def message(self, client, text, milliseconds=None): self.log_message(text) if client is None: return if milliseconds is None: milliseconds = self.options.get("display-time") if client.session is None else client.session.options.get("display-time") client.message = (text, time.monotonic() + milliseconds / 1000.0) client.dirty = True self.add_timer(client.message[1] + 0.01, lambda: setattr(client, "dirty", True)) def mark_dirty(self, window=None): for client in self.clients: if client.attached and (window is None or client.session.window() is window): client.dirty = True def mark_all_dirty(self): for client in self.clients: client.dirty = True def add_timer(self, deadline, callback): if len(self.timers) < 1024: self.timers.append((deadline, callback)) # ---- buffers and copy ------------------------------------------------ def add_buffer(self, text, name=None): if name is None: name = "buffer%d" % self.next_id("buffer") self.buffers = [item for item in self.buffers if item["name"] != name] self.buffers.insert(0, {"name": name, "data": text[:MPLEX_PASTE_MAX]}) del self.buffers[self.options.get("buffer-limit"):] return name def top_buffer_text(self): return self.buffers[0]["data"] if self.buffers else "" def find_buffer(self, name): if not name: if not self.buffers: raise MplexError("no buffers") return self.buffers[0] for item in self.buffers: if item["name"] == name: return item raise MplexError("unknown buffer: %s" % name) def run_shell_input(self, command, text): try: done = subprocess.run(command, shell=True, input=text.encode("utf-8", "replace"), stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=MPLEX_SHELL_TIMEOUT) if done.returncode != 0: raise MplexError("copy command failed: " + done.stderr.decode("utf-8", "replace").strip()[:200]) except (OSError, subprocess.SubprocessError) as exc: raise MplexError("copy command failed: " + short_error(exc)) def store_copy(self, text, command="", append=False): if append and self.buffers: self.buffers[0]["data"] = (self.buffers[0]["data"] + text)[:MPLEX_PASTE_MAX] text = self.buffers[0]["data"] else: self.add_buffer(text) pipe = command or self.options.get("copy-command") if pipe: self.run_shell_input(pipe, text) if self.options.get("set-clipboard") != "off": sequence = "\x1b]52;c;" + base64.b64encode(text.encode("utf-8")).decode("ascii") + "\x07" for client in self.attached_clients(): client.send(b"O", sequence.encode("ascii")) # ---- sizing ---------------------------------------------------------- def resize_session(self, session): sizes = [(c.cols, c.rows) for c in self.attached_clients(session)] if sizes: session.size = (min(item[0] for item in sizes), min(item[1] for item in sizes)) elif not hasattr(session, "size"): session.size = (80, 24) cols, rows = session.size for window in session.windows.values(): window.width = max(1, cols) window.height = max(1, rows - (1 if window.options.get("status") else 0)) window.relayout() self.mark_dirty() def resize_all(self): for session in self.sessions.values(): self.resize_session(session) # ---- model: panes, windows, sessions --------------------------------- def default_shell(self, options): return options.get("default-shell") or os.environ.get("SHELL") or "/bin/sh" def resolve_term(self, name): wanted = [name, "screen-256color", "xterm-256color", "xterm", "vt100"] roots = [os.environ.get("TERMINFO", ""), os.path.expanduser("~/.terminfo"), "/etc/terminfo", "/lib/terminfo", "/usr/share/terminfo", "/usr/lib/terminfo"] for term in wanted: for root in roots: if root and (os.path.exists(os.path.join(root, term[0], term)) or os.path.exists(os.path.join(root, "%02x" % ord(term[0]), term))): return term return "xterm" def spawn_pane(self, window, width, height, words, cwd, env_extra=None): if len(self.panes) >= MPLEX_MAX_PANES: raise MplexError("too many panes (limit %d)" % MPLEX_MAX_PANES) session = window.session options = window.options pane = MplexPane(self.next_id("pane"), width, height, options.get("history-limit"), self.dir, self.stamp) shell = self.default_shell(options) if words: file = shell if len(words) == 1 else words[0] argv = [shell, "-c", words[0]] if len(words) == 1 else list(words) elif options.get("default-command"): file, argv = shell, [shell, "-c", options.get("default-command")] else: file, argv = shell, ["-" + os.path.basename(shell)] env = dict(session.env) env.pop("LINES", None) env.pop("COLUMNS", None) env["TERM"] = self.resolve_term(options.get("default-terminal")) env[PLEXER_ACTIVE_ENV] = "1" env[PLEXER_SOCKET_ENV] = self.sock_path env[PLEXER_PANE_ENV] = str(pane.id) env.update(env_extra or {}) pane.window = window pane.spawn(file, argv, cwd or session.cwd, env) self.panes[pane.id] = pane return pane def register_window(self, session, window, index, select): window.session = session window.options.parent = session.options session.windows[index] = window self.windows[window.id] = window if select or session.current is None: session.select(index) def create_window(self, session, name, cwd, words, env_extra, index, select): if len(self.windows) >= MPLEX_MAX_WINDOWS: raise MplexError("too many windows (limit %d)" % MPLEX_MAX_WINDOWS) cols, rows = getattr(session, "size", (80, 24)) window = MplexWindow(self.next_id("window"), name or "", max(1, cols), max(1, rows - (1 if self.options.get("status") else 0)), session.options) window.session = session pane = self.spawn_pane(window, window.width, window.height, words, cwd, env_extra) window.root = MplexLayoutNode("pane", pane=pane, w=window.width, h=window.height) window.active = pane pane.last_active = time.monotonic() window.auto_rename = not name if not name: window.name = pane.shell_name if not words else os.path.basename(words[0].split()[0] if len(words) == 1 else words[0]) self.register_window(session, window, index, select) window.relayout() self.mark_dirty() return window def create_session(self, name, cwd, env, words, window_name, size, index_hint=None): if len(self.sessions) >= MPLEX_MAX_SESSIONS: raise MplexError("too many sessions (limit %d)" % MPLEX_MAX_SESSIONS) session = MplexSession(self.next_id("session"), name, self.options, cwd, env) session.size = size self.sessions[session.id] = session self.had_session = True try: self.create_window(session, window_name, cwd, words, None, self.options.get("base-index"), True) except Exception: self.sessions.pop(session.id, None) raise return session def auto_session_name(self): taken = {s.name for s in self.sessions.values()} number = 0 while str(number) in taken: number += 1 return str(number) def split_pane(self, window, target, kind, before, words, cwd, env_extra, size, select): leaf = window.leaf_of(target) if leaf is None: raise MplexError("pane is not in this window") if window.zoomed is not None: window.zoomed = None window.relayout() leaf = window.leaf_of(target) span = leaf.w if kind == "lr" else leaf.h if span < 3: raise MplexError("no space for new pane") pane = self.spawn_pane(window, 1, 1, words, cwd, env_extra) try: window.root, fresh = MplexLayout.split(window.root, leaf, kind, before, pane, size) except MplexError: self.discard_pane(pane) raise window.layout_index = -1 window.relayout() if select: window.set_active(pane) self.mark_dirty(window) return pane def discard_pane(self, pane): pane.close() self.panes.pop(pane.id, None) if pane.pid is not None: self.reaping.append((pane.pid, time.monotonic())) pane.pid = None def select_pane(self, window, pane): if window.zoomed is not None and pane is not window.zoomed: window.zoomed = None window.relayout() window.set_active(pane) self.mark_dirty(window) def kill_pane(self, pane): window = pane.window if window is None: return if len(window.panes()) == 1: self.kill_window(window) return was_active = window.active is pane window.root = MplexLayout.remove(window.root, window.leaf_of(pane)) if window.zoomed is pane: window.zoomed = None if self.marked is pane: self.marked = None self.discard_pane(pane) rest = window.panes() if window.last_pane is pane: window.last_pane = None if was_active: window.active = window.last_pane if window.last_pane in rest else max(rest, key=lambda item: item.last_active) window.last_pane = None window.layout_index = -1 window.relayout() self.mark_dirty(window) def kill_window(self, window): session = window.session index = session.index_of(window) for pane in window.panes(): if self.marked is pane: self.marked = None self.discard_pane(pane) self.windows.pop(window.id, None) if index is not None: session.windows.pop(index, None) if not session.windows: self.kill_session(session) return session.history = [item for item in session.history if item is not window] if session.current == index: nxt = session.last if nxt is None: lower = [i for i in session.indexes() if i < index] nxt = lower[-1] if lower else session.indexes()[-1] session.current = nxt session.history = [item for item in session.history if item is not session.windows[nxt]] if session.options.get("renumber-windows"): self.renumber(session) self.mark_dirty() def renumber(self, session): base = self.options.get("base-index") current = session.window() ordered = [session.windows[i] for i in session.indexes()] session.windows = {base + n: window for n, window in enumerate(ordered)} session.current = session.index_of(current) if current else None def kill_session(self, session): for window in list(session.windows.values()): for pane in window.panes(): if self.marked is pane: self.marked = None self.discard_pane(pane) self.windows.pop(window.id, None) session.windows.clear() self.sessions.pop(session.id, None) for client in list(self.attached_clients(session)): others = [s for s in self.sessions.values()] if others and not self.options.get("detach-on-destroy"): self.attach_client(client, self.latest_session()) else: self.detach_client(client, "[exited]", b"X") self.mark_dirty() def attach_client(self, client, session): old = client.session client.session = session session.last_attached = time.time() client.table = "root" client.prompt = None client.overlay = None client.prev = None if not client.attached: client.attached = True client.send(b"A") if old is not None and old is not session: self.resize_session(old) self.resize_session(session) client.dirty = True if self.notice: self.message(client, self.notice, 4000) self.notice = None def detach_client(self, client, text="", kind=b"D"): old = client.session client.send(kind, text.encode("utf-8")) client.attached = False client.session = None client.closing = True if old is not None and old.id in self.sessions: self.resize_session(old) # ---- formats --------------------------------------------------------- def window_flags(self, session, window): flags = "" index = session.index_of(window) if index == session.current: flags += "*" elif index == session.last: flags += "-" if window.zoomed is not None: flags += "Z" if self.marked is not None and self.marked.window is window: flags += "M" return flags def resolver_for(self, session, window, pane, client=None): server = self def get(name): if name.startswith("("): return server.shell_output(name[1:]) return server.variable(name, session, window, pane, client) return get def shell_output(self, command): now = time.monotonic() cached = self.shell_cache.get(command) interval = max(1, self.options.get("status-interval")) if cached and now - cached[0] < interval: return cached[1] try: done = subprocess.run(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=MPLEX_SHELL_TIMEOUT) text = done.stdout[:MPLEX_SHELL_OUTPUT_MAX].decode("utf-8", "replace").strip().split("\n")[0] except (OSError, subprocess.SubprocessError): text = "" if len(self.shell_cache) > 64: self.shell_cache.clear() self.shell_cache[command] = (now, text) return text def variable(self, name, session, window, pane, client): if name in MplexOptions.DEFS: scope = window.options if window is not None else (session.options if session is not None else self.options) value = scope.get(name) if isinstance(value, bool): return "1" if value else "0" return str(value) if name == "host": return socket.gethostname() if name == "host_short": return socket.gethostname().split(".")[0] if name == "version": return VERSION if session is not None: if name == "session_name": return session.name if name == "session_id": return session.tag if name == "session_windows": return str(len(session.windows)) if name == "session_created": return str(int(session.created)) if name == "session_attached": return str(len(self.attached_clients(session))) if window is not None and session is not None: index = session.index_of(window) if name == "window_index": return str(index) if name == "window_name": return window.name if name == "window_id": return window.tag if name == "window_flags": return self.window_flags(session, window) if name == "window_active": return "1" if index == session.current else "0" if name == "window_last_flag": return "1" if index == session.last else "0" if name == "window_panes": return str(len(window.panes())) if name == "window_zoomed_flag": return "1" if window.zoomed is not None else "0" if name == "window_width": return str(window.width) if name == "window_height": return str(window.height) if name in ("window_activity_flag", "window_bell_flag", "window_silence_flag", "window_bigger"): return "0" if name == "window_marked_flag": return "1" if self.marked is not None and self.marked.window is window else "0" if pane is not None: mode = pane.top_mode() screen = pane.screen if name == "pane_id": return pane.tag if name == "pane_index": return str(pane.window.panes().index(pane) + pane.window.options.get("pane-base-index")) if pane.window else "0" if name == "pane_title": return pane.title if name == "pane_current_command": return pane.foreground_command() if name == "pane_current_path": return pane.current_path() if name == "pane_pid": return str(pane.pid or "") if name == "pane_width": return str(pane.w) if name == "pane_height": return str(pane.h) if name == "pane_active": return "1" if pane.window is not None and pane.window.active is pane else "0" if name == "pane_last": return "1" if pane.window is not None and pane.window.last_pane is pane else "0" if name == "pane_in_mode": return "1" if mode is not None else "0" if name == "pane_mode": return mode.name if mode is not None else "" if name == "pane_dead": return "1" if pane.dead else "0" if name == "pane_dead_status": return "" if pane.exit_status is None else str(pane.exit_status) if name == "pane_left": return str(pane.x) if name == "pane_top": return str(pane.y) if name == "pane_right": return str(pane.x + pane.w - 1) if name == "pane_bottom": return str(pane.y + pane.h - 1) if name == "pane_synchronized": return "1" if pane.window is not None and pane.window.options.get("synchronize-panes") else "0" if name == "pane_marked": return "1" if self.marked is pane else "0" if name == "pane_marked_set": return "1" if self.marked is not None else "0" if name == "alternate_on": return "1" if screen.alt_active else "0" if name == "history_size": return str(pane.history_size()) if name == "history_limit": return str(screen.history.maxlen if screen.history is not None else 0) if name == "cursor_x": return str(screen.cx) if name == "cursor_y": return str(screen.cy) if name == "pane_search_string": return mode.search[0] if isinstance(mode, MplexCopyMode) and mode.search else "" if isinstance(mode, MplexCopyMode): if name == "scroll_position": return str(mode.oy) if name == "copy_cursor_x": return str(mode.cx) if name == "copy_cursor_y": return str(mode.cy) if name == "selection_present": return "1" if mode.anchor is not None else "0" if name == "copy_cursor_line": return mode.text(mode.abs_y()) if name == "copy_cursor_word": line = mode.text(mode.abs_y()) if not line: return "" x = min(mode.cx, len(line) - 1) seps = pane.window.options.get("word-separators") + " " start = end = x while start > 0 and line[start - 1] not in seps: start -= 1 while end + 1 < len(line) and line[end + 1] not in seps: end += 1 return line[start:end + 1] if name == "scroll_position": return "" if client is not None: if name == "client_width": return str(client.cols) if name == "client_height": return str(client.rows) if name == "client_session": return client.session.name if client.session else "" if name == "client_tty": return client.tty if name == "client_termname": return client.term if name == "client_pid": return str(client.pid) if name == "client_key_table": return client.table if name == "client_prefix": return "1" if client.table == "prefix" else "0" if name == "client_created": return str(int(client.created)) return None def expand(self, text, ctx, extra=None): """Format-expand text for a command context (strftime applied).""" base = self.resolver_for(ctx.session, ctx.window, ctx.pane, ctx.client) if extra: def resolve(name): return extra[name] if name in extra else base(name) else: resolve = base return MplexFormat.with_time(MplexFormat.expand(text, resolve)) # ---- key handling ---------------------------------------------------- def is_prefix(self, session, name): opts = session.options return name in (opts.get("prefix"), opts.get("prefix2")) and name != "None" def input_bytes(self, client, data): client.keybuf = (client.keybuf + data)[-65536:] self.process_keys(client, False) def process_keys(self, client, final): tokens, rest = MplexKeys.split(client.keybuf, final) client.keybuf = rest for name, raw in tokens: if not client.attached: break self.handle_key(client, name, raw) if client.keybuf and client.attached: client.esc_deadline = time.monotonic() + client.session.options.get("escape-time") / 1000.0 else: client.esc_deadline = None def handle_key(self, client, name, raw): if not client.attached: return client.dirty = True if client.prompt is not None: client.prompt.key(self, client, name, raw) return if client.overlay is not None: client.overlay.key(name, raw, client) return client.message = None session = client.session window = session.window() pane = window.active if not name: self.forward(pane, window, raw) return table = client.table if table == "root": binding = self.config.lookup("root", name) if binding is not None: self.run_binding(client, binding, pane) return if self.is_prefix(session, name): client.table = "prefix" client.repeating = False return mode = pane.top_mode() if mode is not None: mode.key(name, raw, client) return self.forward(pane, window, raw) return binding = self.config.lookup(table, name) if client.repeating and not (binding is not None and binding.repeat): client.table = "root" client.repeating = False self.handle_key(client, name, raw) return client.table = "root" client.repeating = False if binding is not None: self.run_binding(client, binding, pane) if binding.repeat and client.attached and client.table == "root": client.table = table client.repeating = True client.repeat_until = time.monotonic() + client.session.options.get("repeat-time") / 1000.0 def forward(self, pane, window, raw): data = MplexKeys.app_cursor_form(raw) if pane.screen.app_cursor else raw targets = window.panes() if window.options.get("synchronize-panes") else [pane] for target in targets: if not target.dead: target.write(data) target.input_bytes += len(data) def run_binding(self, client, binding, pane=None): self.run_checked(MplexCtx(self, client=client, pane=pane, interactive=True), binding.command) def run_checked(self, ctx, text): try: output = self.run_line(ctx, text) if output: self.show_output(ctx, output) except MplexError as exc: self.message(ctx.client, str(exc)) except Exception as exc: # noqa: BLE001 -- a bad command must never take the server down import traceback sys.stderr.write(traceback.format_exc()) self.message(ctx.client, "internal error: " + short_error(exc)) def show_output(self, ctx, text): pane = ctx.pane lines = text.rstrip("\n").split("\n") if pane is None or (len(lines) == 1 and len(lines[0]) < 200): self.message(ctx.client, lines[0] if lines else "") return old = pane.find_mode(MplexCopyMode) if old is not None and old.static_rows is not None: old.close() pane.push_mode(MplexCopyMode(self, pane, lines=lines)) self.mark_dirty(pane.window) def ask_confirm(self, client, text, callback): def done(value): callback() client.prompt = MplexPrompt(text + " ", "", done, confirm=True) client.dirty = True def refresh_choosers(self): for pane in list(self.panes.values()): for mode in list(pane.modes): if isinstance(mode, MplexChooser): mode.rebuild() self.mark_dirty() # ---- command execution ----------------------------------------------- def run_line(self, ctx, text, depth=0): return self.run_commands(ctx, MplexCommandParser.parse(text), depth) def run_commands(self, ctx, commands, depth=0): if depth > MPLEX_COMMAND_DEPTH_MAX: raise MplexError("commands nested too deeply") flat = [] for words in commands: piece = [] for word in words: if word == ";": if piece: flat.append(piece) piece = [] else: piece.append(word) if piece: flat.append(piece) output = [] for words in flat: text = self.run_one(ctx, words, depth) if text: output.append(text) return "\n".join(output) def run_words(self, ctx, words): return self.run_commands(ctx, [words], 0) def run_one(self, ctx, words, depth): name = MplexCommands.resolve(words[0]) aliases, spec, low, high = MPLEX_COMMAND_SPECS[name] args = MplexArgs.parse(name, spec, words[1:], low, high) ctx.depth = depth handler = getattr(self.commands, "cmd_" + name.replace("-", "_")) return handler(ctx, args) or "" # ---- sockets --------------------------------------------------------- def open_listener(self): lock_fd = os.open(os.path.join(self.dir, MPLEX_LOCK_NAME), os.O_RDWR | os.O_CREAT, 0o600) try: fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: os.close(lock_fd) return None self.lock_fd = lock_fd try: os.unlink(self.sock_path) except OSError: pass listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) listener.bind(self.sock_path) os.chmod(self.sock_path, 0o600) listener.listen(16) listener.setblocking(False) return listener def accept(self): try: sock, _addr = self.listener.accept() except OSError: return sock.setblocking(False) if len(self.conns) + len(self.clients) >= MPLEX_MAX_CLIENTS * 2: sock.close() return self.conns.append(MplexPending(sock)) def pending_read(self, pending): try: data = pending.sock.recv(65536) except (BlockingIOError, InterruptedError): return except OSError: data = b"" if not data: self.drop_pending(pending) return pending.buf += data if pending.buf[:1] == MPLEX_MAGIC: if len(self.clients) >= MPLEX_MAX_CLIENTS: self.drop_pending(pending) return self.conns.remove(pending) client = MplexClientConn(self, pending.sock, self.next_id("client")) self.clients.append(client) self.client_data(client, bytes(pending.buf[1:])) return if b"\n" in pending.buf or len(pending.buf) > PLEXER_RECV_MAX: self.control(pending) def drop_pending(self, pending): if pending in self.conns: self.conns.remove(pending) try: pending.sock.close() except OSError: pass def control(self, pending): """Legacy JSON line protocol: send / read / status against a pane (see NativePlexerController).""" try: request = json.loads(bytes(pending.buf).decode("utf-8", "replace")) if not isinstance(request, dict): raise ValueError("request must be a JSON object") reply = self.dispatch_control(request) except Exception as exc: # noqa: BLE001 -- one bad request must not end the session reply = {"ok": False, "error": short_error(exc)} try: pending.sock.setblocking(True) pending.sock.settimeout(2) pending.sock.sendall((json.dumps(reply) + "\n").encode("utf-8")) except OSError: pass self.drop_pending(pending) def control_pane(self, request): ident = request.get("pane") if ident not in (None, ""): pane = self.panes.get(int(str(ident).lstrip("%"))) if pane is None: raise ValueError("no such pane: %s" % ident) return pane session = self.latest_session() if session is None or session.window() is None: raise ValueError("no session") return session.window().active def dispatch_control(self, request): cmd = request.get("cmd") if cmd == "send": pane = self.control_pane(request) text = str(request.get("text") or "") pane.write(text.encode("utf-8", "replace")) if request.get("enter"): pane.write(b"\r") return {"ok": True, "data": True} if cmd == "read": pane = self.control_pane(request) return {"ok": True, "data": pane.tail_log(max(1, min(int(request.get("lines") or 200), 5000)))} if cmd == "status": pane = self.control_pane(request) window = pane.window return {"ok": True, "data": {"backend": "native", "pid": pane.pid, "log": pane.log_path, "socket": self.sock_path, "pane": pane.tag, "session": window.session.name, "window": window.session.index_of(window)}} if cmd == "show": return self.flow_show(request) if cmd == "result": return self.flow_result(request) if cmd == "cancel": return self.flow_cancel(request) return {"ok": False, "error": "unknown command %r" % cmd} def flow_prune(self, now): """Reclaim flow records whose driver died: answered ones settle, unanswered ones clear their dialog first. Runs on every show and every tick.""" for ident in list(self.flows): found = self.flows[ident] settled = found["settled"] if found["answer"] is not None else None if settled is not None and now - settled > MPLEX_FLOW_KEEP: del self.flows[ident] elif settled is None and now > found["until"] + MPLEX_FLOW_KEEP: self.flow_ui_clear(found) del self.flows[ident] def flow_ui_clear(self, found): client = found["client"] if found.get("overlay") is not None and client.overlay is found["overlay"]: client.overlay = None if found.get("prompt") is not None and client.prompt is found["prompt"]: client.prompt = None client.dirty = True def flow_client(self, pane): window = getattr(pane, "window", None) session = window.session if window is not None else self.latest_session() if session is None: raise ValueError("no session") clients = self.attached_clients(session) if not clients: raise ValueError("no attached client for this pane's session") return clients[0] def flow_check_screen(self, raw): """Validate one show screen straight off the socket; never trust it.""" if not isinstance(raw, dict): raise ValueError("screen must be an object") kind = raw.get("kind") if kind not in PLEXER_FLOW_KINDS: raise ValueError("screen kind must be one of %s" % "/".join(PLEXER_FLOW_KINDS)) title = raw.get("title", "") text = raw.get("text", "") if not isinstance(title, str) or not isinstance(text, str): raise ValueError("screen title and text must be strings") if len(title) > PLEXER_FLOW_TITLE_MAX or len(text) > PLEXER_FLOW_TEXT_MAX: raise ValueError("screen title/text too long") options = [] if kind == "choice": listed = raw.get("options") if not isinstance(listed, list) or not listed or len(listed) > PLEXER_FLOW_OPTIONS_MAX: raise ValueError("choice needs 1 to %d options" % PLEXER_FLOW_OPTIONS_MAX) for label in listed: if not isinstance(label, str) or not label.strip() or len(label) > PLEXER_FLOW_LABEL_MAX: raise ValueError("choice options must be non-empty strings") options.append(label) try: default = int(raw.get("default", 0)) except (TypeError, ValueError): raise ValueError("choice default must be an option index") if not 0 <= default < len(options): raise ValueError("choice default is outside its options") else: default = 0 initial = raw.get("initial", "") if not isinstance(initial, str) or len(initial) > PLEXER_FLOW_ANSWER_MAX: raise ValueError("initial text too long") return {"kind": kind, "title": title, "text": text, "options": options, "default": default, "initial": initial} def flow_show(self, request): pane = self.control_pane(request) screen = self.flow_check_screen(request.get("screen")) try: timeout = float(request.get("timeout", 60)) except (TypeError, ValueError): raise ValueError("timeout must be a number of seconds") timeout = max(1.0, min(timeout, PLEXER_FLOW_TIMEOUT_MAX)) now = time.monotonic() self.flow_prune(now) if len(self.flows) >= MPLEX_FLOW_MAX: raise ValueError("too many pending flows (%d)" % MPLEX_FLOW_MAX) client = self.flow_client(pane) if client.overlay is not None or client.prompt is not None: raise ValueError("client is busy (a prompt or overlay is already open)") flow_id = self.flow_seq self.flow_seq += 1 found = {"id": flow_id, "client": client, "kind": screen["kind"], "until": now + timeout, "started": now, "settled": None, "answer": None, "overlay": None, "prompt": None} if screen["kind"] == "text": label = ((screen["title"] + ": ") if screen["title"] else "") + screen["text"].replace("\n", " ")[:200] + " " prompt = MplexPrompt(label, screen["initial"], lambda value: self.flow_answered(flow_id, "ok", value[:PLEXER_FLOW_ANSWER_MAX]), on_cancel=lambda: self.flow_answered(flow_id, "cancelled")) found["prompt"] = prompt client.prompt = prompt else: overlay = MplexFlowScreen(self, flow_id, screen["title"], screen["text"], screen["options"], screen["default"], now + timeout) found["overlay"] = overlay client.overlay = overlay self.flows[flow_id] = found client.dirty = True return {"ok": True, "data": {"id": flow_id}} def flow_answered(self, flow_id, status, value=None): found = self.flows.get(flow_id) if found is not None and found["answer"] is None: found["answer"] = {"status": status, "value": value} found["settled"] = time.monotonic() def flow_result(self, request): try: flow_id = int(request.get("id")) except (TypeError, ValueError): raise ValueError("result needs a flow id") found = self.flows.get(flow_id) if found is None: raise ValueError("unknown flow %r" % (request.get("id"),)) client = found["client"] if client not in self.clients or not client.attached: del self.flows[flow_id] return {"ok": True, "data": {"done": True, "status": "detached"}} if found["answer"] is not None: done = found["answer"] self.flow_ui_clear(found) del self.flows[flow_id] return {"ok": True, "data": {"done": True, "status": done["status"], "value": done["value"]}} if time.monotonic() >= found["until"]: self.flow_ui_clear(found) del self.flows[flow_id] return {"ok": True, "data": {"done": True, "status": "timeout"}} return {"ok": True, "data": {"done": False}} def flow_cancel(self, request): try: flow_id = int(request.get("id")) except (TypeError, ValueError): raise ValueError("cancel needs a flow id") found = self.flows.get(flow_id) if found is None: raise ValueError("unknown flow %r" % (request.get("id"),)) self.flow_ui_clear(found) del self.flows[flow_id] return {"ok": True, "data": True} def client_read(self, client): try: data = client.sock.recv(65536) except (BlockingIOError, InterruptedError): return except OSError: data = b"" if not data: self.drop_client(client) return self.client_data(client, data) def client_data(self, client, data): frames = client.frames(data) if frames is None: self.drop_client(client) return for kind, payload in frames: if kind == b"H": self.on_hello(client, payload) elif kind == b"I" and client.attached: self.input_bytes(client, payload) elif kind == b"R": try: size = json.loads(payload.decode("utf-8", "replace")) client.rows = max(2, min(1000, int(size["rows"]))) client.cols = max(2, min(2000, int(size["cols"]))) except (ValueError, KeyError, TypeError): continue client.prev = None if client.attached: self.resize_session(client.session) client.dirty = True def on_hello(self, client, payload): try: info = json.loads(payload.decode("utf-8", "replace")) if not isinstance(info, dict): raise ValueError("hello must be an object") client.rows = max(2, min(1000, int(info.get("rows") or 24))) client.cols = max(2, min(2000, int(info.get("cols") or 80))) client.term = str(info.get("term") or "")[:64] client.tty = str(info.get("tty") or "")[:128] client.cwd = str(info.get("cwd") or "/")[:4096] client.pid = int(info.get("pid") or 0) env = info.get("env") or {} client.env = {str(k)[:256]: str(v)[:8192] for k, v in list(env.items())[:512]} if isinstance(env, dict) else {} pane = info.get("pane") client.env_pane = int(pane) if pane not in (None, "") else None commands = [[str(word) for word in item][:256] for item in (info.get("commands") or [["new-session"]])[:64] if item] except (ValueError, TypeError) as exc: client.send(b"E", ("bad hello: " + short_error(exc)).encode("utf-8")) client.closing = True return ctx = MplexCtx(self, client=client) try: output = "\n".join(text for text in (self.run_one(ctx, words, 0) for words in commands) if text) except MplexError as exc: client.send(b"E", str(exc).encode("utf-8")) client.closing = True return except Exception as exc: # noqa: BLE001 import traceback sys.stderr.write(traceback.format_exc()) client.send(b"E", ("internal error: " + short_error(exc)).encode("utf-8")) client.closing = True return if not client.attached: if output: client.send(b"M", output.encode("utf-8")) client.send(b"X", b"") client.closing = True def drop_client(self, client): if client in self.clients: self.clients.remove(client) session = client.session client.attached = False client.session = None client.close() if session is not None and session.id in self.sessions: self.resize_session(session) # ---- main loop ------------------------------------------------------- def serve(self): self.listener = self.open_listener() if self.listener is None: return 1 signal.signal(signal.SIGTERM, lambda _s, _f: setattr(self, "running", False)) signal.signal(signal.SIGHUP, signal.SIG_IGN) signal.signal(signal.SIGINT, signal.SIG_IGN) self.running = True try: self.loop() finally: self.shutdown() return 0 def stop(self): self.running = False def loop(self): """Run until stopped. One failing iteration is logged and survived, because it would otherwise end every pane; MPLEX_LOOP_ERRORS_MAX failures in a row end the server.""" failures = 0 while self.running: try: self.iterate() failures = 0 except Exception: # noqa: BLE001 -- see docstring import traceback sys.stderr.write(traceback.format_exc()) sys.stderr.flush() failures += 1 if failures >= MPLEX_LOOP_ERRORS_MAX: sys.stderr.write("mplex server: %d failed iterations in a row, exiting\n" % failures) self.running = False time.sleep(0.05) def iterate(self): now = time.monotonic() self.run_deadlines(now) fd_map = {self.listener.fileno(): ("listen", None)} for pending in self.conns: fd_map[pending.sock.fileno()] = ("pending", pending) for client in self.clients: fd_map[client.sock.fileno()] = ("client", client) for pane in self.panes.values(): if pane.master_fd is not None: fd_map[pane.master_fd] = ("pane", pane) writers = {} for client in self.clients: if client.out: writers[client.sock.fileno()] = client for pane in self.panes.values(): if pane.pending_in and pane.master_fd is not None: writers[pane.master_fd] = pane try: ready, can_write, _ = select.select(list(fd_map), list(writers), [], self.wakeup(now)) except InterruptedError: return except (OSError, ValueError): self.sweep_bad_fds() return for fd in ready: kind, obj = fd_map[fd] if kind == "listen": self.accept() elif kind == "pending": self.pending_read(obj) elif kind == "client": self.client_read(obj) elif kind == "pane": self.pane_read(obj) for fd in can_write: target = writers[fd] if isinstance(target, MplexPane): target.drain() else: target.flush() now = time.monotonic() self.reap_children(now) self.render_clients(now) self.close_finished() if not self.sessions and (self.had_session or now - self.started > MPLEX_IDLE_EXIT) and not self.clients_busy(): self.running = False def clients_busy(self): return any(client.out for client in self.clients) def sweep_bad_fds(self): for pending in list(self.conns): if pending.sock.fileno() < 0: self.conns.remove(pending) for client in list(self.clients): if client.sock.fileno() < 0: self.drop_client(client) for pane in list(self.panes.values()): if pane.master_fd is not None: try: os.fstat(pane.master_fd) except OSError: self.pane_exited(pane) def wakeup(self, now): due = [now + 1.0] due.extend(deadline for deadline, _cb in self.timers) for client in self.clients: if client.esc_deadline: due.append(client.esc_deadline) if client.repeating: due.append(client.repeat_until) if client.dirty and client.attached and len(client.out) <= MPLEX_CLIENT_OUT_MAX // 4: due.append(client.last_render + MPLEX_RENDER_INTERVAL) if client.overlay is not None and hasattr(client.overlay, "until"): due.append(client.overlay.until) return max(0.0, min(due) - now) def run_deadlines(self, now): if self.timers: ripe = [item for item in self.timers if item[0] <= now] self.timers = [item for item in self.timers if item[0] > now] for _deadline, callback in ripe: callback() for client in list(self.clients): if client.esc_deadline and now >= client.esc_deadline and client.attached: client.esc_deadline = None self.process_keys(client, True) if client.repeating and now >= client.repeat_until: client.repeating = False client.table = "root" client.dirty = True overlay = client.overlay if overlay is not None and hasattr(overlay, "until") and now >= overlay.until: client.overlay = None client.dirty = True if now >= self.next_tick: self.next_tick = now + 1.0 self.tick(now) for pending in list(self.conns): if now - pending.created > 5.0: self.drop_pending(pending) def tick(self, now): if self.flows: self.flow_prune(now) for window in list(self.windows.values()): if window.auto_rename and window.active is not None and window.session is not None: template = window.options.get("automatic-rename-format") if window.options.get("automatic-rename"): resolver = self.resolver_for(window.session, window, window.active) name = MplexFormat.expand(template, resolver) if name and name != window.name: window.name = name[:64] self.mark_all_dirty() for pane in window.panes(): if isinstance(pane.top_mode(), MplexClockMode): self.mark_dirty(window) interval = self.options.get("status-interval") if interval and int(now) % interval == 0: self.mark_all_dirty() def pane_read(self, pane): try: data = os.read(pane.master_fd, 65536) except (BlockingIOError, InterruptedError): return except OSError: data = b"" if not data: self.pane_exited(pane) return pane.feed(data) self.mark_dirty(pane.window) def pane_exited(self, pane): status = None if pane.pid is not None: try: done, raw = os.waitpid(pane.pid, os.WNOHANG) if done: status = os.waitstatus_to_exitcode(raw) if hasattr(os, "waitstatus_to_exitcode") else raw pane.pid = None except OSError: pane.pid = None if pane.window is not None and pane.window.options.get("remain-on-exit") and not pane.dead: pane.dead = True pane.exit_status = status pane.screen.feed(("\r\nPane is dead (status %s)\r\n" % status).encode("utf-8")) if pane.master_fd is not None: try: os.close(pane.master_fd) except OSError: pass pane.master_fd = None self.mark_dirty(pane.window) return if pane.window is not None and pane.id in self.panes: self.kill_pane(pane) def reap_children(self, now): keep = [] for pid, since in self.reaping: try: done, _status = os.waitpid(pid, os.WNOHANG) except OSError: continue if done: continue if now - since > MPLEX_REAP_KILL_AFTER: try: os.killpg(pid, signal.SIGKILL) except OSError: try: os.kill(pid, signal.SIGKILL) except OSError: pass keep.append((pid, since)) self.reaping = keep def render_clients(self, now): for client in self.clients: if not client.attached or not client.dirty: continue if now - client.last_render < MPLEX_RENDER_INTERVAL: continue if len(client.out) > MPLEX_CLIENT_OUT_MAX // 4: continue canvas = self.renderer.frame(client) data, snapshot = canvas.encode(client.prev) client.last_render = now client.dirty = False if client.prev is not None and snapshot == client.prev and canvas.cursor == client.cursor: continue client.prev = snapshot client.cursor = canvas.cursor client.send(b"O", data) def close_finished(self): for client in list(self.clients): if client.closing and not client.out: self.drop_client(client) def shutdown(self): for client in list(self.clients): if client.attached: client.send(b"X", b"[server exited]") for _try in range(20): if not client.out: break client.flush() if client.out: time.sleep(0.005) client.close() self.clients = [] for pane in list(self.panes.values()): pane.close(hard=True) if pane.pid is not None: try: os.waitpid(pane.pid, os.WNOHANG) except OSError: pass for item in (self.listener,): try: item.close() except (OSError, AttributeError): pass try: os.unlink(self.sock_path) except OSError: pass @staticmethod def probe(path): probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) probe.settimeout(1) try: probe.connect(path) return True except OSError: return False finally: probe.close() @classmethod def ensure_running(cls, home, wait=5.0): """Start the server as a detached daemon unless one already answers on its socket.""" path = os.path.join(plexer_dir(home), MPLEX_SOCKET_NAME) if cls.probe(path): return True pid = os.fork() if pid == 0: code = 1 try: os.setsid() if os.fork() > 0: os._exit(0) devnull = os.open(os.devnull, os.O_RDWR) log = os.open(os.path.join(plexer_dir(home), MPLEX_LOG_NAME), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) os.dup2(devnull, 0) os.dup2(log, 1) os.dup2(log, 2) os.chdir("/") code = cls(home).serve() except BaseException: # noqa: BLE001 import traceback traceback.print_exc() os._exit(code) os.waitpid(pid, 0) deadline = time.monotonic() + wait while time.monotonic() < deadline: if cls.probe(path): return True time.sleep(0.05) return False class MplexPending: """A connection that has not yet said whether it speaks frames or one JSON line.""" def __init__(self, sock): self.sock = sock self.buf = bytearray() self.created = time.monotonic() # name: (aliases, getopt spec, min positional, max positional) MPLEX_COMMAND_SPECS = { "attach-session": (("attach", "a"), "dErxc:f:t:", 0, 0), "bind-key": (("bind",), "nrT:N:", 1, None), "break-pane": (("breakp",), "abdPF:n:s:t:", 0, 0), "capture-pane": (("capturep",), "aCeJMNpPqTb:E:S:t:", 0, 0), "choose-buffer": ((), "NrZF:f:K:O:t:", 0, 1), "choose-client": ((), "NrZF:f:K:O:t:", 0, 1), "choose-tree": ((), "GNrswZF:f:K:O:t:", 0, 1), "clear-history": (("clearhist",), "Ht:", 0, 0), "clock-mode": ((), "t:", 0, 0), "command-prompt": ((), "1bFkliNI:p:t:T:", 0, 1), "confirm-before": (("confirm",), "byc:p:t:", 1, 1), "copy-mode": ((), "deHMqSus:t:", 0, 0), "delete-buffer": (("deleteb",), "b:", 0, 0), "detach-client": (("detach",), "aPE:s:t:", 0, 0), "display-message": (("display",), "aCIlNpvc:d:F:t:", 0, 1), "display-panes": (("displayp",), "bNd:t:", 0, 1), "find-window": (("findw",), "CiNrTZt:", 1, 1), "has-session": (("has",), "t:", 0, 0), "if-shell": (("if",), "bFt:", 2, 3), "kill-pane": (("killp",), "at:", 0, 0), "kill-server": ((), "", 0, 0), "kill-session": ((), "aCt:", 0, 0), "kill-window": (("killw",), "at:", 0, 0), "last-pane": (("lastp",), "deZt:", 0, 0), "last-window": (("last",), "t:", 0, 0), "list-buffers": (("lsb",), "F:f:", 0, 0), "list-clients": (("lsc",), "F:f:t:", 0, 0), "list-commands": (("lscm",), "F:", 0, 1), "list-keys": (("lsk",), "1aNP:T:", 0, 1), "list-panes": (("lsp",), "asF:f:t:", 0, 0), "list-sessions": (("ls",), "F:f:", 0, 0), "list-windows": (("lsw",), "aF:f:t:", 0, 0), "load-buffer": (("loadb",), "b:t:", 1, 1), "move-window": (("movew",), "abdkrs:t:", 0, 0), "new-session": (("new",), "AdDEPXc:e:F:f:n:s:t:x:y:", 0, None), "new-window": (("neww",), "abdkPSc:e:F:n:t:", 0, None), "next-layout": (("nextl",), "t:", 0, 0), "next-window": (("next",), "at:", 0, 0), "paste-buffer": (("pasteb",), "dprs:b:t:", 0, 0), "previous-layout": (("prevl",), "t:", 0, 0), "previous-window": (("prev",), "at:", 0, 0), "refresh-client": (("refresh",), "cDlLRSUA:B:C:f:r:t:", 0, 1), "rename-session": (("rename",), "t:", 1, 1), "rename-window": (("renamew",), "t:", 1, 1), "resize-pane": (("resizep",), "DLMRTUZx:y:t:", 0, 1), "rotate-window": (("rotatew",), "DUZt:", 0, 0), "run-shell": (("run",), "bCEc:d:t:", 0, 1), "save-buffer": (("saveb",), "ab:", 1, 1), "select-layout": (("selectl",), "Enopt:", 0, 1), "select-pane": (("selectp",), "DdeLlMmRUZT:t:", 0, 0), "select-window": (("selectw",), "lnpTt:", 0, 0), "send-keys": (("send",), "FHKlMRXc:N:t:", 0, None), "send-prefix": ((), "2t:", 0, 0), "set-buffer": (("setb",), "awb:n:t:", 0, 1), "set-option": (("set",), "aFgopqsuUwt:", 1, 2), "set-window-option": (("setw",), "aFgoqut:", 1, 2), "show-buffer": (("showb",), "b:", 0, 0), "show-messages": (("showmsgs",), "JTt:", 0, 0), "show-options": (("show",), "AgHpqsvwt:", 0, 1), "show-window-options": (("showw",), "gvt:", 0, 1), "source-file": (("source",), "Fnqvt:", 1, None), "split-window": (("splitw",), "bdefhIPvZc:e:F:l:t:", 0, None), "start-server": (("start",), "", 0, 0), "suspend-client": (("suspendc",), "t:", 0, 0), "swap-pane": (("swapp",), "dDUZs:t:", 0, 0), "swap-window": (("swapw",), "ds:t:", 0, 0), "switch-client": (("switchc",), "ElnprZc:t:T:", 0, 0), "unbind-key": (("unbind",), "anqT:", 0, 1), } MPLEX_DEFAULT_FORMATS = { "list-sessions": "#{session_name}: #{session_windows} windows (created #{t:session_created})#{?session_attached, (attached),}", "list-windows": "#{window_index}: #{window_name}#{window_flags} (#{window_panes} panes) [#{window_width}x#{window_height}] #{window_id}#{?window_active, (active),}", "list-panes": "#{pane_index}: [#{pane_width}x#{pane_height}] [history #{history_size}/#{history_limit}] #{pane_id}#{?pane_active, (active),}", "list-clients": "#{client_tty}: #{session_name} [#{client_width}x#{client_height} #{client_termname}]", "list-buffers": "#{buffer_name}: #{buffer_size} bytes: \"#{buffer_sample}\"", "display-message": "[#{session_name}] #{window_index}:#{window_name}, current pane #{pane_index} - (%H:%M %d-%b-%y)", } class MplexCommands: """tmux's command set as methods named cmd_<name>(ctx, args).""" def __init__(self, server): self.s = server @staticmethod def resolve(word): if word in MPLEX_COMMAND_SPECS: return word for name, spec in MPLEX_COMMAND_SPECS.items(): if word in spec[0]: return name if word in MPLEX_UNSUPPORTED: raise MplexError("command not supported by mplex: %s" % word) matches = [name for name in MPLEX_COMMAND_SPECS if name.startswith(word)] if len(matches) == 1: return matches[0] if len(matches) > 1: raise MplexError("ambiguous command: %s (%s)" % (word, ", ".join(sorted(matches)[:5]))) raise MplexError("unknown command: %s" % word) # ---- helpers --------------------------------------------------------- def need_client(self, ctx): if ctx.client is None or not ctx.client.attached: raise MplexError("no attached client") return ctx.client def pane_target(self, ctx, args, flag="t"): return MplexTargets.pane(ctx, args.get(flag)) def window_target(self, ctx, args, flag="t"): return MplexTargets.window(ctx, args.get(flag)) def session_target(self, ctx, args, flag="t"): return MplexTargets.session(ctx, args.get(flag)) def fmt(self, text, session=None, window=None, pane=None, client=None, extra=None): base = self.s.resolver_for(session, window, pane, client) if extra: def resolve(name): return extra[name] if name in extra else base(name) else: resolve = base return MplexFormat.with_time(MplexFormat.expand(text, resolve)) def listing(self, command, args, rows): template = args.get("F") or MPLEX_DEFAULT_FORMATS[command] flt = args.get("f") lines = [] for session, window, pane, client, extra in rows: if flt and not MplexFormat.truthy(self.fmt(flt, session, window, pane, client, extra)): continue lines.append(self.fmt(template, session, window, pane, client, extra)) return "\n".join(lines) def substitute(self, template, answers): out = [] in_quote = False pos = 0 while pos < len(template): char = template[pos] if char == "\\" and pos + 1 < len(template): out.append(template[pos:pos + 2]) pos += 2 continue if char == '"': in_quote = not in_quote if char == "%" and pos + 1 < len(template): nxt = template[pos + 1] index = 0 if nxt == "%" else (int(nxt) - 1 if nxt.isdigit() and nxt != "0" else None) if index is not None: value = answers[index] if index < len(answers) else "" if in_quote: value = value.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$") out.append(value) pos += 2 continue out.append(char) pos += 1 return "".join(out) def run_later(self, ctx, template, answers): """Run template after a prompt finished, against the pane the prompt was opened for.""" pane = ctx.pane if ctx.pane is not None and ctx.pane.id in self.s.panes else None fresh = MplexCtx(self.s, client=ctx.client, pane=pane, interactive=True) fresh.depth = ctx.depth + 1 self.s.run_checked(fresh, self.substitute(template, answers)) # ---- sessions and clients -------------------------------------------- def cmd_new_session(self, ctx, args): s = self.s name = args.get("s") or s.auto_session_name() if ":" in name or "." in name: raise MplexError("bad session name: %s" % name) existing = next((item for item in s.sessions.values() if item.name == name), None) if existing is not None and args.has("A"): return self.attach(ctx, existing, args.has("D")) if existing is not None: raise MplexError("duplicate session: %s" % name) client = ctx.client cwd = os.path.expanduser(args.get("c") or (client.cwd if client else os.getcwd())) env = dict(client.env) if client is not None and client.env else dict(os.environ) for item in args.all("e"): key, _eq, value = item.partition("=") env[key] = value size = (80, 24 + (1 if s.options.get("status") else 0)) if args.get("x") or args.get("y"): size = (args.int("x", 80), args.int("y", 24) + (1 if s.options.get("status") else 0)) elif client is not None and not args.has("d"): size = (client.cols, client.rows) session = s.create_session(name, cwd, env, args.positional, args.get("n"), size) if not args.has("d") and client is not None: s.attach_client(client, session) if args.has("P"): return self.fmt(args.get("F") or "#{session_name}:", session, session.window(), session.window().active) return "" def attach(self, ctx, session, detach_others): client = self.need_client_for_attach(ctx) if detach_others: for other in list(self.s.attached_clients(session)): if other is not client: self.s.detach_client(other, "[detached (from session %s)]" % session.name) self.s.attach_client(client, session) return "" def need_client_for_attach(self, ctx): if ctx.client is None: raise MplexError("no client to attach") return ctx.client def cmd_attach_session(self, ctx, args): if args.get("t"): session = MplexTargets.session(ctx, args.get("t")) else: session = self.s.latest_session() if session is None: raise MplexError("no sessions") return self.attach(ctx, session, args.has("d")) def cmd_detach_client(self, ctx, args): s = self.s if args.get("s"): session = MplexTargets.session(ctx, args.get("s")) targets = list(s.attached_clients(session)) else: current = self.find_client(ctx, args.get("t")) targets = [c for c in s.attached_clients() if c is not current] if args.has("a") else [current] for client in targets: s.detach_client(client, "[detached (from session %s)]" % client.session.name) return "" def find_client(self, ctx, spec): if not spec: return self.need_client(ctx) for client in self.s.attached_clients(): if spec in (client.tty, str(client.id), str(client.pid)): return client raise MplexError("can't find client: %s" % spec) def cmd_has_session(self, ctx, args): MplexTargets.session(ctx, args.get("t") or (ctx.session.name if ctx.session else None)) return "" def cmd_kill_session(self, ctx, args): target = self.session_target(ctx, args) if args.has("a"): for session in [item for item in self.s.sessions.values() if item is not target]: self.s.kill_session(session) else: self.s.kill_session(target) return "" def cmd_kill_server(self, ctx, args): self.s.stop() return "" def cmd_start_server(self, ctx, args): return "" def cmd_list_sessions(self, ctx, args): rows = [(item, item.window(), item.window().active, None, None) for item in sorted(self.s.sessions.values(), key=lambda x: x.id)] return self.listing("list-sessions", args, rows) def cmd_list_windows(self, ctx, args): sessions = sorted(self.s.sessions.values(), key=lambda x: x.id) if args.has("a") else [self.session_target(ctx, args)] rows = [(sess, sess.windows[i], sess.windows[i].active, None, None) for sess in sessions for i in sess.indexes()] return self.listing("list-windows", args, rows) def cmd_list_panes(self, ctx, args): s = self.s if args.has("a") or args.has("s"): sessions = sorted(s.sessions.values(), key=lambda x: x.id) if args.has("a") else [self.session_target(ctx, args)] windows = [(sess, sess.windows[i]) for sess in sessions for i in sess.indexes()] else: sess, _index, window = self.window_target(ctx, args) windows = [(sess, window)] rows = [(sess, window, pane, None, None) for sess, window in windows for pane in window.panes()] return self.listing("list-panes", args, rows) def cmd_list_clients(self, ctx, args): rows = [(c.session, c.session.window(), c.session.window().active, c, None) for c in self.s.attached_clients()] return self.listing("list-clients", args, rows) def cmd_rename_session(self, ctx, args): session = self.session_target(ctx, args) name = args.positional[0] if not name or ":" in name or "." in name: raise MplexError("bad session name: %s" % name) if any(item.name == name and item is not session for item in self.s.sessions.values()): raise MplexError("duplicate session: %s" % name) session.name = name self.s.mark_all_dirty() return "" def cmd_switch_client(self, ctx, args): client = self.need_client(ctx) s = self.s if args.has("T"): client.table = args.get("T") client.repeating = False return "" ordered = sorted(s.sessions.values(), key=lambda x: x.id) here = ordered.index(client.session) window = None if args.has("n"): target = ordered[(here + 1) % len(ordered)] elif args.has("p"): target = ordered[(here - 1) % len(ordered)] elif args.has("l"): target = getattr(client, "last_session", None) if target is None or target.id not in s.sessions: raise MplexError("no last session") elif args.get("t"): spec = args.get("t") if spec.startswith("%"): target, window = MplexTargets.pane(ctx, spec)[:2] elif ":" in spec or spec.startswith("@"): target, _index, window = MplexTargets.window(ctx, spec) else: target = MplexTargets.session(ctx, spec) else: raise MplexError("switch-client needs -n, -p, -l, -t or -T") previous = client.session s.attach_client(client, target) if target is not previous: client.last_session = previous if window is not None: target.select(target.index_of(window)) return "" def cmd_suspend_client(self, ctx, args): self.need_client(ctx).send(b"Z") return "" def cmd_refresh_client(self, ctx, args): client = self.need_client(ctx) client.prev = None client.dirty = True return "" def cmd_list_commands(self, ctx, args): lines = [] for name in sorted(MPLEX_COMMAND_SPECS): aliases, spec, low, high = MPLEX_COMMAND_SPECS[name] lines.append("%s%s %s" % (name, " (%s)" % aliases[0] if aliases else "", "[" + spec.replace(":", " arg").strip() + "]" if spec else "")) return "\n".join(lines) # ---- windows --------------------------------------------------------- def cmd_new_window(self, ctx, args): s = self.s spec = args.get("t") session = ctx.session index = None anchor = None if spec and not spec.endswith(":") and ":" not in spec and not spec.lstrip("=").isdigit() and not spec.startswith(("@", "%", "+", "-", "!", "^", "$", "{")): has_window = ctx.session is not None and MplexTargets.window_index(ctx.session, spec) is not None if not has_window: spec = spec + ":" if spec and spec.endswith(":"): session = MplexTargets.session(ctx, spec[:-1]) elif spec and self.is_new_index(ctx, spec): sess_part, _sep, idx_part = spec.rpartition(":") session = MplexTargets.session(ctx, sess_part) if sess_part else ctx.session index = int(idx_part.lstrip("=")) elif spec: session, index, anchor = MplexTargets.window(ctx, spec) if session is None: raise MplexError("no current session") if index is None: index = session.free_index(s.options.get("base-index")) current = session.current if (args.has("a") or args.has("b")) and current is not None: index = current + 1 if args.has("a") else current self.make_room(session, index) elif anchor is not None: if args.has("a"): index += 1 self.make_room(session, index) elif args.has("b"): self.make_room(session, index) elif args.has("k"): s.kill_window(anchor) else: raise MplexError("index in use: %d" % index) cwd = os.path.expanduser(args.get("c") or session.cwd) env_extra = dict(item.partition("=")[::2] for item in args.all("e")) if args.has("S"): for idx in session.indexes(): if session.windows[idx].name == args.get("n"): session.select(idx) return "" created = s.create_window(session, args.get("n"), cwd, args.positional, env_extra, index, not args.has("d")) if args.has("P"): return self.fmt(args.get("F") or "#{session_name}:#{window_index}.#{pane_index}", session, created, created.active) return "" def is_new_index(self, ctx, spec): """-t names an index that does not exist yet (the usual new-window -t :N case).""" if ":" not in spec: return False sess_part, _sep, idx_part = spec.rpartition(":") idx_part = idx_part.lstrip("=") if not idx_part.isdigit(): return False try: session = MplexTargets.session(ctx, sess_part) if sess_part else ctx.session except MplexError: return False return int(idx_part) not in session.windows def make_room(self, session, index): if index not in session.windows: return current = session.window() moved = {} for idx in session.indexes(): moved[idx + 1 if idx >= index else idx] = session.windows[idx] session.windows = moved session.current = session.index_of(current) if current else None def cmd_kill_window(self, ctx, args): session, index, window = self.window_target(ctx, args) if args.has("a"): for other in [session.windows[i] for i in session.indexes() if i != index]: self.s.kill_window(other) else: self.s.kill_window(window) return "" def cmd_select_window(self, ctx, args): if args.has("n") or args.has("p"): return self.step_window(ctx, args, 1 if args.has("n") else -1) if args.has("l"): return self.cmd_last_window(ctx, args) session, index, window = self.window_target(ctx, args) if args.has("T") and index == session.current: if session.last is None: raise MplexError("no last window") session.select(session.last) self.s.mark_dirty() return "" session.select(index) self.s.mark_dirty() return "" def step_window(self, ctx, args, step): session = MplexTargets.session(ctx, args.get("t") and args.get("t").partition(":")[0]) if args.has("a"): raise MplexError("no %s window" % ("next" if step > 0 else "previous")) indexes = session.indexes() here = indexes.index(session.current) session.select(indexes[(here + step) % len(indexes)]) self.s.mark_dirty() return "" def cmd_next_window(self, ctx, args): return self.step_window(ctx, args, 1) def cmd_previous_window(self, ctx, args): return self.step_window(ctx, args, -1) def cmd_last_window(self, ctx, args): session = MplexTargets.session(ctx, args.get("t")) if session.last not in session.windows: raise MplexError("no last window") session.select(session.last) self.s.mark_dirty() return "" def cmd_rename_window(self, ctx, args): session, index, window = self.window_target(ctx, args) if not args.positional[0]: raise MplexError("empty window name") window.name = args.positional[0][:64] window.auto_rename = False self.s.mark_all_dirty() return "" def parse_window_destination(self, ctx, spec): if ":" in spec: sess_part, _sep, idx_part = spec.rpartition(":") session = MplexTargets.session(ctx, sess_part) if sess_part else ctx.session else: session, idx_part = ctx.session, spec idx_part = idx_part.lstrip("=") if not idx_part.lstrip("-").isdigit(): raise MplexError("bad window index: %s" % idx_part) return session, int(idx_part) def cmd_move_window(self, ctx, args): s = self.s if args.has("r"): s.renumber(MplexTargets.session(ctx, (args.get("t") or "").partition(":")[0])) s.mark_all_dirty() return "" src_session, src_index, window = MplexTargets.window(ctx, args.get("s")) if not args.get("t"): raise MplexError("move-window needs -t") dst_session, idx = self.parse_window_destination(ctx, args.get("t")) if args.has("a"): idx += 1 self.make_room(dst_session, idx) elif args.has("b"): self.make_room(dst_session, idx) elif idx in dst_session.windows and dst_session.windows[idx] is not window: if args.has("k"): s.kill_window(dst_session.windows[idx]) else: raise MplexError("index in use: %d" % idx) if dst_session is src_session and idx == src_index: return "" was_current = src_session.current == src_index src_session.windows.pop(src_index) src_session.history = [item for item in src_session.history if item is not window] window.session = dst_session window.options.parent = dst_session.options dst_session.windows[idx] = window if was_current and src_session.windows and src_session is not dst_session: src_session.current = src_session.indexes()[0] elif was_current and src_session is dst_session: src_session.current = idx if not args.has("d"): dst_session.select(idx) if not src_session.windows: s.kill_session(src_session) else: s.resize_session(src_session) s.resize_session(dst_session) return "" def cmd_swap_window(self, ctx, args): src_session, src_index, src = MplexTargets.window(ctx, args.get("s")) dst_session, dst_index, dst = MplexTargets.window(ctx, args.get("t")) was = (src_session.window(), dst_session.window()) src_session.windows[src_index], dst_session.windows[dst_index] = dst, src src.session, dst.session = dst_session, src_session src.options.parent, dst.options.parent = dst_session.options, src_session.options for session, current in ((src_session, was[0]), (dst_session, was[1])): if current is not None: session.current = session.index_of(current) self.s.resize_session(src_session) self.s.resize_session(dst_session) return "" def cmd_find_window(self, ctx, args): s = self.s text = args.positional[0] fields = ("C" if args.has("C") else "") + ("N" if args.has("N") else "") + ("T" if args.has("T") else "") fields = fields or "CNT" if args.has("r"): flags = re.IGNORECASE if args.has("i") else 0 try: matcher = re.compile(text, flags).search except re.error as exc: raise MplexError("bad regular expression: %s" % exc) else: import fnmatch pattern = "*%s*" % text low = pattern.lower() if args.has("i") else pattern def matcher(value): return fnmatch.fnmatchcase(value.lower() if args.has("i") else value, low) found = [] for session in sorted(s.sessions.values(), key=lambda x: x.id): for index in session.indexes(): window = session.windows[index] hit = False if "N" in fields and matcher(window.name): hit = True for pane in window.panes(): if "T" in fields and matcher(pane.title): hit = True if "C" in fields and matcher(pane.screen.text()): hit = True if hit: found.append((session, index, window)) if not found: raise MplexError("no windows matching: %s" % text) if len(found) == 1: session, index, _window = found[0] client = ctx.client if client is not None and client.attached and client.session is not session: s.attach_client(client, session) session.select(index) s.mark_dirty() return "" items = [MplexChoice("%s:%d: %s%s" % (sess.name, idx, win.name, s.window_flags(sess, win)), ("window", win.id)) for sess, idx, win in found] return self.open_chooser(ctx, "tree-mode", lambda: [MplexChoice(i.label, i.payload) for i in items], args) # ---- panes ----------------------------------------------------------- def size_arg(self, text, total): text = text.strip() if text.endswith("%"): return max(1, total * int(text[:-1]) // 100) return int(text) def cmd_split_window(self, ctx, args): s = self.s session, window, target = self.pane_target(ctx, args) kind = "lr" if args.has("h") else "tb" leaf = window.leaf_of(target) size = None if args.get("l"): try: size = self.size_arg(args.get("l"), leaf.w if kind == "lr" else leaf.h) except ValueError: raise MplexError("bad size: %s" % args.get("l")) cwd = os.path.expanduser(args.get("c") or session.cwd) env_extra = dict(item.partition("=")[::2] for item in args.all("e")) pane = s.split_pane(window, target, kind, args.has("b"), args.positional, cwd, env_extra, size, not args.has("d")) if args.has("Z") and not args.has("d"): window.zoomed = pane window.relayout() if args.has("P"): return self.fmt(args.get("F") or "#{session_name}:#{window_index}.#{pane_index}", session, window, pane) return "" def cmd_kill_pane(self, ctx, args): session, window, pane = self.pane_target(ctx, args) if args.has("a"): for other in [item for item in window.panes() if item is not pane]: self.s.kill_pane(other) else: self.s.kill_pane(pane) return "" def cmd_select_pane(self, ctx, args): s = self.s if args.has("e") or args.has("d"): raise MplexError("select-pane -e/-d (pane input switch) is not supported by mplex") if args.has("M"): s.marked = None s.mark_all_dirty() return "" session, window, pane = self.pane_target(ctx, args) if args.has("m"): s.marked = None if s.marked is pane else pane s.mark_all_dirty() return "" if args.get("T") is not None: pane.screen.title = args.get("T")[:256] s.mark_all_dirty() return "" new = pane for flag in "UDLR": if args.has(flag): found = MplexLayout.neighbour(window.visible_panes() if window.zoomed else window.panes(), pane, flag, window.width, window.height) if found is None: raise MplexError("no pane in that direction") new = found if args.has("l"): if window.last_pane not in window.panes(): raise MplexError("no last pane") new = window.last_pane keep_zoom = args.has("Z") and window.zoomed is not None zoomed = window.zoomed s.select_pane(window, new) if keep_zoom and zoomed is not None: window.zoomed = new window.relayout() if session.window() is not window: session.select(session.index_of(window)) return "" def cmd_last_pane(self, ctx, args): _session, _index, window = MplexTargets.window(ctx, args.get("t")) if window.last_pane not in window.panes(): raise MplexError("no last pane") self.s.select_pane(window, window.last_pane) return "" def cmd_resize_pane(self, ctx, args): if args.has("M") or args.has("T"): raise MplexError("resize-pane -M/-T is not supported by mplex") session, window, pane = self.pane_target(ctx, args) if args.has("Z"): if window.zoomed is not None: window.zoomed = None elif len(window.panes()) > 1: window.zoomed = pane window.relayout() self.s.mark_dirty(window) return "" leaf = window.leaf_of(pane) amount = 1 if args.positional: try: amount = int(args.positional[0]) except ValueError: raise MplexError("bad adjustment: %s" % args.positional[0]) try: if args.get("x"): MplexLayout.resize_to(leaf, "lr", self.size_arg(args.get("x"), window.width)) if args.get("y"): MplexLayout.resize_to(leaf, "tb", self.size_arg(args.get("y"), window.height)) if args.has("R"): MplexLayout.resize_by(leaf, "lr", amount, True) elif args.has("L"): MplexLayout.resize_by(leaf, "lr", -amount, False) elif args.has("D"): MplexLayout.resize_by(leaf, "tb", amount, True) elif args.has("U"): MplexLayout.resize_by(leaf, "tb", -amount, False) except MplexError: pass window.layout_index = -1 window.relayout() self.s.mark_dirty(window) return "" def cmd_swap_pane(self, ctx, args): s = self.s if args.has("U") or args.has("D"): _s, window, dst = self.pane_target(ctx, args) panes = window.panes() here = panes.index(dst) src = panes[(here - 1) % len(panes)] if args.has("U") else panes[(here + 1) % len(panes)] else: _s, _w, dst = self.pane_target(ctx, args) if args.get("s"): _s2, _w2, src = MplexTargets.pane(ctx, args.get("s")) elif s.marked is not None: src = s.marked else: raise MplexError("no marked pane") if src is dst: return "" wa, wb = src.window, dst.window leaf_a, leaf_b = wa.leaf_of(src), wb.leaf_of(dst) leaf_a.pane, leaf_b.pane = dst, src src.window, dst.window = wb, wa if wa is not wb: if wa.active is src: wa.active = dst if wb.active is dst: wb.active = src for window in (wa,) if wa is wb else (wa, wb): window.relayout() s.marked = None s.mark_all_dirty() return "" def cmd_rotate_window(self, ctx, args): session, index, window = self.window_target(ctx, args) panes = window.panes() if len(panes) < 2: return "" order = panes[-1:] + panes[:-1] if args.has("D") else panes[1:] + panes[:1] position = panes.index(window.active) for leaf, pane in zip(window.root.leaves(), order): leaf.pane = pane window.active = order[position] window.relayout() s = self.s s.mark_dirty(window) return "" def cmd_break_pane(self, ctx, args): s = self.s session, window, pane = MplexTargets.pane(ctx, args.get("s")) if len(window.panes()) < 2: raise MplexError("can't break with only one pane") dst_session = session index = dst_session.free_index(s.options.get("base-index")) if args.get("t"): dst_session, index = self.parse_window_destination(ctx, args.get("t")) if index in dst_session.windows: raise MplexError("index in use: %d" % index) leaf = window.leaf_of(pane) was_active = window.active is pane window.root = MplexLayout.remove(window.root, leaf) if window.zoomed is pane: window.zoomed = None rest = window.panes() if was_active or window.active is pane: window.active = window.last_pane if window.last_pane in rest else rest[0] if window.last_pane is pane: window.last_pane = None window.relayout() cols, rows = getattr(dst_session, "size", (80, 24)) fresh = MplexWindow(s.next_id("window"), args.get("n") or pane.foreground_command() or pane.shell_name, cols, max(1, rows - (1 if s.options.get("status") else 0)), dst_session.options) fresh.auto_rename = not args.get("n") fresh.root = MplexLayoutNode("pane", pane=pane, w=fresh.width, h=fresh.height) fresh.active = pane pane.window = fresh s.register_window(dst_session, fresh, index, not args.has("d")) fresh.relayout() s.mark_all_dirty() if args.has("P"): return self.fmt(args.get("F") or "#{session_name}:#{window_index}.#{pane_index}", dst_session, fresh, pane) return "" def apply_layout(self, window, name, index): panes = window.panes() opts = window.options window.zoomed = None window.root = MplexLayout.preset(name, panes, window.width, window.height, opts.get("main-pane-width"), opts.get("main-pane-height")) window.layout_index = index window.relayout() self.s.mark_dirty(window) def cmd_select_layout(self, ctx, args): session, window, pane = self.pane_target(ctx, args) names = MPLEX_LAYOUT_NAMES if args.has("E"): leaf = window.leaf_of(pane) parent = leaf.parent if parent is not None: for child in parent.children: MplexLayout.set_size(child, parent.kind, 1) window.relayout() self.s.mark_dirty(window) return "" if args.positional: wanted = args.positional[0] matches = [item for item in names if item == wanted] or [item for item in names if item.startswith(wanted)] if len(matches) != 1: raise MplexError("unknown or ambiguous layout: %s" % wanted) self.apply_layout(window, matches[0], names.index(matches[0])) return "" if args.has("p"): index = (window.layout_index - 1) % len(names) elif args.has("o"): index = window.layout_index if window.layout_index >= 0 else 0 else: index = (window.layout_index + 1) % len(names) self.apply_layout(window, names[index], index) return "" def cmd_next_layout(self, ctx, args): session, index, window = self.window_target(ctx, args) self.apply_layout(window, MPLEX_LAYOUT_NAMES[(window.layout_index + 1) % len(MPLEX_LAYOUT_NAMES)], (window.layout_index + 1) % len(MPLEX_LAYOUT_NAMES)) return "" def cmd_previous_layout(self, ctx, args): session, index, window = self.window_target(ctx, args) step = (window.layout_index - 1) % len(MPLEX_LAYOUT_NAMES) self.apply_layout(window, MPLEX_LAYOUT_NAMES[step], step) return "" def cmd_clear_history(self, ctx, args): _s, _w, pane = self.pane_target(ctx, args) pane.clear_history() return "" def cmd_capture_pane(self, ctx, args): _s, _w, pane = self.pane_target(ctx, args) screen = pane.screen if args.has("a") and not screen.alt_active: if args.has("q"): return "" raise MplexError("no alternate screen") history = list(screen.history or []) rows = history + [list(row) for row in screen.grid] base = len(history) def line_of(row): text = "".join(cell[0] if cell else " " for cell in row) return text if args.has("N") else text.rstrip() def bound(value, default): if value is None: return default if value == "-": return None try: return base + int(value) except ValueError: raise MplexError("bad line number: %s" % value) start = bound(args.get("S"), base) end = bound(args.get("E"), base + pane.h - 1) start = 0 if start is None else max(0, start) end = len(rows) - 1 if end is None else min(len(rows) - 1, end) text = "\n".join(line_of(rows[i]) for i in range(start, end + 1)) if args.has("p"): return text self.s.add_buffer(text + "\n", args.get("b")) return "" # ---- keys, prompts, input -------------------------------------------- def cmd_send_prefix(self, ctx, args): session, window, pane = self.pane_target(ctx, args) name = session.options.get("prefix2" if args.has("2") else "prefix") if name == "None": raise MplexError("no prefix key set") self.s.forward(pane, window, MplexKeys.to_bytes(name, pane.screen.app_cursor)) return "" def cmd_send_keys(self, ctx, args): session, window, pane = self.pane_target(ctx, args) words = list(args.positional) count = args.int("N", None) if args.has("M"): raise MplexError("send-keys -M (mouse) is not supported by mplex") if args.has("X"): mode = pane.top_mode() if not isinstance(mode, MplexCopyMode): raise MplexError("pane is not in a mode") if not words: raise MplexError("send-keys -X needs a command") if len(words) > 1 and words[1] == "--": del words[1] if args.has("F"): words = [self.fmt(item, session, window, pane, ctx.client) for item in words] if count: pane.prefix_count = count mode.command(words[0], words[1:], ctx.client) return "" if args.has("R"): pane.screen.reset() if not words: if count: pane.prefix_count = count return "" if args.has("F"): words = [self.fmt(item, session, window, pane, ctx.client) for item in words] data = b"" if args.has("l"): data = "".join(words).encode("utf-8") elif args.has("H"): try: data = bytes(int(item, 16) for item in words) except ValueError: raise MplexError("bad hex byte in send-keys -H") else: for word in words: try: data += MplexKeys.to_bytes(MplexKeys.canonical(word), pane.screen.app_cursor) except MplexError: data += word.encode("utf-8") mode = pane.top_mode() for _i in range(max(1, min(count or 1, 1000))): if mode is not None and not args.has("l") and not args.has("H"): tokens, _rest = MplexKeys.split(data, True) for name, raw in tokens: mode.key(name, raw, ctx.client) elif not pane.dead: pane.write(data) self.s.mark_dirty(window) return "" def cmd_bind_key(self, ctx, args): table = "root" if args.has("n") else (args.get("T") or "prefix") key = MplexKeys.canonical(args.positional[0]) words = args.positional[1:] if not words: raise MplexError("bind-key needs a command") command = " ".join(MplexCommandParser.quote(word) for word in words) for parsed in MplexCommandParser.parse(command): self.resolve(parsed[0]) self.s.config.bind(table, key, command, args.has("r"), args.get("N") or "") return "" def cmd_unbind_key(self, ctx, args): table = "root" if args.has("n") else (args.get("T") or "prefix") config = self.s.config if args.has("a"): config.tables[table] = {} return "" if not args.positional: raise MplexError("unbind-key needs a key (or -a)") key = MplexKeys.canonical(args.positional[0]) if not config.unbind(table, key) and not args.has("q"): raise MplexError("unknown key: %s" % args.positional[0]) return "" def cmd_list_keys(self, ctx, args): config = self.s.config opts = ctx.session.options if ctx.session else self.s.options only = MplexKeys.canonical(args.positional[0]) if args.positional else None tables = [args.get("T")] if args.get("T") else list(config.tables) lines = [] if args.has("N"): prefix = opts.get("prefix") rows = [] for table in ([args.get("T")] if args.get("T") else ["root", "prefix"]): for key, binding in config.tables.get(table, {}).items(): if only is not None and key != only: continue note = binding.note or (binding.command if args.has("a") else "") if not note: continue label = key if table == "root" else "%s %s" % (prefix, key) if args.get("P") is not None: label = args.get("P") + key rows.append((label, note)) if only is not None and not rows: raise MplexError("unknown key: %s" % args.positional[0]) if args.has("1"): rows = rows[:1] width = max([len(label) for label, _note in rows] or [0]) return "\n".join("%s %s" % (label.ljust(width), note) for label, note in rows) for table in tables: for key, binding in config.tables.get(table, {}).items(): if only is not None and key != only: continue lines.append("bind-key %s -T %-12s %-12s %s" % ("-r" if binding.repeat else " ", table, key, binding.command)) if args.has("1") and lines: lines = lines[:1] return "\n".join(lines) def cmd_command_prompt(self, ctx, args): s = self.s client = self.need_client(ctx) template = args.positional[0] if args.positional else None prompts = args.get("p").split(",") if args.get("p") else [] inputs = args.get("I").split(",") if args.get("I") else [] prompts = [MplexFormat.strip_styles(self.fmt(item, ctx.session, ctx.window, ctx.pane, client)) for item in prompts] inputs = [self.fmt(item, ctx.session, ctx.window, ctx.pane, client) for item in inputs] if template is not None and args.has("F"): template = self.fmt(template, ctx.session, ctx.window, ctx.pane, client) kind = args.get("T") or "command" history = s.prompt_history.setdefault(kind, []) answers = [] count = max(1, len(prompts)) pane = ctx.pane copy_mode = pane.find_mode(MplexCopyMode) if pane is not None else None def ask(position): label = (prompts[position] + " ") if prompts else ":" initial = inputs[position] if position < len(inputs) else "" def done(value): answers.append(value) if position + 1 < count: ask(position + 1) return if template is None: self.run_later(ctx, "%%", answers) else: self.run_later(ctx, template, answers) kwargs = {} if args.has("i") and template is not None: kwargs["on_change"] = lambda text: self.run_later(ctx, template, ["=" + text]) kwargs["on_step"] = lambda name, text: self.run_later(ctx, template, [("+" if name == "C-s" else "-") + text]) if copy_mode is not None: kwargs["on_cancel"] = copy_mode.search_cancel client.prompt = MplexPrompt(label, initial, done, history, single=args.has("1"), numeric=args.has("N"), key_name=args.has("k"), **kwargs) client.dirty = True ask(0) return "" def cmd_confirm_before(self, ctx, args): client = self.need_client(ctx) command = args.positional[0] text = args.get("p") or "Confirm '%s'? (y/n)" % command text = self.fmt(text, ctx.session, ctx.window, ctx.pane, client) pane = ctx.pane def run(): fresh = MplexCtx(self.s, client=client, pane=pane if pane is not None and pane.id in self.s.panes else None, interactive=True) fresh.depth = ctx.depth + 1 self.s.run_checked(fresh, command) self.s.ask_confirm(client, text, run) return "" def cmd_display_message(self, ctx, args): session, window, pane = self.pane_target(ctx, args) template = args.positional[0] if args.positional else MPLEX_DEFAULT_FORMATS["display-message"] text = self.fmt(template, session, window, pane, ctx.client) text = MplexFormat.strip_styles(text) if args.has("p"): return text client = ctx.client if client is not None and client.attached: delay = args.int("d", None) self.s.message(client, text, delay) return "" def cmd_display_panes(self, ctx, args): client = self.need_client(ctx) window = client.session.window() duration = args.int("d", None) or window.options.get("display-panes-time") client.overlay = MplexPaneNumbers(self.s, window, time.monotonic() + duration / 1000.0) client.dirty = True return "" def cmd_clock_mode(self, ctx, args): _s, window, pane = self.pane_target(ctx, args) if pane.find_mode(MplexClockMode) is None: pane.push_mode(MplexClockMode(self.s, pane)) self.s.mark_dirty(window) return "" def cmd_copy_mode(self, ctx, args): if args.has("M"): raise MplexError("copy-mode -M (mouse) is not supported by mplex") _s, window, pane = self.pane_target(ctx, args) existing = pane.find_mode(MplexCopyMode) if args.has("q"): if existing is not None: existing.close() return "" if existing is None: existing = MplexCopyMode(self.s, pane, exit_at_bottom=args.has("e"), show_position=not args.has("H")) pane.push_mode(existing) if args.has("u"): existing.command("page-up", [], ctx.client) self.s.mark_dirty(window) return "" def cmd_show_messages(self, ctx, args): return "\n".join("%s %s" % (time.strftime("%H:%M:%S", time.localtime(stamp)), text) for stamp, text in self.s.messages) # ---- buffers --------------------------------------------------------- def buffer_rows(self): rows = [] for item in self.s.buffers: sample = item["data"][:50].replace("\n", " ") rows.append((None, None, None, None, {"buffer_name": item["name"], "buffer_size": str(len(item["data"].encode("utf-8"))), "buffer_sample": sample})) return rows def cmd_list_buffers(self, ctx, args): return self.listing("list-buffers", args, self.buffer_rows()) def cmd_set_buffer(self, ctx, args): name = args.get("b") if args.get("n"): item = self.s.find_buffer(name) item["name"] = args.get("n") return "" if not args.positional: raise MplexError("set-buffer needs data") data = args.positional[0] if args.has("a"): item = self.s.find_buffer(name) item["data"] = (item["data"] + data)[:MPLEX_PASTE_MAX] else: self.s.add_buffer(data, name) return "" def cmd_show_buffer(self, ctx, args): return self.s.find_buffer(args.get("b"))["data"] def cmd_delete_buffer(self, ctx, args): item = self.s.find_buffer(args.get("b")) self.s.buffers.remove(item) self.s.refresh_choosers() return "" def cmd_save_buffer(self, ctx, args): item = self.s.find_buffer(args.get("b")) path = os.path.expanduser(args.positional[0]) try: with open(path, "ab" if args.has("a") else "wb") as handle: handle.write(item["data"].encode("utf-8")) except OSError as exc: raise MplexError("cannot write %s: %s" % (path, short_error(exc))) return "" def cmd_load_buffer(self, ctx, args): path = os.path.expanduser(args.positional[0]) try: with open(path, "rb") as handle: data = handle.read(MPLEX_PASTE_MAX) except OSError as exc: raise MplexError("cannot read %s: %s" % (path, short_error(exc))) self.s.add_buffer(data.decode("utf-8", "replace"), args.get("b")) return "" def cmd_paste_buffer(self, ctx, args): session, window, pane = self.pane_target(ctx, args) item = self.s.find_buffer(args.get("b")) separator = args.get("s") if args.get("s") is not None else "\r" text = item["data"] if args.has("r") else item["data"].replace("\n", separator) data = text.encode("utf-8") if args.has("p") and pane.screen.bracketed_paste: data = b"\x1b[200~" + data + b"\x1b[201~" if not pane.dead: pane.write(data) if args.has("d"): self.s.buffers.remove(item) return "" # ---- choosers -------------------------------------------------------- def open_chooser(self, ctx, kind, builder, args, on_choose=None, on_delete=None, preview=None, selected=None): s = self.s pane = ctx.pane if pane is None: raise MplexError("no pane") window = pane.window zoom_on_open = args.has("Z") and window.zoomed is None and len(window.panes()) > 1 if zoom_on_open: window.zoomed = pane window.relayout() def restore(): if zoom_on_open and window.zoomed is pane: window.zoomed = None window.relayout() def choose(node, client): kind_tag, ident = node.payload if kind_tag == "window": target = s.windows.get(ident) if target is None: return if client is not None and client.session is not target.session: s.attach_client(client, target.session) target.session.select(target.session.index_of(target)) elif kind_tag == "pane": target = s.panes.get(ident) if target is None or target.window is None: return if client is not None and client.session is not target.window.session: s.attach_client(client, target.window.session) target.window.session.select(target.window.session.index_of(target.window)) s.select_pane(target.window, target) elif kind_tag == "session": target = s.sessions.get(ident) if target is not None and client is not None: s.attach_client(client, target) s.mark_all_dirty() mode = MplexChooser(s, pane, kind, builder, on_choose or choose, on_delete, preview, selected, on_close=restore) pane.push_mode(mode) s.mark_dirty(window) return "" def cmd_choose_tree(self, ctx, args): s = self.s sessions_only = args.has("s") windows_collapsed = args.has("w") def builder(): items = [] for session in sorted(s.sessions.values(), key=lambda x: x.id): kids = [] for index in session.indexes(): window = session.windows[index] panes = [] for number, pane in enumerate(window.panes()): panes.append(MplexChoice("%d: %s \"%s\"%s" % (number + window.options.get("pane-base-index"), pane.foreground_command(), pane.title, "*" if pane is window.active else ""), ("pane", pane.id))) kids.append(MplexChoice("%d: %s%s (%d panes) [%dx%d]" % (index, window.name, s.window_flags(session, window), len(panes), window.width, window.height), ("window", window.id), panes, expanded=not (windows_collapsed or sessions_only) and len(panes) > 1)) attached = " (attached)" if s.attached_clients(session) else "" items.append(MplexChoice("%s: %d windows%s" % (session.name, len(session.windows), attached), ("session", session.id), kids, expanded=not sessions_only)) return items def preview(node, width, height): kind_tag, ident = node.payload pane = None if kind_tag == "pane": pane = s.panes.get(ident) elif kind_tag == "window": window = s.windows.get(ident) pane = window.active if window else None else: session = s.sessions.get(ident) pane = session.window().active if session else None if pane is None: return [] blank = MplexCanvas.BLANK return [[cell if cell is not None else blank for cell in row[:width]] for row in pane.screen.grid[:height]] def delete(node, client): kind_tag, ident = node.payload if kind_tag == "session" and ident in s.sessions: s.kill_session(s.sessions[ident]) elif kind_tag == "window" and ident in s.windows: s.kill_window(s.windows[ident]) elif kind_tag == "pane" and ident in s.panes: s.kill_pane(s.panes[ident]) selected = ("window", ctx.window.id) if ctx.window is not None else None return self.open_chooser(ctx, "tree-mode", builder, args, on_delete=delete, preview=preview, selected=selected) def cmd_choose_buffer(self, ctx, args): s = self.s pane = ctx.pane def builder(): return [MplexChoice("%s: %d bytes: \"%s\"" % (item["name"], len(item["data"]), item["data"][:50].replace("\n", " ")), ("buffer", item["name"])) for item in s.buffers] def choose(node, client): if pane is not None and pane.id in s.panes and not pane.dead: item = s.find_buffer(node.payload[1]) pane.write(item["data"].replace("\n", "\r").encode("utf-8")) def delete(node, client): s.buffers[:] = [item for item in s.buffers if item["name"] != node.payload[1]] def preview(node, width, height): data = next((item["data"] for item in s.buffers if item["name"] == node.payload[1]), "") return [[(char, None, None, 0) for char in line[:width]] for line in data.split("\n")[:height]] if not s.buffers: raise MplexError("no buffers") return self.open_chooser(ctx, "buffer-mode", builder, args, on_choose=choose, on_delete=delete, preview=preview) def cmd_choose_client(self, ctx, args): s = self.s def builder(): return [MplexChoice("%s: %s [%dx%d %s]" % (c.tty or c.id, c.session.name, c.cols, c.rows, c.term), ("client", c.id)) for c in s.attached_clients()] def choose(node, client): for target in s.attached_clients(): if target.id == node.payload[1]: s.detach_client(target, "[detached (from session %s)]" % target.session.name) return self.open_chooser(ctx, "client-mode", builder, args, on_choose=choose, on_delete=choose) # ---- options, shell, files ------------------------------------------- def option_name(self, name): if name in MplexOptions.DEFS: return name matches = [item for item in MplexOptions.DEFS if item.startswith(name)] if len(matches) == 1: return matches[0] raise MplexError("unknown or unsupported option: %s" % name) def option_scope(self, ctx, args, window_scope): if args.has("g") or args.has("s"): return self.s.options if window_scope or args.has("w"): return MplexTargets.window(ctx, args.get("t"))[2].options return MplexTargets.session(ctx, args.get("t") and args.get("t").partition(":")[0]).options def set_option(self, ctx, args, window_scope): try: name = self.option_name(args.positional[0]) except MplexError: if args.has("q"): return "" raise scope = self.option_scope(ctx, args, window_scope) if args.has("u"): scope.unset(name) else: if len(args.positional) < 2: scope.toggle(name) else: value = args.positional[1] if args.has("F"): value = self.fmt(value, ctx.session, ctx.window, ctx.pane, ctx.client) if args.has("o") and scope.is_set(name): raise MplexError("already set: %s" % name) scope.set(name, value, args.has("a")) self.s.resize_all() self.s.mark_all_dirty() return "" def cmd_set_option(self, ctx, args): return self.set_option(ctx, args, False) def cmd_set_window_option(self, ctx, args): return self.set_option(ctx, args, True) def show_options(self, ctx, args, window_scope): scope = self.option_scope(ctx, args, window_scope) names = [self.option_name(args.positional[0])] if args.positional else sorted(MplexOptions.DEFS) lines = [] for name in names: if args.positional or args.has("A") or args.has("g") or args.has("s") or scope.is_set(name): value = scope.get(name) text = MplexOptions.show(value) if args.has("v"): lines.append(text) else: lines.append("%s %s" % (name, MplexCommandParser.quote(text) if isinstance(value, str) else text)) return "\n".join(lines) def cmd_show_options(self, ctx, args): return self.show_options(ctx, args, False) def cmd_show_window_options(self, ctx, args): return self.show_options(ctx, args, True) def shell_in(self, ctx, command, pane): cwd = pane.current_path() if pane is not None else None env = dict(ctx.session.env) if ctx.session is not None else None try: done = subprocess.run(["/bin/sh", "-c", command], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=MPLEX_SHELL_TIMEOUT, cwd=cwd or None, env=env) except (OSError, subprocess.SubprocessError) as exc: raise MplexError("shell command failed: " + short_error(exc)) return done.returncode, done.stdout[:MPLEX_SHELL_OUTPUT_MAX].decode("utf-8", "replace") def cmd_if_shell(self, ctx, args): session, window, pane = self.pane_target(ctx, args) condition = args.positional[0] if args.has("F"): ok = MplexFormat.truthy(self.fmt(condition, session, window, pane, ctx.client)) else: ok = self.shell_in(ctx, self.fmt(condition, session, window, pane, ctx.client), pane)[0] == 0 chosen = args.positional[1] if ok else (args.positional[2] if len(args.positional) > 2 else "") if not chosen: return "" return self.s.run_line(ctx, chosen, ctx.depth + 1) def cmd_run_shell(self, ctx, args): session, window, pane = self.pane_target(ctx, args) if args.get("d"): raise MplexError("run-shell -d (delay) is not supported by mplex") if not args.positional: return "" command = self.fmt(args.positional[0], session, window, pane, ctx.client) if args.has("C"): return self.s.run_line(ctx, command, ctx.depth + 1) if args.has("b"): try: subprocess.Popen(["/bin/sh", "-c", command], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=pane.current_path() or None, start_new_session=True) except OSError as exc: raise MplexError("shell command failed: " + short_error(exc)) return "" return self.shell_in(ctx, command, pane)[1].rstrip("\n") def cmd_source_file(self, ctx, args): s = self.s out = [] for raw in args.positional: path = os.path.expanduser(raw) if not os.path.exists(path): if args.has("q"): continue raise MplexError("no such file: %s" % raw) if path.endswith(".toml"): before = s.config.skipped s.config.apply_file(path) for problem in s.config.errors[before:]: s.log_message("mplex.toml: " + problem) skipped = s.config.skipped - before if skipped: out.append("%s: %d problem(s) skipped, see show-messages" % (path, skipped)) s.resize_all() s.mark_all_dirty() continue try: with open(path, "r", encoding="utf-8") as handle: lines = handle.read(MPLEX_CONFIG_MAX).splitlines() except OSError as exc: raise MplexError("cannot read %s: %s" % (path, short_error(exc))) for number, line in enumerate(lines, 1): if not line.strip() or line.lstrip().startswith("#"): continue try: text = s.run_line(ctx, line, ctx.depth + 1) except MplexError as exc: raise MplexError("%s:%d: %s" % (raw, number, exc)) if text: out.append(text) return "\n".join(out) class MplexClient: """The attached terminal: forwards keys and resizes to the server, paints what it sends.""" def __init__(self, home, commands): self.home = home self.commands = commands self.sock_path = os.path.join(plexer_dir(home), MPLEX_SOCKET_NAME) self.stdin_fd = None self.saved = None self.attached = False self.wake_r = None self.wake_w = None @staticmethod def frame(kind, payload=b""): return kind + struct.pack(">I", len(payload)) + payload def size(self): try: packed = fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ, b"\0" * 8) rows, cols = struct.unpack("HH", packed[:4]) if rows and cols: return rows, cols except (OSError, ValueError, AttributeError): pass return 24, 80 def hello(self): rows, cols = self.size() pane = os.environ.get(PLEXER_PANE_ENV, "") info = {"rows": rows, "cols": cols, "term": os.environ.get("TERM", ""), "cwd": os.getcwd(), "pid": os.getpid(), "tty": os.ttyname(sys.stdin.fileno()) if sys.stdin.isatty() else "", "commands": self.commands, "env": dict(list(os.environ.items())[:512]), "pane": pane if pane.isdigit() else ""} return self.frame(b"H", json.dumps(info).encode("utf-8")) def enter(self): self.stdin_fd = sys.stdin.fileno() try: self.saved = termios.tcgetattr(self.stdin_fd) tty.setraw(self.stdin_fd) except (termios.error, OSError): self.saved = None os.write(sys.stdout.fileno(), b"\x1b[?1049h\x1b[0m\x1b[2J\x1b[H") self.attached = True def leave(self): if not self.attached: return self.attached = False try: os.write(sys.stdout.fileno(), b"\x1b[0m\x1b[?25h\x1b[?1049l") except OSError: pass if self.saved is not None: try: termios.tcsetattr(self.stdin_fd, termios.TCSADRAIN, self.saved) except (termios.error, OSError): pass def write_out(self, data): fd = sys.stdout.fileno() view = memoryview(data) while view: try: written = os.write(fd, view) except BlockingIOError: select.select([], [fd], [], 1.0) continue view = view[written:] def on_winch(self, _signum, _frame): try: os.write(self.wake_w, b"w") except OSError: pass def run(self): sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) try: sock.connect(self.sock_path) except OSError: sys.stderr.write("no server running on %s\n" % self.sock_path) return 1 sock.sendall(MPLEX_MAGIC + self.hello()) self.wake_r, self.wake_w = os.pipe() os.set_blocking(self.wake_w, False) previous = signal.signal(signal.SIGWINCH, self.on_winch) if hasattr(signal, "SIGWINCH") else None buf = bytearray() code = 0 message = "" try: while True: watch = [sock, self.wake_r] + ([self.stdin_fd] if self.attached else []) try: ready, _w, _x = select.select(watch, [], []) except InterruptedError: continue if self.wake_r in ready: os.read(self.wake_r, 4096) rows, cols = self.size() sock.sendall(self.frame(b"R", json.dumps({"rows": rows, "cols": cols}).encode("ascii"))) if self.attached and self.stdin_fd in ready: data = os.read(self.stdin_fd, 4096) if not data: message = "[detached]" break sock.sendall(self.frame(b"I", data)) if sock in ready: data = sock.recv(262144) if not data: code = 1 message = "[lost server]" break buf += data done = self.handle_frames(sock, buf) if done is not None: code, message = done break finally: self.leave() if previous is not None: signal.signal(signal.SIGWINCH, previous) sock.close() os.close(self.wake_r) os.close(self.wake_w) if message: (sys.stderr if code else sys.stdout).write(message + "\n") return code def handle_frames(self, sock, buf): while len(buf) >= 5: length = struct.unpack(">I", bytes(buf[1:5]))[0] if len(buf) < 5 + length: return None kind, payload = bytes(buf[0:1]), bytes(buf[5:5 + length]) del buf[:5 + length] if kind == b"A": self.enter() elif kind == b"O": self.write_out(payload) elif kind == b"M": sys.stdout.write(payload.decode("utf-8", "replace") + "\n") sys.stdout.flush() elif kind == b"E": return 1, payload.decode("utf-8", "replace") elif kind in (b"D", b"X"): return 0, payload.decode("utf-8", "replace") elif kind == b"Z": self.suspend(sock) return None def suspend(self, sock): self.leave() os.kill(os.getpid(), signal.SIGTSTP) self.enter() rows, cols = self.size() sock.sendall(self.frame(b"R", json.dumps({"rows": rows, "cols": cols}).encode("ascii"))) class MplexCli: """tai.py --mplex <command...>: any mplex command; new-session and attach-session attach this terminal.""" @staticmethod def attaches(name, rest): if name == "attach-session": return True if name != "new-session": return False try: return not MplexArgs.parse(name, MPLEX_COMMAND_SPECS[name][1], rest, 0, None).has("d") except MplexError: return True @staticmethod def split_commands(words): """argv to commands: a lone ";" separates them, "\\;" is a literal semicolon argument.""" commands = [[]] for word in words: if word == ";": commands.append([]) else: commands[-1].append(";" if word == "\\;" else word) return [item for item in commands if item] @classmethod def main(cls, home, words): commands = cls.split_commands(words) or [["new-session"]] try: name = MplexCommands.resolve(commands[0][0]) except MplexError as exc: sys.stderr.write("%s\n" % exc) return 1 commands[0][0] = name words = commands[0] if cls.attaches(name, words[1:]): if os.environ.get(PLEXER_ACTIVE_ENV): sys.stderr.write("sessions should be nested with care, unset %s to force\n" % PLEXER_ACTIVE_ENV) return 1 if not (sys.stdin.isatty() and sys.stdout.isatty()): sys.stderr.write("open terminal failed: not a terminal\n") return 1 if name in ("new-session", "start-server"): if not MplexServer.ensure_running(home): sys.stderr.write("could not start the mplex server, see %s\n" % os.path.join(plexer_dir(home), MPLEX_LOG_NAME)) return 1 return MplexClient(home, commands).run() class NativePlexerBackend(PlexerBackend): """What the shell hook execs into: a new mplex session attached to this terminal.""" def __init__(self, home, profile, shell_cmd=None): self.home = home self.profile = profile self.shell_cmd = shell_cmd def run(self): if pty is None or termios is None or select is None or fcntl is None: return "error: pty/termios/select not available on this platform" return MplexCli.main(self.home, ["new-session"] + (list(self.shell_cmd) if self.shell_cmd else [])) def plexer_should_wrap(env): """The Python-side half of the detection flow (see research/shell-multiplexer-auto-attach/). The cheap, fast checks ($- interactive flag, isatty) already happened in the bashrc block itself before tai.py was even invoked -- this only covers what needs Python: never wrap if a plexer is already active, and never wrap if tmux already is.""" if env.get(PLEXER_ACTIVE_ENV): return False if env.get("TMUX"): return False if os.path.exists(os.path.join(os.path.expanduser("~"), ".tai-no-plexer")): return False return True PLEXER_BASHRC_MARK_BEGIN = "# tai plexer hook - start" PLEXER_BASHRC_MARK_END = "# tai plexer hook - end" PLEXER_BASHRC_BLOCK = ( PLEXER_BASHRC_MARK_BEGIN + "\n" "if [ -z \"$TAI_PLEXER_ACTIVE\" ] && [ -z \"$TMUX\" ] && [ ! -e \"$HOME/.tai-no-plexer\" ] && case \"$-\" in *i*) true;; *) false;; esac; then\n" " if [ -t 0 ] && [ -t 1 ]; then\n" " exec \"$HOME/.local/bin/tai.py\" --plexer-wrap\n" " fi\n" "fi\n" + PLEXER_BASHRC_MARK_END + "\n" ) FISH_PLEXER_BLOCK = ( PLEXER_BASHRC_MARK_BEGIN + "\n" "if status is-interactive\n" "and test -z \"$TAI_PLEXER_ACTIVE\"\n" "and test -z \"$TMUX\"\n" "and not test -e \"$HOME/.tai-no-plexer\"\n" "and isatty stdin\n" "and isatty stdout\n" " exec \"$HOME/.local/bin/tai.py\" --plexer-wrap\n" "end\n" + PLEXER_BASHRC_MARK_END + "\n" ) class Tools: def __init__(self, app): self.app = app self.calls = 0 self.handlers = { "shell": self.run_shell, "read_file": self.run_read_file, "write_file": self.run_write_file, "edit_file": self.run_edit_file, "web_search": self.run_web_search, "describe_image": self.run_describe_image, "web_fetch": self.run_web_fetch, "speak": self.run_speak, "listen": self.run_listen, "remember": self.run_remember, "recall": self.run_recall, "load_skill": self.run_load_skill, "get_current_terminal_content": self.run_terminal_content, "list_terminals": self.run_list_terminals, "tail_terminals": self.run_tail_terminals, "export_terminal_log": self.run_export_terminal_log, "plexer_send": self.run_plexer_send, "plexer_read": self.run_plexer_read, "plexer_type": self.run_plexer_type, "plexer_status": self.run_plexer_status, "plexer_show": self.run_plexer_show, "tui_screen": lambda args: self.run_tui("screen", args), "tui_keys": lambda args: self.run_tui("key", args), "tui_type": lambda args: self.run_tui("type", args), "tui_click": lambda args: self.run_tui("click", args), "tui_goto": lambda args: self.run_tui("goto", args), "tui_result": lambda args: self.run_tui("result", args), "fork": self.run_fork, "poll": self.run_poll, "ask_user": self.run_ask_user, "sysinfo": self.run_sysinfo, "create_skill": self.run_create_skill, "install_skill": self.run_install_skill, "store_secret": self.run_store_secret, "list_secrets": self.run_list_secrets, "delete_secret": self.run_delete_secret, "schedule": self.run_schedule, "unschedule": self.run_unschedule, "schedules": self.run_schedules, "record_save": self.run_record_save, "record_read": self.run_record_read, "record_search": self.run_record_search, "record_delete": self.run_record_delete, "graph_link": self.run_graph_link, "graph_query": self.run_graph_query, "delete_file": self.run_delete_file, "audit": self.run_audit, "restore": self.run_restore, "release": self.run_release, "tags": self.run_tags, "install": self.run_install, "create_bot": self.run_create_bot, "search": self.run_search, "mcp_add": self.run_mcp_add, "mcp_list": self.run_mcp_list, "mcp_enable": self.run_mcp_enable, "mcp_disable": self.run_mcp_disable, "mcp_remove": self.run_mcp_remove, "mcp_tools": self.run_mcp_tools, "mcp_call": self.run_mcp_call, "mcp_login": self.run_mcp_login, } self.secret_grants = set() self.read_files = set() self.live = False self.diff_preview = None self.board_shown = False def note_diff(self, path, old, new): self.diff_preview = {"path": path, "old": old, "new": new} def box_engine(self): try: return ensure_box(), "" except BackendError as exc: return "", "error: " + short_error(exc) def mcp_schemas(self): if self.app.store is None: return [] try: return McpBridge(self.app).schemas_for(self.active_profile) except (sqlite3.Error, OSError, ValueError): return [] def mcp_resolve_github(self, owner, repo): """Deterministic half of GitHub resolution. Returns (entry-ish, notes) or raises McpError.""" notes = [] base = "https://api.github.com/repos/%s/%s/contents/" % (owner, repo) def fetch(path): out = self.run_web_fetch({"url": base + path, "raw": True}) if not isinstance(out, str) or out.startswith("error:") or len(out) > 200000: return None try: data = json.loads(out) except ValueError: return None if not isinstance(data, dict) or data.get("type") != "file" or not data.get("content"): return None try: return base64.b64decode(data["content"]).decode("utf-8", "replace") except (ValueError, TypeError): return None for candidate in ("mcp.json", ".mcp.json", "server.json"): text = fetch(candidate) if text: notes.append("found %s, normalizing it" % candidate) entry, sub = mcp_normalize(text) return entry, notes + sub package = fetch("package.json") if package: try: meta = json.loads(package) except ValueError: meta = {} if isinstance(meta, dict): name = meta.get("name", "") if name: notes.append("package %s has no run contract, trying npx" % name) return {"name": mcp_sanitize_name(repo), "transport": "stdio", "command": "npx", "args": ["-y", name], "env": {}, "url": "", "headers": {}, "cwd": "", "allow": [], "deny": [], "enabled": None, "source": "github:%s/%s" % (owner, repo)}, notes for candidate in ("README.md", "readme.md"): text = fetch(candidate) if text: found = re.search(r"(npx|uvx)\s+([^\s\"'`]+)", text) if found: notes.append("run block found in %s" % candidate) return {"name": mcp_sanitize_name(repo), "transport": "stdio", "command": found.group(1), "args": ["-y", found.group(2)] if found.group(1) == "npx" else [found.group(2)], "env": {}, "url": "", "headers": {}, "cwd": "", "allow": [], "deny": [], "enabled": None, "source": "github:%s/%s" % (owner, repo)}, notes raise McpError("no run contract found (checked mcp.json, server.json, package.json, README)") def mcp_resolve_agent(self, owner, repo, profile): """Fork-based half of GitHub resolution. Returns (entry-ish, notes) or raises McpError.""" depth = getattr(self.app, "depth", 0) if depth >= FORK_MAX_DEPTH: raise McpError("at fork depth limit, cannot spawn resolver") brief = MCP_RESOLVER_BRIEF % ((owner, repo) * 7) try: out = default_runner(self.app.config, self.app.store.seal, profile, depth + 1, brief, MCP_RESOLVER_TIMEOUT, max_steps=MCP_RESOLVER_STEPS) except Exception as exc: raise McpError("resolver subagent failed: %s" % short_error(exc)) out = out or "" match = re.search(r"```json\s*(\{.*?\})\s*```", out, re.DOTALL) or re.search(r"(\{.*\})", out, re.DOTALL) if not match: raise McpError("resolver returned no JSON (first 200 chars: %r)" % out[:200]) try: data = json.loads(match.group(1)) except ValueError: raise McpError("resolver returned bad JSON: %.200s" % match.group(1)) if not isinstance(data, dict): raise McpError("resolver returned non-object JSON") if data.get("error"): raise McpError("resolver: %s (checked: %s)" % (data.get("error"), data.get("evidence", "?"))) entry, sub = mcp_normalize(json.dumps(data), mcp_sanitize_name(repo)) return entry, ["resolver subagent settled it"] + sub def run_mcp_add(self, args): source = str(args.get("source") or "").strip() profile = str(args.get("profile") or self.active_profile) if not source: return "error: source must be pasted JSON/TOML config or a github URL" target = mcp_github_target(source) if ("\n" not in source and "{" not in source and "[" not in source) else None notes = [] try: if target: try: entry, notes = self.mcp_resolve_github(target[0], target[1]) except McpError as first: try: entry, notes = self.mcp_resolve_agent(target[0], target[1], profile) notes = ["deterministic pass failed (%s), resolver subagent took over" % first] + notes except McpError as second: raise McpError("%s; resolver subagent also failed: %s" % (first, second)) entry["source"] = "github:%s/%s" % (target[0], target[1]) else: text, rewritten = mcp_rewrite_placeholders(source) notes += rewritten entry, sub = mcp_normalize(text, str(args.get("name") or "") or None) notes += sub except McpError as exc: if target: return ("error: could not resolve github %s/%s: %s\n" "finish it by hand: read the repo README for the run command, then call mcp_add " "with explicit JSON like {\"mcpServers\": {\"name\": {\"command\": \"npx\", \"args\": [...]}}}" % (target[0], target[1], exc)) return "error: %s" % exc name = str(args.get("name") or entry["name"] or "") if not MCP_SERVER_RE.match(name): return "error: bad server name '%s'" % name entry["name"] = name if entry["transport"] == "stdio": shown = " ".join([entry["command"]] + entry["args"]) flags = [word for word in ("sudo", "rm -rf", "curl", "wget", "ssh", "nc ") if word in shown] prompt = "mcp add stdio '%s'%s (runs with your privileges)" % (shown[:300], " FLAGS: %s" % ",".join(flags) if flags else "") else: prompt = "mcp add http %s (headers sealed, secrets resolve at call time)" % entry["url"][:300] if notes: prompt += " [%s]" % "; ".join(notes)[:300] if not self.app.ask_approval(prompt): return "denied by user" bridge = McpBridge(self.app) try: client = bridge.connect(entry, profile) except McpHttpError as exc: if exc.code == 401: return self.mcp_add_401(entry, profile, notes) return "error: verify failed, nothing stored: %s" % exc except McpError as exc: return "error: verify failed, nothing stored: %s" % exc try: try: tools = client.list_tools() except McpHttpError as exc: if exc.code == 401: return self.mcp_add_401(entry, profile, notes) return "error: verify failed, nothing stored: %s" % exc except McpError as exc: return "error: verify failed, nothing stored: %s" % exc finally: client.close() try: self.app.store.mcp_save_server(entry) except ValueError as exc: return "error: %s" % exc self.app.store.mcp_set_enabled(name, profile, entry["enabled"] if entry["enabled"] is not None else True) self.app.store.mcp_store_tools(name, tools) lines = ["added MCP server '%s' (%s, %d tools, enabled for %s)" % (name, entry["transport"], len(tools), profile)] lines += ["- %s: %s" % (item.get("name", ""), (item.get("description", "") or "")[:120]) for item in tools[:MCP_MAX_TOOLS]] if len(tools) > MCP_MAX_TOOLS: lines.append("- ...and %d more (cap %d offered)" % (len(tools) - MCP_MAX_TOOLS, MCP_MAX_TOOLS)) return "\n".join(lines) def mcp_add_401(self, entry, profile, notes): if entry["transport"] != "http": return "error: verify failed, nothing stored: http 401" oauth = McpOAuth(self.app.store) access, _refresh = oauth.load_tokens(entry["name"], profile) if access: return "error: verify failed: stored OAuth tokens refused, run mcp_login '%s'" % entry["name"] if any(key.lower() == "authorization" for key in entry["headers"]): return "error: verify failed: http 401, check the configured credentials" entry["enabled"] = False try: self.app.store.mcp_save_server(entry) except ValueError as exc: return "error: %s" % exc self.app.store.mcp_set_enabled(entry["name"], profile, False) lines = ["stored MCP server '%s' disabled: it answered 401, so it needs OAuth login" % entry["name"], "run mcp_login '%s' to approve in your browser, then it verifies and enables" % entry["name"]] return "\n".join(lines + ["(note: %s)" % note for note in notes]) def run_mcp_list(self, args): rows = self.app.store.mcp_list_servers() if not rows: return "no MCP servers registered" lines = [] for row in rows: if row["transport"] == "stdio": detail = " ".join([row["command"]] + row["args"])[:120] else: detail = row["url"][:120] enabled = [found[0] for found in self.app.store.db.execute("SELECT profile FROM mcp_server_profiles WHERE server = ? AND enabled = 1", (row["name"],)).fetchall()] cached = self.app.store.mcp_cached_tools(row["name"]) lines.append("%s (%s) %s | profiles: %s | tools: %d | from %s" % (row["name"], row["transport"], detail, ", ".join(sorted(enabled)) or "none", len(cached), row["source"] or "?")) return "\n".join(lines) def run_mcp_enable(self, args): server = str(args.get("server") or "") profile = str(args.get("profile") or self.active_profile) if self.app.store.mcp_get_server(server) is None: return "error: unknown MCP server '%s'" % server self.app.store.mcp_set_enabled(server, profile, True) return "MCP server '%s' enabled for %s" % (server, profile) def run_mcp_disable(self, args): server = str(args.get("server") or "") profile = str(args.get("profile") or self.active_profile) if self.app.store.mcp_get_server(server) is None: return "error: unknown MCP server '%s'" % server self.app.store.mcp_set_enabled(server, profile, False) return "MCP server '%s' disabled for %s" % (server, profile) def run_mcp_remove(self, args): server = str(args.get("server") or "") if self.app.store.mcp_get_server(server) is None: return "error: unknown MCP server '%s'" % server if not self.app.ask_approval("mcp remove '%s' (server, profile rows, pins, cached tools)" % server): return "denied by user" profiles = [found[0] for found in self.app.store.db.execute( "SELECT DISTINCT profile FROM mcp_server_profiles WHERE server = ?", (server,)).fetchall()] self.app.store.mcp_remove_server(server) oauth = McpOAuth(self.app.store) for profile in set(profiles + [self.active_profile]): oauth.drop_tokens(server, profile) return "removed MCP server '%s'" % server def run_mcp_tools(self, args): server = str(args.get("server") or "") rows = [self.app.store.mcp_get_server(server)] if server else self.app.store.mcp_list_servers() rows = [row for row in rows if row is not None] if server and not rows: return "error: unknown MCP server '%s'" % server if not rows: return "no MCP servers registered" lines = [] for row in rows: pins = self.app.store.mcp_tool_pins(row["name"]) for item in self.app.store.mcp_cached_tools(row["name"]): lines.append("%s/%s%s: %s" % (row["name"], item["tool"], "" if item["tool"] in pins else " (unpinned)", (item["description"] or "")[:150])) return "\n".join(lines) or "no cached tools (call mcp_add to verify)" def run_mcp_call(self, args): server = str(args.get("server") or "") tool = str(args.get("tool") or "") call_args = args.get("args") or {} if not server or not tool: return "error: mcp_call needs server and tool" return McpBridge(self.app).call(server, tool, call_args, self.active_profile) def run_mcp_login(self, args): server = str(args.get("server") or "") profile = str(args.get("profile") or self.active_profile) row = self.app.store.mcp_get_server(server) if row is None: return "error: unknown MCP server '%s'" % server if row["transport"] != "http": return "error: local stdio servers need no login" if not self.app.ask_approval("mcp login '%s' (opens a browser, waits up to %ds)" % (server, MCP_OAUTH_TIMEOUT)): return "denied by user" try: note = McpOAuth(self.app.store).login(row, profile) except McpError as exc: return "error: %s" % exc bridge = McpBridge(self.app) try: client = bridge.connect(row, profile) except McpError as exc: return "error: login ok but verify failed: %s" % exc try: try: tools = client.list_tools() except McpError as exc: return "error: login ok but verify failed: %s" % exc finally: client.close() self.app.store.mcp_set_enabled(server, profile, True) self.app.store.mcp_store_tools(server, tools) return "%s\nverified %d tools, enabled for %s" % (note, len(tools), profile) def dispatch(self, name, raw_arguments): self.calls += 1 started = time.time() try: args = raw_arguments if isinstance(raw_arguments, dict) else json.loads(raw_arguments or "{}") except (TypeError, ValueError): self.log_call(name, raw_arguments, started, "bad_args", "error: arguments are not valid JSON") return "error: arguments are not valid JSON" handler = self.handlers.get(name) if handler is None and name.startswith(MCP_TOOL_PREFIX): resolved = McpBridge(self.app).resolve(name) if resolved is not None: server, tool = resolved handler = lambda call_args: McpBridge(self.app).call(server, tool, call_args, self.active_profile) if handler is None: self.log_call(name, args, started, "unknown_tool", "error: unknown tool " + name) return "error: unknown tool " + name try: result = handler(args) except Denied: self.log_call(name, args, started, "denied", "denied") raise except Exception as exc: self.log_call(name, args, started, "error", "error: " + short_error(exc)) return "error: " + short_error(exc) if self.app.store is not None: result = self.app.store.redact(result, self.active_profile) self.log_call(name, args, started, "ok", result) return result def log_call(self, name, args, started, outcome, result): if not LOG.enabled: return exec_ms = int((time.time() - started) * 1000) if isinstance(args, dict): shown = dict((key, "***" if str(key).lower() in LOG_MASKED_ARGS else args[key]) for key in args) else: shown = args if isinstance(args, str) else short_error(args) text = result if isinstance(result, str) else "" if outcome == "ok": message = "tool %s finished in %dms" % (name, exec_ms) elif outcome == "denied": message = "tool %s denied in %dms" % (name, exec_ms) elif outcome == "unknown_tool": message = "tool %s unknown" % name elif outcome == "bad_args": message = "tool %s got bad arguments" % name else: message = "tool %s failed in %dms: %s" % (name, exec_ms, short_error(result)) meta = {"name": name, "args": shown, "exec_ms": exec_ms, "outcome": outcome, "result_chars": len(text)} if outcome == "error": meta["error"] = short_error(result) LOG.event("INFO", "tool", "call", message, profile=self.active_profile, bot=getattr(self.app, "bot", ""), meta=meta) def secret_grant(self, kind, key, names): token = (kind, key, tuple(sorted(names))) if token in self.secret_grants: return True if not self.app.ask_approval("share secrets (%s) with %s" % (", ".join(sorted(names)), kind)): return False self.secret_grants.add(token) return True def resolve_secrets(self, names): vault = {} for name in names: value = self.app.store.load_secret(name, self.active_profile) if value is None: return None, "error: unknown secret '%s'" % name vault[name] = value return vault, "" def shell_is_safe(self, command): parts = command.split() if not parts: return False if parts[0] == "git": return len(parts) > 1 and parts[1] in ("status", "diff", "log", "show", "branch", "remote") return parts[0] in SAFE_COMMANDS def snapshot_shell_targets(self, command, workdir): if self.app.store is None: return 0 profile = self.active_profile count = 0 for found in shell_target_paths(command, workdir): if not os.path.isfile(found): continue text, override = read_capped(found) if text is None: continue try: self.app.store.audit_event(self.audit_actor(), "shell-snapshot", found, command[:200], old=text, tags=["shell"], old_size=override, profile=profile) self.app.store.upsert_file_record(found, text, override, profile) count += 1 except (sqlite3.Error, OSError): continue return count def run_shell(self, args): command = str(args.get("command") or "").strip() if not command: return "error: empty command" secret_names = args.get("secrets") or [] if not isinstance(secret_names, list) or any(not isinstance(item, str) for item in secret_names): return "error: secrets must be a list of names" vault, failure = self.resolve_secrets(secret_names) if secret_names else ({}, "") if failure: return failure if vault and not self.secret_grant("shell", "", sorted(vault)): return "denied by user" if self.app.env == "sandbox": return self.run_shell_box(command, str(args.get("workdir") or "/"), vault) workdir = str(args.get("workdir") or os.getcwd()) if not self.app.config.auto_approve and not self.shell_is_safe(command): try: approved = self.app.ask_approval(command) except Denied as denied: if not Approval.is_affirmative(denied.guidance): raise approved = True if not getattr(self.app, "quiet", False): print(paint("taking %r as approval" % denied.guidance[:80], Ansi.YELLOW)) if not approved: return "denied by user" self.snapshot_shell_targets(command, workdir) env = dict(os.environ) for name, value in vault.items(): env[secret_env_name(name)] = value stream = self.shell_stream(command) code, output, elapsed, timed_out = run_live(command, shell=True, cwd=workdir, env=env, stream=stream, ask=self.shell_ask(command)) if stream is not None: stream.close(stream_status(code, output, elapsed, timed_out)) if timed_out: return "error: timed out after %d seconds" % SHELL_TIMEOUT return "exit %d\n%s%s" % (code, spill_or_truncate(self.app.store, "output", "shell: " + command[:80], output.strip() or "(no output)", ["shell"], self.active_profile), curl_hint(command)) def shell_stream(self, command): """Where a running command's output goes live: the chat scene's own window when the app provides one, else the REPL's scrolling window, else nowhere.""" factory = getattr(self.app, "stream_factory", None) if factory is not None: store = self.app.store return factory(store.redact(command, self.active_profile) if store is not None else command) return LiveStream() if self.live and sys.stdout.isatty() else None def shell_ask(self, command): """ask(text, secret) for a command that stops on a prompt (sudo, ssh, a y/n), or None when nobody can answer: autonomous mode, background workers and non-interactive stdin keep the old behavior of a command that never gets input.""" app = self.app if getattr(app, "auto", False) or not getattr(app, "persist", True) or getattr(app, "depth", 0) > 0: return None prompter = getattr(app, "prompter", None) def answered(value): if value is not None: app.user_stepped_in = True return value if prompter is not None: return lambda text, secret: answered(prompter("ask", "%s asks:\n%s" % (command[:60], text), [])) if not (sys.stdin.isatty() and sys.stdout.isatty()): return None def repl_ask(text, secret): try: line = getpass.getpass(paint("» %s " % text, Ansi.YELLOW)) if secret else input(paint("» %s " % text, Ansi.YELLOW)) except (EOFError, KeyboardInterrupt): return None return answered(line) return repl_ask def run_shell_box(self, command, workdir, vault): engine, failure = self.box_engine() if not engine: return failure extra = ["--workdir", workdir] for name, value in vault.items(): extra += ["-e", "%s=%s" % (secret_env_name(name), value)] try: stream = self.shell_stream(command) ask = self.shell_ask(command) code, output, elapsed, timed_out = run_live([engine, "exec", *extra, "-it" if ask else "-i", BOX_NAME, "sh", "-c", command], stream=stream, ask=ask) except (OSError, subprocess.SubprocessError) as exc: return "error: " + short_error(exc) if stream is not None: stream.close(stream_status(code, output, elapsed, timed_out)) if timed_out: return "error: timed out after %d seconds" % SHELL_TIMEOUT return "exit %d\n%s%s" % (code, spill_or_truncate(self.app.store, "output", "shell: " + command[:80], output.strip() or "(no output)", ["shell"], self.active_profile), curl_hint(command)) def run_read_file(self, args): path = str(args.get("path") or "") if self.app.env == "sandbox": result = self.box_read(path) if not result.startswith("error:"): self.read_files.add(("box", path)) return result if not path or not os.path.isfile(path): return "error: no such file" if os.path.getsize(path) > 200000: return "error: file too large" try: with open(path, "r", encoding="utf-8", errors="replace") as handle: result = truncate(handle.read(), 8000, 2000) except OSError as exc: return "error: " + short_error(exc) self.read_files.add(("home", os.path.abspath(path))) return result def audit_actor(self): if getattr(self.app, "persist", True) and getattr(self.app, "depth", 0) == 0: return "main" return "worker" @property def active_profile(self): profile = getattr(self.app, "profile", None) if profile: return profile store = getattr(self.app, "store", None) return getattr(store, "profile", None) or "default" def audit_file(self, action, path, old, new, message="", tags=(), old_size=None, new_size=None): if self.app.store is None: return None profile = self.active_profile try: row_id = self.app.store.audit_event(self.audit_actor(), action, path, message, old, new, ["file", action] + list(tags), old_size, new_size, profile) except (sqlite3.Error, OSError): return False try: if action == "delete": self.app.store.delete_file_record(path, profile) elif new is not None: self.app.store.upsert_file_record(path, new, new_size, profile) elif old is not None: self.app.store.upsert_file_record(path, old, old_size, profile) except (sqlite3.Error, OSError): pass return row_id def box_read_raw(self, path, limit=200000): engine, failure = self.box_engine() if not engine: return None, failure try: done = box_exec(engine, ["sh", "-c", "wc -c < \"$1\" && head -c \"$2\" \"$1\"", "box", path, str(limit + 1)], timeout=60) except (OSError, subprocess.SubprocessError) as exc: return None, "error: " + short_error(exc) if done.returncode != 0: return None, "error: no such file" head, _, rest = done.stdout.decode("utf-8", "replace").partition("\n") try: size = int(head.strip()) except ValueError: return None, "error: unreadable file" return size, rest def box_read(self, path): if not path: return "error: empty path" engine, failure = self.box_engine() if not engine: return failure try: done = box_exec(engine, ["head", "-c", "200000", path], timeout=60) except (OSError, subprocess.SubprocessError) as exc: return "error: " + short_error(exc) if done.returncode != 0: return "error: no such file" return truncate(done.stdout.decode("utf-8", "replace"), 8000, 2000) def box_write(self, path, content, old=_UNSET, action="write", message="", preview=True): engine, failure = self.box_engine() if not engine: return failure if ("box", path) not in self.read_files: try: probe = box_exec(engine, ["test", "-f", path], timeout=30) except (OSError, subprocess.SubprocessError): probe = None if probe is not None and probe.returncode == 0: return "error: write denied for '%s', read it first with read_file" % path if old is _UNSET: old, old_size = self.box_old_image(path) else: old_size = None parent = os.path.dirname(path) or "." try: done = box_exec(engine, ["sh", "-c", "mkdir -p \"$1\" && cat > \"$2\"", "box", parent, path], input_data=content.encode("utf-8"), timeout=60) except (OSError, subprocess.SubprocessError) as exc: return "error: " + short_error(exc) if done.returncode != 0: return "error: " + done.stderr.decode("utf-8", "replace")[:200] self.read_files.add(("box", path)) audited = self.audit_file(action, "sandbox:" + path, old, content, message, (), old_size, None) note = "" if audited is not False else " (audit failed)" if preview and old_size is None: self.note_diff(path, old, content) return "wrote %s%s" % (path, note) def box_old_image(self, path): size, raw = self.box_read_raw(path, AUDIT_MAX_CHARS) if size is None: return None, None if size <= AUDIT_MAX_CHARS: return raw, None return raw[:AUDIT_MAX_CHARS], size def run_write_file(self, args): path = str(args.get("path") or "") if not path: return "error: empty path" if self.app.env == "sandbox": return self.box_write(path, str(args.get("content") or "")) key = ("home", os.path.abspath(path)) if os.path.isfile(path) and key not in self.read_files: return "error: write denied for '%s', read it first with read_file" % path content = str(args.get("content") or "") if os.path.isfile(path): old, old_size = read_capped(path) else: old, old_size = None, None try: parent = os.path.dirname(os.path.abspath(path)) os.makedirs(parent, exist_ok=True) with open(path, "w", encoding="utf-8") as handle: handle.write(content) self.read_files.add(key) except OSError as exc: return "error: " + short_error(exc) audited = self.audit_file("write", os.path.abspath(path), old, content, "", (), old_size, None) note = "" if audited is not False else " (audit failed)" if old_size is None: self.note_diff(path, old, content) return "wrote %s%s" % (path, note) def run_edit_file(self, args): path = str(args.get("path") or "") find = str(args.get("find") or "") if self.app.env == "sandbox": return self.box_edit(path, find, str(args.get("replace") or "")) if not path or not os.path.isfile(path): return "error: no such file" if not find: return "error: empty match" if ("home", os.path.abspath(path)) not in self.read_files: return "error: edit denied for '%s', read it first with read_file" % path try: with open(path, "r", encoding="utf-8", errors="replace") as handle: content = handle.read() except OSError as exc: return "error: " + short_error(exc) if content.count(find) != 1: return "error: match is not unique (%d occurrences)" % content.count(find) updated = content.replace(find, str(args.get("replace") or "")) try: with open(path, "w", encoding="utf-8") as handle: handle.write(updated) except OSError as exc: return "error: " + short_error(exc) audited = self.audit_file("edit", os.path.abspath(path), content, updated) note = "" if audited is not False else " (audit failed)" self.note_diff(path, content, updated) return "edited %s%s" % (path, note) def run_web_search(self, args): query = str(args.get("query") or "").strip() if not query: return "error: empty query" params = {"query": query} if args.get("images"): params["type"] = "images" if args.get("content"): params["content"] = "true" url = RSEARCH_BASE + "/search?" + urllib.parse.urlencode(params) request = urllib.request.Request(url, headers={"Accept": "application/json"}) try: with urllib.request.urlopen(request, timeout=30) as response: data = json.loads(response.read().decode("utf-8", "replace")) except (OSError, ValueError) as exc: return "error: search failed: " + short_error(exc) lines = [] for pos, item in enumerate(data.get("results") or [], 1): lines.append("%d. %s\n %s\n %s" % (pos, item.get("title"), item.get("url"), (item.get("description") or "")[:400])) if item.get("content"): lines.append(" content: " + truncate(str(item["content"]), 2000, 500)) self.result_note = "%d results" % len(data.get("results") or []) return "\n".join(lines) if lines else "no results" def run_describe_image(self, args): source = str(args.get("source") or "").strip() if not source: return "error: empty source" if source.startswith(("http://", "https://")): url = RSEARCH_BASE + "/describe?" + urllib.parse.urlencode({"url": source}) request = urllib.request.Request(url, headers={"Accept": "application/json"}) else: path = os.path.expanduser(source) try: if os.path.getsize(path) > 10 * 1024 * 1024: return "error: image larger than 10 MB" with open(path, "rb") as handle: body = handle.read() except OSError as exc: return "error: " + short_error(exc) request = urllib.request.Request(RSEARCH_BASE + "/describe", data=body, headers={"Accept": "application/json", "Content-Type": "application/octet-stream"}, method="POST") try: with urllib.request.urlopen(request, timeout=60) as response: data = json.loads(response.read().decode("utf-8", "replace")) except (OSError, ValueError) as exc: return "error: describe failed: " + short_error(exc) text = str(data.get("description") or data.get("error") or "no description") self.result_note = text return text def run_web_fetch(self, args): url = str(args.get("url") or "").strip() if not url.startswith(("http://", "https://")): return "error: url must start with http:// or https://" method = str(args.get("method") or "GET").strip().upper() if method not in FETCH_METHODS: return "error: method must be one of %s" % ", ".join(FETCH_METHODS) headers = {"User-Agent": "tai/%s" % VERSION} custom = args.get("headers") or {} if not isinstance(custom, dict) or any(not isinstance(name, str) or not isinstance(value, str) for name, value in custom.items()): return "error: headers must be an object of string names to string values" for name, value in custom.items(): if not re.fullmatch(r"[A-Za-z0-9-]+", name.strip()) or "\n" in value or "\r" in value: return "error: invalid header '%s'" % name[:40] headers[name.strip()] = value data = None if args.get("body") is not None: if not isinstance(args.get("body"), str): return "error: body must be a string" data = args.get("body").encode("utf-8") try: timeout = int(args.get("timeout") or 30) except (TypeError, ValueError): return "error: invalid timeout" timeout = max(1, min(timeout, 120)) auth_name = str(args.get("auth_secret") or "").strip() if auth_name: value = self.app.store.load_secret(auth_name, self.active_profile) if value is None: return "error: unknown secret '%s'" % auth_name header = str(args.get("auth_header") or "Authorization").strip() or "Authorization" if not re.fullmatch(r"[A-Za-z0-9-]+", header): return "error: invalid auth header name" prefix = args.get("auth_prefix") if args.get("auth_prefix") is not None else "Bearer " prefix = str(prefix) if "\n" in prefix or "\r" in prefix: return "error: invalid auth prefix" host = urllib.parse.urlparse(url).hostname or "" if not self.secret_grant("web_fetch", host, [auth_name]): return "denied by user" headers[header] = prefix + value request = urllib.request.Request(url, data=data, headers=headers, method=method) try: with urllib.request.urlopen(request, timeout=timeout) as response: status = getattr(response, "status", 200) fields = getattr(response, "headers", None) content_type = fields.get("Content-Type", "") if fields is not None else "" payload = response.read(200000) except urllib.error.HTTPError as exc: try: detail = exc.read(2000).decode("utf-8", "replace").strip() except OSError: detail = "" if detail: return "error: HTTP %d: %s" % (exc.code, truncate(detail, 1000, 300)) return "error: HTTP %d: %s" % (exc.code, short_error(exc)) except OSError as exc: return "error: fetch failed: " + short_error(exc) text = payload.decode("utf-8", "replace") if not args.get("raw") and ("html" in content_type or not content_type): stripped = re.sub(r"(?s)<script.*?</script>|<style.*?</style>", " ", text) stripped = re.sub(r"<[^>]+>", " ", stripped) text = re.sub(r"\s+", " ", html.unescape(stripped)).strip() text = truncate(text.strip(), 5000, 1000) or "empty page" head = "HTTP %d · %s · %s" % (status, content_type.split(";")[0].strip() or "unknown", human_size(len(payload))) return "%s\n%s" % (head, text) def run_speak(self, args): text = str(args.get("text") or "").strip() if not text: return "error: empty text" voice = str(args.get("voice") or self.app.config.voice) try: audio = edge_synthesize(text[:8000], voice) except (BackendError, OSError) as exc: return "error: speech failed: " + short_error(exc) if not audio: return "error: speech returned no audio" path = os.path.join(self.app.config.audio_dir, "speech-%d.mp3" % int(time.time())) try: with open(path, "wb") as handle: handle.write(audio) except OSError as exc: return "error: " + short_error(exc) return "saved %s (%d bytes), played: %s" % (path, len(audio), play_audio(path)) def run_listen(self, args): try: seconds = max(1, min(30, int(args.get("seconds") or 5))) except (TypeError, ValueError): seconds = 5 return listen_audio(seconds, self.app.config.audio_dir) def run_remember(self, args): instruction = str(args.get("instruction") or "").strip() if not instruction: return "error: empty instruction" current = self.app.system_message prompt = [ {"role": "system", "content": MERGE_SYSTEM}, {"role": "user", "content": "CURRENT SYSTEM PROMPT:\n" + current + "\n\nMEMORY INSTRUCTION:\n" + instruction}, ] spinner = Spinner("merging memory") spinner.start() try: merged = self.app.chat.complete(prompt, ephemeral=True) except BackendError as exc: return "error: memory merge failed: " + short_error(exc) finally: spinner.stop() text = merged["content"].strip() text = re.sub(r"^```[a-zA-Z]*\n|\n```$", "", text).strip() if len(text) < 20: return "error: merged system message too short, rejected" if len(text) > SYSTEM_MAX_CHARS: return "error: merged system message exceeds %d chars, rejected" % SYSTEM_MAX_CHARS self.app.update_system(text) return "system message updated for profile '%s' (%d to %d chars)" % (self.app.profile, len(current), len(text)) def run_recall(self, args): query = str(args.get("query") or "").strip() if not query: return "error: empty query" tags = args.get("tags") or [] if not isinstance(tags, list) or any(not isinstance(item, str) for item in tags): return "error: tags must be a list of names" rows = self.app.store.search_events(self.active_profile, query, tags=tags) if not rows: return "no memories matching " + query return "\n".join("[%s %s/%s] %s" % (stamp, role, kind, text[:300]) for stamp, role, kind, text in rows) def box_edit(self, path, find, replace): if not path: return "error: empty path" if not find: return "error: empty match" if ("box", path) not in self.read_files: return "error: edit denied for '%s', read it first with read_file" % path size, content = self.box_read_raw(path) if size is None: return content if size > 200000: return "error: file too large to edit safely (%d bytes)" % size if content.count(find) != 1: return "error: match is not unique (%d occurrences)" % content.count(find) written = self.box_write(path, content.replace(find, replace), old=content, action="edit") if written.startswith("wrote "): return "edited %s%s" % (path, written[len("wrote " + path):]) return written def run_load_skill(self, args): name = str(args.get("name") or "").strip() skill = self.app.skills.get(name) if skill is None: blueprint = SKILL_BLUEPRINTS.get(name) if blueprint is None: known = ", ".join(sorted(self.app.skills)) or "none" return "error: unknown skill, known: " + known return "building skill '%s' from blueprint, this runs deep research and takes a while:\n%s" % (name, self.run_create_skill({"name": name, "brief": blueprint["brief"], "scope": blueprint["scope"]})) parts = [skill["body"].strip(), skill_provenance_note(skill)] extras = skill_extra_files(skill["root"]) if extras: parts.append("bundled files:\n" + "\n".join(extras)) return "\n\n".join(parts) def run_terminal_content(self, args): try: lines = max(10, min(500, int(args.get("lines") or 100))) except (TypeError, ValueError): lines = 100 target = str(args.get("target") or "").strip()[:128] history = shell_history_section() if not target: socket_path = os.environ.get(PLEXER_SOCKET_ENV) if socket_path: return self.run_terminal_content_native(socket_path, lines, history) target = os.environ.get("TMUX_PANE", "").strip()[:128] if not shutil.which("tmux"): return "tmux not available" if target: header = ["pane %s" % target] else: pane_header = tmux_pane_header() header = [pane_header] if pane_header else [] argv = ["tmux", "capture-pane", "-p", "-S", "-%d" % lines] if target: argv += ["-t", target] try: done = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=10) except (OSError, subprocess.SubprocessError) as exc: return "terminal capture failed: " + short_error(exc) + history if done.returncode != 0: return "terminal capture failed: " + (done.stderr or "").strip()[:200] + history if not done.stdout.strip(): return "terminal pane is empty" + history return "\n".join(header + [truncate(done.stdout, 6000, 2000)]) + history def run_terminal_content_native(self, socket_path, lines, history): """Current-pane capture under tai's own multiplexer: one socket read, no tmux, no scan.""" try: content = NativePlexerController(socket_path, os.environ.get(PLEXER_PANE_ENV)).read_pane(lines) except PlexerError as exc: return "terminal capture failed: " + str(exc) + history if not (content or "").strip(): return "terminal pane is empty" + history pane = (os.environ.get(PLEXER_PANE_ENV) or "").strip() header = "native pane %s" % pane if pane else "native plexer pane" return "\n".join([header, truncate(content, 6000, 2000)]) + history def run_list_terminals(self, args): panes, error = tmux_panes() if error: return error if not panes: return "no open terminals" sessions = len({pane["ref"].split(":")[0] for pane in panes}) windows = len({pane["ref"].rsplit(".", 1)[0] for pane in panes}) lines = ["%d session%s · %d window%s · %d pane%s" % ( sessions, "" if sessions == 1 else "s", windows, "" if windows == 1 else "s", len(panes), "" if len(panes) == 1 else "s")] for pane in panes[:TERMINAL_LIST_MAX_PANES]: lines.append("%s %s %s %s%s" % (pane["ref"], pane["id"], pane["command"], pane["path"], " *" if pane["active"] else "")) if len(panes) > TERMINAL_LIST_MAX_PANES: lines.append("... +%d more" % (len(panes) - TERMINAL_LIST_MAX_PANES)) return "\n".join(lines) def run_tail_terminals(self, args): try: lines = max(1, min(TAIL_TERMINALS_MAX_LINES, int(args.get("lines") or TAIL_TERMINALS_DEFAULT_LINES))) except (TypeError, ValueError): lines = TAIL_TERMINALS_DEFAULT_LINES panes, error = tmux_panes() if error: return error if not panes: return "no open terminals" blocks = [] for pane in panes[:TAIL_TERMINALS_MAX_PANES]: try: done = subprocess.run(["tmux", "capture-pane", "-p", "-t", pane["id"], "-S", "-%d" % lines], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=10) except (OSError, subprocess.SubprocessError): blocks.append("=== %s %s (%s) ===\n(capture failed)" % (pane["ref"], pane["id"], pane["command"])) continue if done.returncode != 0 or not done.stdout.strip(): blocks.append("=== %s %s (%s) ===\n(empty)" % (pane["ref"], pane["id"], pane["command"])) continue blocks.append("=== %s %s (%s) ===\n%s" % (pane["ref"], pane["id"], pane["command"], done.stdout.strip()[:TAIL_PER_PANE_CHARS])) if len(panes) > TAIL_TERMINALS_MAX_PANES: blocks.append("... +%d more panes, capture one with get_current_terminal_content" % (len(panes) - TAIL_TERMINALS_MAX_PANES)) return truncate("\n\n".join(blocks), 6000, 2000) def run_export_terminal_log(self, args): if not os.environ.get("TMUX"): return "error: tmux is not active (this tool only works inside a live tmux session)" if not shutil.which("tmux"): return "error: tmux not available" extra_tags = args.get("tags") or [] if not isinstance(extra_tags, list) or any(not isinstance(item, str) for item in extra_tags): return "error: tags must be a list of strings" try: done = subprocess.run(["tmux", "capture-pane", "-p", "-S", "-"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=30) except (OSError, subprocess.SubprocessError) as exc: return "error: " + short_error(exc) if done.returncode != 0: return "error: tmux capture failed: " + (done.stderr or "").strip()[:200] raw = done.stdout if not raw.strip(): return "terminal pane history is empty" profile = self.active_profile pane_header = tmux_pane_header() or "pane" stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") slug = re.sub(r"[^A-Za-z0-9_.-]+", "-", pane_header).strip("-") or "pane" folder = os.path.join(self.app.config.home, "termlogs", profile) try: os.makedirs(folder, exist_ok=True) path = os.path.join(folder, "%s-%s.log" % (slug, stamp)) with open(path, "w", encoding="utf-8") as handle: handle.write(raw) except OSError as exc: return "error: " + short_error(exc) if self.app.store is None: return "captured %s to %s (%d bytes); vault unavailable, not indexed for search" % (pane_header, path, len(raw.encode("utf-8"))) title = "tmux %s captured %s" % (pane_header, stamp) record_id = self.app.store.add_record("termlog", title, raw, ["termlog"] + extra_tags, profile) chunks = chunk_lines(collapse_repeated_lines(raw)) self.app.store.add_chunks("record", record_id, chunks, profile) return "captured %s to %s (%d bytes), indexed as %s in %d chunks -- use search (kind chunk) to answer questions about this terminal session" % (pane_header, path, len(raw.encode("utf-8")), record_id, len(chunks)) def run_plexer_send(self, args): controller = PlexerControllerFactory.detect() if controller is None: return "error: no plexer session active (neither tai's native wrapper nor tmux)" text = str(args.get("text") or "") try: controller.send_keys(text, enter=bool(args.get("enter"))) except PlexerError as exc: return "error: " + str(exc) return "sent %d chars via %s backend%s" % (len(text), controller.kind, " + enter" if args.get("enter") else "") def run_plexer_read(self, args): controller = PlexerControllerFactory.detect() if controller is None: return "error: no plexer session active (neither tai's native wrapper nor tmux)" try: lines = max(1, min(int(args.get("lines") or 200), 5000)) except (TypeError, ValueError): lines = 200 try: content = controller.read_pane(lines) except PlexerError as exc: return "error: " + str(exc) return truncate(content or "(empty)", 6000, 2000) def run_plexer_type(self, args): controller = PlexerControllerFactory.detect() if controller is None: return "error: no plexer session active (neither tai's native wrapper nor tmux)" text = str(args.get("text") or "") if not text: return "error: empty text" try: delay = max(0.0, min(float(args.get("delay") or 0.03), 2.0)) except (TypeError, ValueError): delay = 0.03 try: controller.type_text(text, delay=delay, enter=args.get("enter") is not False) except PlexerError as exc: return "error: " + str(exc) return "typed %d chars via %s backend at %.2fs/char" % (len(text), controller.kind, delay) def run_tui(self, op, args): """Drive the running grr TUI through its control socket (see GrrControl).""" payload = {"op": op} payload.update({k: v for k, v in args.items() if v is not None}) if op == "screen" and args.get("until"): payload["wait"] = (args.get("timeout") or GRR_CONTROL_WAIT) + 2 return json.dumps(grr_control_call(payload)) def run_plexer_status(self, args): controller = PlexerControllerFactory.detect() if controller is None: return "no plexer session active (neither tai's native wrapper nor tmux)" try: data = controller.status() except PlexerError as exc: return "error: " + str(exc) return json.dumps(data) def run_plexer_show(self, args): spec = args.get("spec") if not isinstance(spec, dict): return "error: spec must be a JSON object (the flow contract is uiflow.md)" app, error = validate_flow_spec(spec) if error: return "error: invalid flow spec: " + error if args.get("timeout") is not None: try: override = int(args.get("timeout")) except (TypeError, ValueError): return "error: timeout must be an integer number of seconds" if not PLEXER_FLOW_TIMEOUT_MIN <= override <= PLEXER_FLOW_TIMEOUT_MAX: return "error: timeout must be %d to %d seconds" % (PLEXER_FLOW_TIMEOUT_MIN, PLEXER_FLOW_TIMEOUT_MAX) app["timeout"] = override if getattr(self.app, "auto", False): return "plexer_show unavailable in autonomous mode: decide yourself and state the assumption" if not getattr(self.app, "persist", True) or getattr(self.app, "depth", 0) > 0: return "plexer_show unavailable to background workers: decide yourself and state the assumption" controller = PlexerControllerFactory.detect() if controller is None: return "error: no plexer session active (neither tai's native wrapper nor tmux)" try: outcome = PlexerFlow(app).run(controller) except PlexerError as exc: return "error: " + str(exc) self.app.user_stepped_in = True return json.dumps(outcome) def run_fork(self, args): task = str(args.get("task") or "").strip() if not task: return "error: empty task" if self.app.depth >= FORK_MAX_DEPTH: return "error: fork depth limit reached" try: timeout = max(30, min(3600, int(args.get("timeout") or 600))) except (TypeError, ValueError): timeout = 600 asked = str(args.get("profile") or "").strip() if asked and not PROFILE_RE.match(asked): return "error: invalid profile name" if asked and asked != self.active_profile: return "error: cannot fork as another profile" profile = self.active_profile try: agent_id = spawn_agent(task, profile, timeout, self.app.config, self.app.store.seal, self.app.depth + 1, self.app.runner_override) except AgentsFull as exc: return "error: %s, poll existing agents first" % exc return "agent %d started: %s. Collect its result with poll." % (agent_id, task[:80]) def run_poll(self, args): try: agent_id = int(args.get("id") or 0) except (TypeError, ValueError): return "error: invalid agent id" try: wait = max(0, min(120, int(args.get("wait") or 0))) except (TypeError, ValueError): wait = 0 status, text = poll_agent(agent_id, 0, self.active_profile) if status == "running" and wait > 0: board = LaneBoard(self.active_profile) if LaneBoard.available(self.app) else None if board is not None: board.start() try: status, text = poll_agent(agent_id, wait, self.active_profile) finally: if board is not None: board.stop() self.board_shown = True if status in ("done", "timeout", "error"): with AGENTS_LOCK: if agent_id in AGENTS: AGENTS[agent_id]["collected"] = True if status in ("done", "timeout") and self.app.store is not None: with AGENTS_LOCK: record = dict(AGENTS.get(agent_id) or {}) full = record.get("result") or "" if len(full) > SPILL_LIMIT: spilled = record.get("spilled") if not spilled: task = str(record.get("task") or "")[:80] spilled = self.app.store.add_record("output", "agent %d: %s" % (agent_id, task), full, ["agent-output"], self.active_profile) self.app.store.add_chunks("record", spilled, chunk_lines(full), self.active_profile) with AGENTS_LOCK: if agent_id in AGENTS: AGENTS[agent_id]["spilled"] = spilled return "[%s] %s" % (status, spilled_view(full, spilled, len(full))) return "[%s] %s" % (status, text) def run_ask_user(self, args): question = str(args.get("question") or "").strip() if not question: return "error: question must be a non-empty string" raw_options = args.get("options") or [] if not isinstance(raw_options, list): return "error: options must be a list of strings" options = [] for item in raw_options[:ASK_USER_MAX_OPTIONS]: if isinstance(item, dict): label = item.get("label", item.get("value", "")) else: label = item label = str(label or "").strip().replace("\n", " ") if label: options.append(label[:200]) if getattr(self.app, "auto", False): return "ask_user unavailable in autonomous mode: decide yourself and state the assumption" if not getattr(self.app, "persist", True) or getattr(self.app, "depth", 0) > 0: return "ask_user unavailable to background workers: decide yourself and state the assumption" prompter = getattr(self.app, "prompter", None) if prompter is not None: raw = prompter("ask", question, options) if raw is None: return "user gave no answer (input closed): proceed with your best assumption" else: if not sys.stdin.isatty(): return "error: no interactive user on this stdin, decide yourself and state the assumption" print(paint("?- " + question, Ansi.CYAN, Ansi.BOLD)) for pos, label in enumerate(options, 1): print(paint(" %d) %s" % (pos, label), Ansi.DIM)) try: if options: raw = input(paint("answer [1-%d, option text, or free input] " % len(options), Ansi.YELLOW)) else: raw = input(paint("answer (free input, empty skips) ", Ansi.YELLOW)) except (EOFError, KeyboardInterrupt): return "user gave no answer (input closed): proceed with your best assumption" self.app.user_stepped_in = True answer = raw.strip() if not answer: return "user gave no answer (empty): proceed with your best assumption" if len(answer) > ASK_USER_MAX_ANSWER: answer = answer[:ASK_USER_MAX_ANSWER] + "...[truncated]" if options: if answer.isdigit(): pos = int(answer) if 1 <= pos <= len(options): return "user chose option %d: %s" % (pos, options[pos - 1]) lowered = answer.lower() for pos, label in enumerate(options, 1): if label.lower() == lowered: return "user chose option %d: %s" % (pos, label) return "user answered: %s" % answer def run_sysinfo(self, args): names = args.get("checks") if names is None: return collect_sysinfo() if not isinstance(names, list) or not names: return "error: checks must be a non-empty list" valid = [name for name, _func in SYSINFO_CHECKS] unknown = [str(item) for item in names if str(item) not in valid] if unknown: return "error: unknown checks: %s (valid: %s)" % (", ".join(unknown), ", ".join(valid)) return collect_sysinfo([str(item) for item in names]) def run_create_skill(self, args): name = str(args.get("name") or "").strip() brief = str(args.get("brief") or "").strip() if not name or not SKILL_NAME_RE.match(name): return "error: invalid skill name, use lowercase letters, digits, and hyphens" if not brief: return "error: empty brief" if self.app.depth >= FORK_MAX_DEPTH: return "error: create_skill depth limit reached" scope = str(args.get("scope") or "project").strip().lower() if scope not in ("project", "home"): return "error: scope must be project or home" try: timeout = max(60, min(3600, int(args.get("timeout") or 1200))) except (TypeError, ValueError): timeout = 1200 skill_dir = skill_scope_dir(scope, self.app.config.home, os.getcwd(), name) skill_file = os.path.join(skill_dir, "SKILL.md") existed = os.path.isfile(skill_file) spinner = Spinner("creating skill %s" % name) spinner.start() try: if self.app.runner_override is None: result = default_runner(self.app.config, self.app.store.seal, self.app.profile, self.app.depth + 1, create_skill_prompt(name, brief, skill_dir), timeout, CREATE_SKILL_STEPS) else: result = self.app.runner_override(create_skill_prompt(name, brief, skill_dir), self.app.profile, timeout) finally: spinner.stop() if os.path.isfile(skill_file): self.app.skills = discover_skills(self.app.config.home, os.getcwd()) self.app.apply_system() action = "replaced" if existed else "created" return "skill '%s' %s at %s\n%s" % (name, action, skill_file, truncate(result, 2000, 500)) status = "timeout" if "[time limit reached]" in result else "done" return "skill '%s' was not created (%s), worker output:\n%s" % (name, status, truncate(result, 3000, 1000)) @staticmethod def find_skill_root(base): """A source dir qualifies if it's SKILL.md itself, or has exactly one child that is.""" if os.path.isfile(os.path.join(base, "SKILL.md")): return base try: entries = sorted(os.listdir(base)) except OSError: return None candidates = [entry for entry in entries if os.path.isfile(os.path.join(base, entry, "SKILL.md"))] return os.path.join(base, candidates[0]) if len(candidates) == 1 else None def resolve_skill_source(self, source, workdir): """Returns ((path, ephemeral), '') on success, (None, 'error: ...') otherwise. path is a directory containing SKILL.md, or a bare SKILL.md file. ephemeral means the path lives under workdir (git clone / zip extraction) and symlinking to it would dangle once workdir is cleaned up, so install must copy it. """ if source.endswith(".git") or source.startswith("git@") or source.startswith("git+"): git_url = source[4:] if source.startswith("git+") else source if not shutil.which("git"): return None, "error: git is not installed, supply a local path or .zip instead" clone_dir = os.path.join(workdir, "clone") try: done = subprocess.run(["git", "clone", "--depth", "1", git_url, clone_dir], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=120) except (OSError, subprocess.SubprocessError) as exc: return None, "error: git clone failed: " + short_error(exc) if done.returncode != 0: return None, "error: git clone failed: " + (done.stderr or "").strip()[:300] found = self.find_skill_root(clone_dir) if found is None: return None, "error: no SKILL.md found in " + git_url return (found, True), "" path = os.path.expanduser(source) if not os.path.exists(path): return None, "error: no such file or directory: " + source if os.path.isfile(path) and path.endswith(".zip"): extract_dir = os.path.join(workdir, "zip") try: with zipfile.ZipFile(path) as archive: archive.extractall(extract_dir) except (OSError, zipfile.BadZipFile) as exc: return None, "error: bad zip file: " + short_error(exc) found = self.find_skill_root(extract_dir) if found is None: return None, "error: no SKILL.md found in " + source return (found, True), "" if os.path.isfile(path) and path.endswith(".md"): return (path, False), "" if os.path.isdir(path): found = self.find_skill_root(path) if found is None: return None, "error: no SKILL.md found under " + source return (found, False), "" return None, "error: source must be a directory, a SKILL.md file, a .zip, or a git URL" def run_install_skill(self, args): source = str(args.get("source") or "").strip() if not source: return "error: empty source" scope = str(args.get("scope") or "").strip().lower() or default_skill_scope(os.getcwd()) if scope not in ("project", "home"): return "error: scope must be project or home" mode = str(args.get("mode") or "").strip().lower() or None if mode is not None and mode not in ("copy", "symlink"): return "error: mode must be copy or symlink" force = bool(args.get("force")) workdir = tempfile.mkdtemp(prefix="tai-skill-") try: spinner = Spinner("installing skill from %s" % source) spinner.start() try: resolved, failure = self.resolve_skill_source(source, workdir) finally: spinner.stop() if failure: return failure real_source, ephemeral = resolved source_dir = real_source if os.path.isdir(real_source) else None skill_file = os.path.join(real_source, "SKILL.md") if source_dir else real_source try: skill = parse_skill_file(skill_file) except OSError as exc: return "error: " + short_error(exc) if skill is None: return "error: %s has no valid SKILL.md (needs name, description, and a name of lowercase letters, digits, hyphens)" % source name = str(args.get("name") or "").strip() or skill["name"] if not SKILL_NAME_RE.match(name): return "error: invalid skill name, use lowercase letters, digits, and hyphens" if mode is None: mode = "symlink" if (source_dir and not ephemeral and is_external_skill_root(source_dir)) else "copy" elif mode == "symlink" and (ephemeral or source_dir is None): return "error: symlink mode needs a persistent source directory, not a git/zip source or a bare SKILL.md file" scope_dir = skill_scope_dir(scope, self.app.config.home, os.getcwd()) dest = os.path.join(scope_dir, name) existed = os.path.islink(dest) or os.path.exists(dest) if existed and not force and not self.app.ask_approval("overwrite existing skill '%s' at %s" % (name, dest)): return "denied by user" if existed: if os.path.islink(dest) or os.path.isfile(dest): os.remove(dest) else: shutil.rmtree(dest) os.makedirs(scope_dir, exist_ok=True) if mode == "symlink": os.symlink(os.path.abspath(source_dir), dest) elif source_dir is not None: shutil.copytree(source_dir, dest) else: os.makedirs(dest) shutil.copy2(real_source, os.path.join(dest, "SKILL.md")) save_skill_install_meta(scope_dir, name, {"source": source, "mode": mode, "scope": scope, "installed_at": datetime.now(timezone.utc).isoformat()}) if self.app.store is not None: try: self.app.store.audit_event(self.audit_actor(), "skill-install", dest, "installed skill '%s' from %s (%s, %s scope)" % (name, source, mode, scope), tags=["skill", "install"]) except (sqlite3.Error, OSError): pass self.app.skills = discover_skills(self.app.config.home, os.getcwd()) self.app.apply_system() extras = skill_extra_files(dest) tail = "\nbundled files: %s" % ", ".join(extras) if extras else "" replaced = " (replacing the previous install)" if existed else "" return "skill '%s' installed (%s, %s scope) at %s from %s%s\n%s%s" % (name, mode, scope, dest, source, replaced, skill["description"], tail) finally: shutil.rmtree(workdir, ignore_errors=True) def run_store_secret(self, args): name = str(args.get("name") or "").strip() value = str(args.get("value") or "") if not SECRET_RE.match(name): return "error: invalid secret name, use letters, digits, dot, underscore, hyphen" if not value: return "error: empty value" username = str(args.get("username") or "").strip()[:128] host = str(args.get("host") or "").strip()[:255] port = 0 if args.get("port") is not None and str(args.get("port")).strip() != "": try: port = int(args.get("port")) except (TypeError, ValueError): return "error: port must be a number" if not 1 <= port <= 65535: return "error: port must be 1-65535" notes = str(args.get("notes") or "").strip()[:2000] expires = str(args.get("expires") or "").strip() if expires: try: expires = parse_schedule_at(expires) except ValueError as exc: return "error: " + short_error(exc) raw_tags = args.get("tags") or [] if not isinstance(raw_tags, list) or any(not isinstance(item, str) for item in raw_tags): return "error: tags must be a list of names" tags = normalize_tags(raw_tags) meta = {} if username: meta["username"] = username if host: meta["host"] = host if port: meta["port"] = port if notes: meta["notes"] = notes if expires: meta["expires"] = expires self.app.store.save_secret(name, value, meta, tags, self.active_profile) return "stored secret '%s' (sealed; tags: %s)" % (name, ", ".join(["secret"] + tags)) def run_list_secrets(self, args): infos = self.app.store.list_secret_infos(self.active_profile) if not infos: return "secrets: none" now = datetime.now(timezone.utc).isoformat() lines = [] for info in infos: meta = info["meta"] parts = ["- " + info["name"]] if info["tags"]: parts.append("[%s]" % " ".join(info["tags"])) who = "" if meta.get("username") and meta.get("host"): who = "%s@%s" % (meta["username"], meta["host"]) elif meta.get("host"): who = str(meta["host"]) elif meta.get("username"): who = str(meta["username"]) if who and meta.get("port"): who += ":%s" % meta["port"] if who: parts.append(who) if meta.get("notes"): parts.append(str(meta["notes"])[:60]) if meta.get("expires"): parts.append("(expired)" if meta["expires"] <= now else "(expires %s)" % local_display(meta["expires"])) lines.append(" ".join(parts)) return "\n".join(lines) def run_delete_secret(self, args): name = str(args.get("name") or "").strip() if self.app.store.load_secret(name, self.active_profile) is None: return "error: unknown secret '%s'" % name if not self.app.ask_approval("delete secret '%s'" % name): return "denied by user" self.app.store.delete_secret(name, self.active_profile) return "deleted secret '%s'" % name def run_schedule(self, args): prompt = str(args.get("prompt") or "").strip() if not prompt: return "error: empty prompt" at_raw = args.get("at") every_raw = args.get("every") if (at_raw is None) == (every_raw is None): return "error: pass exactly one of at or every" if at_raw is not None: try: next_run = parse_schedule_at(str(at_raw)) except ValueError as exc: return "error: " + short_error(exc) every_sec = 0 else: try: every_sec = int(every_raw) except (TypeError, ValueError): return "error: every must be seconds" if every_sec < 60: return "error: every must be at least 60 seconds" next_run = (datetime.now(timezone.utc) + timedelta(seconds=every_sec)).isoformat() name = str(args.get("name") or "").strip()[:80] try: timeout = max(60, min(3600, int(args.get("timeout") or 600))) except (TypeError, ValueError): timeout = 600 asked = str(args.get("profile") or "").strip() if asked and not PROFILE_RE.match(asked): return "error: invalid profile name" if asked and asked != self.active_profile: return "error: cannot schedule for another profile" profile = self.active_profile raw_tags = args.get("tags") or [] if not isinstance(raw_tags, list) or any(not isinstance(item, str) for item in raw_tags): return "error: tags must be a list of names" if not self.app.ask_approval("schedule '%s'" % (name or prompt[:60])): return "denied by user" row_id = self.app.store.add_schedule(name, prompt, profile, every_sec, next_run, timeout, normalize_tags(raw_tags)) when = "every %s" % format_delay(every_sec) if every_sec else "at %s" % local_display(next_run) return "scheduled #%d (%s)" % (row_id, when) def run_unschedule(self, args): try: row_id = int(args.get("id") or 0) except (TypeError, ValueError): return "error: invalid schedule id" if row_id not in [item["id"] for item in self.app.store.list_schedules(self.active_profile)]: return "error: no schedule #%d" % row_id if not self.app.ask_approval("delete schedule #%d" % row_id): return "denied by user" self.app.store.remove_schedule(row_id, self.active_profile) return "deleted schedule #%d" % row_id def run_schedules(self, args): return format_schedule_lines(self.app.store.list_schedules(self.active_profile), datetime.now(timezone.utc)) def record_tag_args(self, args): raw_tags = args.get("tags") or [] if not isinstance(raw_tags, list) or any(not isinstance(item, str) for item in raw_tags): return None return normalize_tags(raw_tags) def run_record_save(self, args): content = str(args.get("content") or "") if not content: return "error: content is empty" title = str(args.get("title") or "").strip()[:200] or "(untitled)" kind = str(args.get("kind") or "note").strip().lower() or "note" if kind not in RECORD_KINDS: return "error: unknown kind %s (valid: %s)" % (kind, ", ".join(RECORD_KINDS)) tags = self.record_tag_args(args) if tags is None: return "error: tags must be a list of names" record_id = self.app.store.add_record(kind, title, content, tags, self.active_profile) chunks = chunk_lines(content) self.app.store.add_chunks("record", record_id, chunks, self.active_profile) saved = "saved %s (%d chars, kind %s, %d chunks, tags: %s)" % (record_id, len(content), kind, len(chunks), ", ".join(self.app.store.item_tags(record_id, self.active_profile))) links = sorted(edge["other"] for edge in self.app.store.edges_for(record_id, self.active_profile) if edge["direction"] == "out") if links: saved += " [linked: %s]" % ", ".join(links) return saved def run_create_bot(self, args): name = str(args.get("name") or "").strip().lower() if not BOT_RE.match(name): return "error: invalid bot name (lowercase letters, digits, - and _)" if name == "main": return "error: 'main' is the default bot" description = str(args.get("description") or "").strip() rules = str(args.get("rules") or "").strip() behavior = str(args.get("behavior") or "").strip() if not description and not rules and not behavior: return "error: give at least one of description, rules, or behavior" raw_nicks = args.get("nicknames") or [] if not isinstance(raw_nicks, list) or any(not isinstance(item, str) for item in raw_nicks): return "error: nicknames must be a list of names" profile = self.active_profile taken = {"main"} for item in self.app.store.list_bots(profile): taken.add(item["name"]) taken.update(item["nicknames"]) short = re.split(r"[-_]", name, 1)[0] nicks = [] for cand in list(raw_nicks) + [short]: clean = cand.strip().lower() if clean and clean != name and BOT_RE.match(clean) and clean not in nicks: nicks.append(clean) for cand in list(raw_nicks): clean = cand.strip().lower() if clean and not BOT_RE.match(clean): return "error: invalid nickname '%s'" % cand.strip() if name in taken: return "error: bot name '%s' is taken" % name clashes = [nick for nick in nicks if nick in taken] if clashes: return "error: nickname '%s' is taken" % clashes[0] parts = [] if description: parts.append(description) if rules: parts.append("Rules:\n" + rules) if behavior: parts.append("Behavior:\n" + behavior) self.app.store.save_bot_system(profile, name, "\n\n".join(parts)) bots = self.app.store.load_bots(profile) stamp = now_iso() bots[name] = {"nicknames": nicks, "created": stamp, "updated": stamp} self.app.store.save_bots(profile, bots) extra = " (nicknames: %s)" % ", ".join(nicks) if nicks else "" return "created bot '%s'%s. Switch with /bot %s or mention @%s." % (name, extra, name, name) def run_install(self, args): action = str(args.get("action") or "").strip().lower() if action not in ("status", "install", "upgrade", "reinstall", "uninstall"): return "error: action must be status, install, upgrade, reinstall, or uninstall" targets = args.get("targets") or list(INSTALL_TARGETS) if not isinstance(targets, list) or not targets or any(item not in INSTALL_TARGETS for item in targets): return "error: unknown target, valid: %s" % ", ".join(INSTALL_TARGETS) if action != "status" and not self.app.ask_approval("install %s: %s (vault always kept)" % (action, ", ".join(targets))): return "denied by user" report = install_report(action, targets, self.app.config.home, self.app.store) if action != "status" and "skills" in targets: self.app.skills = discover_skills(self.app.config.home, os.getcwd()) return report def run_search(self, args): query = str(args.get("query") or "").strip() if not query: return "error: empty query" kinds = args.get("kinds") or ["record", "event", "audit", "chunk"] if not isinstance(kinds, list) or not kinds or any(item not in ("record", "event", "audit", "chunk") for item in kinds): return "error: kinds must be a list of record, event, audit, chunk" try: limit = int(args.get("limit") or 10) except (TypeError, ValueError): return "error: invalid limit" expand = bool(args.get("expand")) profile = self.active_profile hits = self.app.store.fts_search(query, kinds, profile, limit) if self.app.store.seal.enabled and "event" in kinds and not any(hit["kind"] == "event" for hit in hits): for ts, _role, kind, text in self.app.store.events_like(profile, query, limit): hits.append({"kind": "event", "item": "event %s" % ts, "sub": kind, "title": text[:60], "snippet": text[:160], "rank": 99.0}) hits.sort(key=lambda hit: hit["rank"]) hit_ids = ",".join("%s:%s" % (hit["kind"], hit["item"]) for hit in hits[:limit]) self.app.store.log_event(profile, "tool", "search-query", "q=%s\tlimit=%d\thits=%s" % (query[:200], limit, hit_ids[:1500])) if not hits: return "no matches for '%s'" % query[:80] chunk_ids = [] for hit in hits: if hit["kind"] == "chunk": try: chunk_ids.append(int(str(hit["item"]).split(":", 1)[1])) except (IndexError, ValueError): pass chunk_rows = self.app.store.get_chunks_by_ids(chunk_ids, profile) if chunk_ids else {} lines = [] for hit in hits: if hit["kind"] == "record": lines.append("[record/%s] %s :: %s -- %s" % (hit["sub"], hit["item"], hit["title"][:80], hit["snippet"])) if expand: neighbors = [] for edge in self.app.store.edges_for(hit["item"], profile): if len(neighbors) < 3: neighbors.append("%s (%s)" % (self.app.store.node_title(edge["other"], profile)[:60], edge["relation"])) if neighbors: lines.append(" linked: %s" % "; ".join(neighbors)) elif hit["kind"] == "event": lines.append("[event/%s] #%s %s -- %s" % (hit["sub"], hit["item"], hit["title"][:60], hit["snippet"])) elif hit["kind"] == "chunk": try: chunk_id = int(str(hit["item"]).split(":", 1)[1]) except (IndexError, ValueError): chunk_id = None chunk = chunk_rows.get(chunk_id) if chunk is None: lines.append("[chunk/%s] %s -- %s" % (hit["sub"], hit["title"][:80], hit["snippet"])) else: preview = chunk["text"][:500] + ("..." if len(chunk["text"]) > 500 else "") lines.append("[chunk/%s] %s#%d (parent %s, read full with record_read) -- %s" % (chunk["parent_kind"], chunk["parent_id"][:60], chunk["idx"], chunk["parent_id"], preview)) else: lines.append("[audit/%s] #%s %s -- %s" % (hit["sub"], str(hit["item"]).split(":", 1)[1], hit["title"][:80], hit["snippet"])) return "\n".join(lines) def run_tags(self, args): prefix = str(args.get("prefix") or "").strip() try: limit = int(args.get("limit") or 50) except (TypeError, ValueError): return "error: invalid limit" counts = self.app.store.tag_counts(prefix, limit, self.active_profile) if not counts: return "no tags yet" lines = ["%s (%d)" % (tag, count) for tag, count in counts] return "%d tags:\n%s" % (len(counts), "\n".join(lines)) def run_record_read(self, args): record_id = str(args.get("id") or "").strip() try: offset = max(0, int(args.get("offset") or 0)) except (TypeError, ValueError): return "error: invalid offset" try: limit = int(args.get("limit") or 4000) except (TypeError, ValueError): return "error: invalid limit" page = self.app.store.read_record(record_id, offset, max(1, min(limit, 20000)), self.active_profile) if page is None: return "error: unknown record " + record_id self.app.store.log_event(self.active_profile, "tool", "record-read", record_id[:200]) head = "%s chars %d-%d of %d :: %s" % (record_id, page["start"], page["end"], page["total"], self.app.store.node_title(record_id, self.active_profile)) if page["tags"]: head += " [%s]" % ", ".join(page["tags"]) return head + "\n" + page["slice"] def run_record_search(self, args): query = str(args.get("query") or "") kind = str(args.get("kind") or "").strip().lower() or None if kind is not None and kind not in RECORD_KINDS: return "error: unknown kind %s (valid: %s)" % (kind, ", ".join(RECORD_KINDS)) tags = self.record_tag_args(args) if tags is None: return "error: tags must be a list of names" try: limit = int(args.get("limit") or 8) except (TypeError, ValueError): return "error: invalid limit" found = self.app.store.search_records(query, kind, tags, limit, self.active_profile) if not found: return "no records match" lines = [] for item in found: lines.append("%s [%s] %s (%d chars, %d reads) tags: %s" % (item["id"], item["kind"], item["title"], item["size"], item["reads"], ", ".join(item["tags"]))) return "\n".join(lines) def run_record_delete(self, args): record_id = str(args.get("id") or "").strip() if not record_id: return "error: record id is required" if not self.app.ask_approval("delete record %s" % record_id): return "denied by user" if self.app.store.delete_record(record_id, self.active_profile): return "deleted record " + record_id return "error: unknown record " + record_id def run_graph_link(self, args): src = str(args.get("src") or "").strip() dst = str(args.get("dst") or "").strip() if not src or not dst: return "error: src and dst are required" try: relation = self.app.store.add_edge(src, dst, args.get("relation"), self.active_profile) except ValueError as exc: return "error: " + str(exc) return "linked %s -[%s]-> %s" % (src, relation, dst) def run_delete_file(self, args): path = str(args.get("path") or "") if not path: return "error: empty path" if self.app.env == "sandbox": return self.box_delete(path) if not os.path.isfile(path): return "error: no such file" if ("home", os.path.abspath(path)) not in self.read_files: return "error: delete denied for '%s', read it first with read_file" % path if not self.app.ask_approval("delete file %s" % path): return "denied by user" old, old_size = read_capped(path) try: os.remove(path) except OSError as exc: return "error: " + short_error(exc) self.read_files.discard(("home", os.path.abspath(path))) audited = self.audit_file("delete", os.path.abspath(path), old, None, "", (), old_size, None) note = "" if audited is not False else " (audit failed)" if old_size is None: self.note_diff(path, old, None) return "deleted %s%s" % (path, note) def box_delete(self, path): engine, failure = self.box_engine() if not engine: return failure if ("box", path) not in self.read_files: try: probe = box_exec(engine, ["test", "-f", path], timeout=30) except (OSError, subprocess.SubprocessError): probe = None if probe is None or probe.returncode != 0: return "error: no such file" return "error: delete denied for '%s', read it first with read_file" % path old, old_size = self.box_old_image(path) if old is None: return "error: no such file" try: done = box_exec(engine, ["rm", "-f", path], timeout=60) except (OSError, subprocess.SubprocessError) as exc: return "error: " + short_error(exc) if done.returncode != 0: return "error: " + done.stderr.decode("utf-8", "replace")[:200] self.read_files.discard(("box", path)) audited = self.audit_file("delete", "sandbox:" + path, old, None, "", (), old_size, None) note = "" if audited is not False else " (audit failed)" if old_size is None: self.note_diff(path, old, None) return "deleted %s%s" % (path, note) def run_audit(self, args): path = str(args.get("path") or "").strip() or None tag = str(args.get("tag") or "").strip() or None query = str(args.get("query") or "").strip() or None try: limit = int(args.get("limit") or 20) except (TypeError, ValueError): return "error: invalid limit" if query: rows = self.app.store.audit_search(query, self.active_profile, limit) else: rows = self.app.store.audit_history(path, tag, limit, self.active_profile) if not rows: return "no audit rows match" lines = [] for row in rows: old_size = row["old_size"] if row["old_size"] is not None else "-" new_size = row["new_size"] if row["new_size"] is not None else "-" line = "#%d %s %s %s %s (%s->%s bytes)" % (row["id"], row["ts"][:19], row["actor"], row["action"], row["path"], old_size, new_size) if row["message"]: line += " :: " + row["message"][:120] if row["tags"]: line += " [%s]" % row["tags"] lines.append(line) return "\n".join(lines) def run_restore(self, args): try: row_id = int(args.get("id") or 0) except (TypeError, ValueError): return "error: invalid audit id" row = self.app.store.audit_get(row_id, self.active_profile) if row is None: return "error: unknown audit #%d" % row_id if row["action"] in ("snapshot", "release"): return "error: audit #%d is a %s row, restorable rows are write, edit, delete, shell-snapshot, restore" % (row_id, row["action"]) if row["new"] is not None: target, which, full_size = row["new"], "post", row["new_size"] elif row["old"] is not None: target, which, full_size = row["old"], "pre", row["old_size"] else: return "error: audit #%d holds no content" % row_id if "[truncated, full size" in target: return "error: audit #%d %s-image is truncated (full size %s bytes), cannot restore safely" % (row_id, which, full_size) if not self.app.ask_approval("restore %s from audit #%d (%s-image)" % (row["path"], row_id, which)): return "denied by user" message = "restored from audit #%d (%s-image)" % (row_id, which) if row["path"].startswith("sandbox:"): return self.box_restore(row["path"][len("sandbox:"):], target, message) if os.path.isfile(row["path"]): current, capped = read_capped(row["path"]) else: current, capped = None, None try: parent = os.path.dirname(row["path"]) if parent: os.makedirs(parent, exist_ok=True) with open(row["path"], "w", encoding="utf-8") as handle: handle.write(target) self.read_files.add(("home", os.path.abspath(row["path"]))) except OSError as exc: return "error: " + short_error(exc) self.audit_file("restore", row["path"], None, target, message) self.app.store.upsert_file_record(row["path"], target, None, self.active_profile) if capped is None: self.note_diff(row["path"], current, target) return "restored %s from audit #%d (%s-image)" % (row["path"], row_id, which) def box_restore(self, path, target, message): self.read_files.add(("box", path)) old, capped = self.box_old_image(path) written = self.box_write(path, target, old=None, action="restore", message=message, preview=False) if written.startswith("wrote "): if capped is None: self.note_diff(path, old, target) return "restored sandbox:%s (%s)" % (path, message) return written def run_release(self, args): part = str(args.get("part") or "").strip().lower() message = str(args.get("message") or "").strip() if part not in ("major", "minor", "patch"): return "error: part must be major, minor, or patch" if not message: return "error: release message is required" script_file = os.path.abspath(__file__) try: with open(script_file, "r", encoding="utf-8") as handle: match = VERSION_RE.search(handle.read()) if match is None: return "error: no VERSION line in running script" current = ".".join(match.groups()) upcoming = next_version(current, part) except (OSError, ValueError) as exc: return "error: " + short_error(exc) if not self.app.ask_approval("release %s (%s): %s" % (upcoming, part, message[:120])): return "denied by user" try: old, new, dest, row_id = do_release(self.app.store, script_file, backups_dir(self.app.config.home), part, message, self.audit_actor()) except (OSError, ValueError, sqlite3.Error) as exc: return "error: " + short_error(exc) return "released %s -> %s, backup %s, audit #%d" % (old, new, os.path.basename(dest), row_id) def run_graph_query(self, args): node = str(args.get("node") or "").strip() parsed = parse_node_id(node) if parsed is None or not self.app.store.has_node(*parsed, self.active_profile): return "error: unknown node " + node try: depth = int(args.get("depth") or 2) except (TypeError, ValueError): return "error: invalid depth" try: limit = int(args.get("limit") or 30) except (TypeError, ValueError): return "error: invalid limit" found = self.app.store.traverse(node, depth, limit, self.active_profile) lines = [] for item in found: if item["depth"] == 0: lines.append("%s :: %s" % (item["node"], item["title"])) else: lines.append("%s%s :: %s" % (" " * item["depth"], item["via"], item["title"])) return "\n".join(lines) def conversation_tokens(messages, tools=None): total = 0 for item in messages: total += estimate_tokens(item.get("content")) for call in item.get("tool_calls") or []: total += estimate_tokens(json.dumps(call)) if tools: total += estimate_tokens(json.dumps(tools)) return total def compact_messages(messages, chat, keep=KEEP_TURNS): if len(messages) <= keep + 1: return messages, False system = messages[0] if messages[0].get("role") == "system" else {"role": "system", "content": DEFAULT_SYSTEM} recent = messages[-keep:] while recent and recent[0].get("role") != "user": recent = recent[1:] if not recent: recent = messages[-keep:] middle = messages[1:len(messages) - len(recent)] if not middle: return messages, False digest = [] for item in middle: role = item.get("role") if role == "assistant" and item.get("tool_calls"): names = ", ".join(call.get("function", {}).get("name", "?") for call in item["tool_calls"]) digest.append("assistant tool calls: " + names) digest.append("%s: %s" % (role, (item.get("content") or "")[:1500])) request = [ {"role": "system", "content": COMPACT_SYSTEM}, {"role": "user", "content": "Compress this history:\n" + "\n".join(digest)}, ] before = conversation_tokens(messages) summary = chat.complete(request, ephemeral=True)["content"].strip() rebuilt = [ system, {"role": "user", "content": "Previous session summary:\n" + summary}, {"role": "assistant", "content": "Understood. Continuing with summarized context."}, ] + recent after = conversation_tokens(rebuilt) profile, bot = getattr(chat, "scope", ("", "")) LOG.event("INFO", "agent", "compact", "compacted context %d to %d tokens" % (before, after), profile=profile, bot=bot, meta={"before_tokens": before, "after_tokens": after, "kept_turns": len(recent)}) return rebuilt, True class Denied(Exception): def __init__(self, guidance=""): super().__init__("denied by user") self.guidance = guidance class AgentsFull(Exception): pass class Approval: """Approval input rules (spec): what an allow answer means and which guidance text counts as approval. parse_allow maps an allow answer to once, always, deny or unknown. once is y or yes, always is Y, yolo or always, deny is n, no or empty. Anything else is unknown and the prompt re-asks (APPROVAL_MAX_RETRIES), so a typo like A never silently denies. is_affirmative matches only exact standalone phrases after lowercasing and collapsing whitespace and trailing .!? marks. Anything longer (yes, but do X) is guidance, never approval. Only the shell tool treats affirmative guidance as approval; deletes, secrets, schedules and releases stay strict and keep raising Denied.""" AFFIRMATIVE = frozenset(( "y", "yes", "yeah", "yep", "sure", "proceed", "go ahead", "do it", "run it", "allow", "approve", "approved", "ok", "okay", "fine", "good", "great", "perfect", "all good", "looks good", "sounds good", "it was perfect", "that was perfect", "everything was fine", "everything is fine", )) @staticmethod def parse_allow(answer): cleaned = (answer or "").strip() lowered = cleaned.lower() if cleaned == "Y" or lowered in ("yolo", "always"): return "always" if lowered in ("y", "yes"): return "once" if lowered in ("n", "no", ""): return "deny" return "unknown" @staticmethod def is_affirmative(text): words = " ".join((text or "").strip().lower().split()).rstrip(".!?") return bool(words) and words in Approval.AFFIRMATIVE class GoalFailure: """One unmet goal item: what is wrong, why, and what to do next.""" def __init__(self, item, reason, proposal): self.item = item self.reason = reason self.proposal = proposal @classmethod def from_dict(cls, raw): if not isinstance(raw, dict): return None try: item = str(raw.get("item") or "").strip() reason = str(raw.get("reason") or "").strip() proposal = str(raw.get("proposal") or "").strip() except Exception: return None if not item or not reason or not proposal: return None return cls(item[:VERIFY_MAX_ITEM], reason[:VERIFY_MAX_REASON], proposal[:VERIFY_MAX_PROPOSAL]) def to_dict(self): return {"item": self.item, "reason": self.reason, "proposal": self.proposal} class GoalVerdict: """Parsed verifier answer: ok plus the failure list and commentary.""" def __init__(self, ok, failures, commentary, raw): self.ok = ok self.failures = failures self.commentary = commentary self.raw = raw @classmethod def from_text(cls, text): raw = (text or "").strip() data, error = GoalVerifier.extract_json(raw) failures = [] broken = [] success = None if error is not None: broken.append("verifier returned no usable JSON: %s" % error) elif not isinstance(data, dict): broken.append("verifier returned a JSON value that is not an object") else: success = data.get("success") entries = data.get("failures") if not isinstance(success, bool): broken.append("field success is not true or false") if not isinstance(entries, list): broken.append("field failures is not a list") else: for pos, entry in enumerate(entries[:VERIFY_MAX_FAILURES]): parsed = GoalFailure.from_dict(entry) if parsed is None: broken.append("failure %d misses item, reason or proposal" % (pos + 1)) else: failures.append(parsed) if success is True and failures: broken.append("verifier claims success but lists failures") ok = not broken and success is True for note in broken: failures.append(GoalFailure("goal verification", note, "re-run the request so it can be verified")) if not ok and not failures: failures.append(GoalFailure("goal", "verifier reported failure without details", "re-run the request with explicit checks")) failures = failures[:VERIFY_MAX_FAILURES] commentary = "" if isinstance(data, dict) and isinstance(data.get("commentary"), str): commentary = data["commentary"].strip()[:VERIFY_MAX_COMMENTARY] return cls(ok, failures, commentary, raw[:VERIFY_MAX_COMMENTARY]) def report(self, goal): title = " ".join(str(goal or "").split())[:200] or "goal" if self.ok: note = (" " + self.commentary) if self.commentary else "" return "goal verified: %s%s" % (title, note) lines = ["goal NOT verified: %s" % title] for failure in self.failures: lines.append("- %s: %s" % (failure.item, failure.reason)) lines.append(" next: %s" % failure.proposal) if self.commentary: lines.append(self.commentary) return "\n".join(lines) def followup(self): lines = ["Goal verification FAILED, the goal is not delivered yet. Continue now with the next proposal below."] for failure in self.failures: lines.append("- %s: %s Next: %s" % (failure.item, failure.reason, failure.proposal)) if self.commentary: lines.append(self.commentary) lines.append("Do not repeat denied, skipped or failed actions verbatim; use the proposals and move forward.") return "\n".join(lines)[:VERIFY_MAX_CHARS] class GoalVerifier: """Strict one-shot acceptance check (spec). After a one-shot turn the goal (the prompt text) is verified by a worker subagent before the process may report success. The worker gets goal, final answer, transcript tail and tried actions, and must reply with exactly one JSON object: a success bool plus a failures list of {item, reason, proposal} dicts and an optional commentary. Strictness: only evidence counts, praise is not evidence, denied or skipped calls mean the effect did not happen, success with failures listed is failure, and every failure needs a proposal that moves forward instead of repeating tried actions. Flow (Agent.run_verified): verify, and while the verdict fails run one follow-up turn fed with the failures, at most VERIFY_MAX_ROUNDS extra turns. Verified exits 0; exhausted rounds or a user abort exits 1, and any unverified outcome leaves the turn state open so --resume can continue. Workers (depth above 0) never verify, so verification cannot recurse. Caps: VERIFY_TIMEOUT per check, VERIFY_STEPS worker steps, transcript and field clamps from the VERIFY_MAX_* constants.""" TASK = ( "You are a strict acceptance verifier. The main agent was asked to achieve a GOAL. " "Decide only from evidence whether the goal is actually delivered. Be very strict: claims " "without evidence are failures. User praise (perfect, great, fine) is not evidence. A denied, " "skipped or timed out tool call means its effect did not happen. Partial work is failure unless " "the goal explicitly allows partial work. You may use read-only tools to inspect machine state; " "judge from the transcript when a check is unavailable.\n" "\nGOAL:\n%s\n" "\nFINAL ANSWER of the main agent:\n%s\n" "\nTRANSCRIPT (oldest first, tail truncated):\n%s\n" "\nALREADY TRIED (never propose these again verbatim, move forward):\n%s\n" "\nReply with exactly one JSON object and nothing else:\n" '{"success": true or false, "failures": [{"item": "...", "reason": "...", "proposal": "..."}], "commentary": "..."}\n' "Rules: success is true only when every part of the goal is delivered with evidence. " "When success is false, list every unmet item (at most %d) with its reason and one concrete " "next proposal that differs from ALREADY TRIED." ) @staticmethod def extract_json(text): body = (text or "").strip() if body.startswith("```"): body = body[3:] if body[:4].lower() == "json": body = body[4:] end = body.find("```") body = body[:end] if end >= 0 else body body = body.strip() start = body.find("{") end = body.rfind("}") if start < 0 or end <= start: return None, "no JSON object found" try: return json.loads(body[start:end + 1]), None except ValueError as exc: return None, "invalid JSON: %s" % exc @staticmethod def transcript(messages): lines = [] for item in messages or []: role = item.get("role") or "?" if role == "system": continue text = item.get("content") or "" if role == "assistant": names = [] for call in item.get("tool_calls") or []: fn = call.get("function") or {} names.append("%s %s" % (fn.get("name", "?"), str(fn.get("arguments") or "")[:80])) if names: text = ("%s [tools: %s]" % (text, "; ".join(names))).strip() lines.append(("[%s] %s" % (role, " ".join(str(text).split())))[:VERIFY_MAX_LINE]) joined = "\n".join(lines) return joined[-VERIFY_MAX_CHARS:] if len(joined) > VERIFY_MAX_CHARS else joined @staticmethod def tried_actions(messages): tried = [] for item in messages or []: for call in item.get("tool_calls") or []: fn = call.get("function") or {} args = " ".join(str(fn.get("arguments") or "").split())[:80] tried.append(("%s %s" % (fn.get("name") or "?", args)).strip()) return tried[-VERIFY_MAX_TRIED:] @staticmethod def build_task(goal, final, transcript, tried): goal_text = " ".join(str(goal or "").split())[:VERIFY_MAX_GOAL] or "(empty goal)" final_text = " ".join(str(final or "").split())[:VERIFY_MAX_COMMENTARY] or "(empty answer)" transcript_text = transcript or "(no transcript)" tried_text = "\n".join(tried) if tried else "(none)" return GoalVerifier.TASK % (goal_text, final_text, transcript_text, tried_text, VERIFY_MAX_FAILURES) class RetrievalEval: """Standing retrieval-quality eval (spec). Three layers, cheapest first. Goldens: query-plus-expected-id pairs generated deterministically from vault records (exact term, title phrase, truncated prefix, phrase plus one foreign term), no LLM needed. Metrics over Store.fts_search ranks: Hit@K, MRR, binary nDCG@K, and mean precision@k (Context Precision). Implicit pairs: search-query events joined to later record-read events give pairwise preferences (read beats shown-but-unread), never absolute labels. Zero-hit queries are listed as known recall failures. Caps: EVAL_K scoring depth, EVAL_FETCH rows retrieved, EVAL_MAX_RECORDS sampled, EVAL_MAX_GOLDENS and EVAL_MAX_PAIRS kept.""" @staticmethod def rank_of(expected_id, ranked_ids): try: return list(ranked_ids).index(expected_id) + 1 except ValueError: return None @staticmethod def best_rank(expected_ids, ranked_ids): ranks = [RetrievalEval.rank_of(item, ranked_ids) for item in expected_ids or []] ranks = [rank for rank in ranks if rank is not None] return min(ranks) if ranks else None @staticmethod def hit_at_k(rank, k=EVAL_K): return 1.0 if rank is not None and rank <= k else 0.0 @staticmethod def reciprocal_rank(rank): return 1.0 / rank if rank else 0.0 @staticmethod def ndcg_at_k(rank, k=EVAL_K): if rank is None or rank > k: return 0.0 return 1.0 / math.log2(rank + 1) @staticmethod def context_precision(expected_ids, ranked_ids, k=EVAL_K): wanted = set(expected_ids or []) if not wanted: return 0.0 hits = 0 total = 0.0 for pos, item in enumerate(list(ranked_ids)[:k], 1): if item in wanted: hits += 1 total += hits / pos return total / hits if hits else 0.0 @staticmethod def tokens(text, min_len=EVAL_MIN_TOKEN): seen = [] for token in re.findall(r"[a-z0-9]+", (text or "").lower()): if len(token) >= min_len and token not in seen: seen.append(token) return seen @staticmethod def _doc_freq(store, profile, token): try: row = store.db.execute("SELECT COUNT(DISTINCT item) FROM fts_docs WHERE fts_docs MATCH ? AND profile = ? AND kind = 'record'", ('{title body} : "%s"' % token, profile,)).fetchone() except sqlite3.Error: return 0 return row[0] if row else 0 @staticmethod def goldens_from_vault(store, profile, max_records=EVAL_MAX_RECORDS): rows = store.db.execute("SELECT id, title, content FROM records WHERE profile = ? ORDER BY id LIMIT ?", (profile, max_records,)).fetchall() goldens = [] for pos, row in enumerate(rows): record_id, title, content = row[0], row[1] or "", row[2] or "" words = RetrievalEval.tokens(title + "\n" + content) if not words: continue ranked = sorted(((RetrievalEval._doc_freq(store, profile, token), -len(token), idx, token) for idx, token in enumerate(words[:24]))) rare = [token for _, _, _, token in ranked] goldens.append({"query": rare[0], "expected": [record_id], "category": "exact", "source": record_id}) if len(rare) >= 2: goldens.append({"query": " ".join(rare[:3]), "expected": [record_id], "category": "phrase", "source": record_id}) longest = max(words, key=len) cut = max(EVAL_PREFIX_MIN, len(longest) // 2) if cut < len(longest): goldens.append({"query": longest[:cut], "expected": [record_id], "category": "prefix", "source": record_id}) if len(rare) >= 2 and len(rows) > 1: other = rows[(pos + 1) % len(rows)] other_words = RetrievalEval.tokens((other[1] or "") + "\n" + (other[2] or "")) own = set(words) foreign = [token for token in other_words if token not in own] if foreign: goldens.append({"query": " ".join(rare[:2] + [foreign[0] + "zz"]), "expected": [record_id], "category": "noisy", "source": record_id}) if len(goldens) >= EVAL_MAX_GOLDENS: break return goldens[:EVAL_MAX_GOLDENS] @staticmethod def score(store, goldens, profile, kinds=("record",), k=EVAL_K, fetch=EVAL_FETCH): rows = [] for golden in goldens or []: try: hits = store.fts_search(golden.get("query") or "", kinds, profile, fetch) except Exception: hits = [] ranked = [hit["item"] for hit in hits] expected = golden.get("expected") or [] rank = RetrievalEval.best_rank(expected, ranked) rows.append({"query": golden.get("query"), "category": golden.get("category"), "expected": expected, "rank": rank, "hit": RetrievalEval.hit_at_k(rank, k), "mrr": RetrievalEval.reciprocal_rank(rank), "ndcg": RetrievalEval.ndcg_at_k(rank, k), "cp": RetrievalEval.context_precision(expected, ranked, k)}) result = {"k": k, "goldens": len(rows), "overall": RetrievalEval._mean(rows), "by_category": {}, "misses": []} for row in rows: result["by_category"].setdefault(row["category"], []).append(row) for category in result["by_category"]: result["by_category"][category] = {"goldens": len(result["by_category"][category]), "mean": RetrievalEval._mean(result["by_category"][category])} result["misses"] = [{"query": row["query"], "category": row["category"]} for row in rows if row["rank"] is None][:10] return result @staticmethod def _mean(rows): if not rows: return {"hit": 0.0, "mrr": 0.0, "ndcg": 0.0, "cp": 0.0} return {key: round(sum(row[key] for row in rows) / len(rows), 4) for key in ("hit", "mrr", "ndcg", "cp")} @staticmethod def implicit_pairs(store, profile, limit=500): text_col = "tai_dec(text)" if store.seal.enabled else "text" try: rows = store.db.execute("SELECT role, kind, %s FROM events WHERE profile = ? ORDER BY id DESC LIMIT ?" % text_col, (profile, limit,)).fetchall() except sqlite3.Error: rows = [] rows.reverse() searches = [] for pos, row in enumerate(rows): if row[0] == "tool" and row[1] == "search-query": parsed = RetrievalEval._parse_search_event(row[2] or "") if parsed is not None: searches.append((pos, parsed[0], parsed[1])) pairs = [] zero_hit = [] read_follow = 0 for idx, (pos, query, hits) in enumerate(searches): if not hits: zero_hit.append(query) end = searches[idx + 1][0] if idx + 1 < len(searches) else len(rows) for row in rows[pos + 1:min(end, pos + 21)]: if row[0] == "tool" and row[1] == "record-read" and (row[2] or "").strip(): read_follow += 1 read_id = (row[2] or "").strip() rank = RetrievalEval.rank_of(read_id, hits) pairs.append({"query": query, "read": read_id, "rank": rank, "shown": len(hits)}) break if len(pairs) >= EVAL_MAX_PAIRS: break return {"searches": len(searches), "read_follow": read_follow, "pairs": pairs[:EVAL_MAX_PAIRS], "zero_hit": zero_hit[:10], "zero_hit_count": len(zero_hit)} @staticmethod def _parse_search_event(text): try: parts = dict(part.split("=", 1) for part in str(text).split("\t") if "=" in part) query = (parts.get("q") or "").strip() raw = (parts.get("hits") or "").strip() hits = [piece.split(":", 1)[1] if piece.startswith("record:") else piece for piece in raw.split(",") if piece] if not query: return None return query, hits except Exception: return None @staticmethod def format_report(result, pairs): lines = ["retrieval eval: %d goldens at k=%d" % (result.get("goldens", 0), result.get("k", EVAL_K))] overall = result.get("overall") or {} lines.append("overall: hit=%.3f mrr=%.3f ndcg=%.3f cp=%.3f" % (overall.get("hit", 0.0), overall.get("mrr", 0.0), overall.get("ndcg", 0.0), overall.get("cp", 0.0))) for category in sorted((result.get("by_category") or {}).keys()): entry = result["by_category"][category] mean = entry.get("mean") or {} lines.append("%s: n=%d hit=%.3f mrr=%.3f ndcg=%.3f cp=%.3f" % (category, entry.get("goldens", 0), mean.get("hit", 0.0), mean.get("mrr", 0.0), mean.get("ndcg", 0.0), mean.get("cp", 0.0))) for miss in result.get("misses") or []: lines.append("miss [%s]: %s" % (miss.get("category"), (miss.get("query") or "")[:80])) lines.append("implicit: %d searches, %d followed by a read, %d zero-hit" % (pairs.get("searches", 0), pairs.get("read_follow", 0), pairs.get("zero_hit_count", 0))) for query in pairs.get("zero_hit") or []: lines.append("zero-hit: %s" % query[:80]) return "\n".join(lines) class Agent: def __init__(self, config, store, persist=True, quiet=False, depth=0, auto=False, yolo=False, ctx=None): self.config = config self.auto = auto self.yolo = yolo self.ctx_id = ShellSession.coerce(ctx) self.store = store self.chat = ChatClient(config) if not persist or depth > 0: self.chat.fixed_key = "worker/%s" % uuid.uuid4().hex self.tools = Tools(self) self.profile = "" self.bot = "main" self.system_message = "" self.messages = [] self.skills = discover_skills(config.home, os.getcwd()) self.env = "home" self.persist = persist self.quiet = quiet self.depth = depth self.deadline = None self.timed_out = False self.runner_override = None self.user_stepped_in = False self._turn_open = False self._turn_goal = "" self._turn_steps = 0 self._turn_tools = 0 self._last_event = None self.switch_profile(config.profile, silent=True) def apply_system(self): content = self.system_message + skill_catalog(self.skills) + tool_catalog() content += orientation_block(sandbox=self.env == "sandbox") content += capability_block(self.yolo, self.auto) if self.auto: content += "\n\n" + AUTONOMOUS_NOTE self.messages[0] = {"role": "system", "content": content} def sync_chat_scope(self): self.chat.scope = (self.profile, self.bot) def reset_history(self): self.messages = [{"role": "system", "content": self.system_message}] self.apply_system() self.apply_system() def switch_profile(self, name, silent=False): if self.messages and self.persist: self.store.save_session(self.profile, self.messages, self.bot, ctx=self.ctx_id) self.profile = name self.bot = "main" self.store.profile = name self.tools.read_files.clear() self.tools.secret_grants.clear() loaded = self.store.load_system(name) if loaded is None: loaded = DEFAULT_SYSTEM self.store.save_system(name, loaded) if LEGACY_PASSWORD_NOTE in loaded: loaded = loaded.replace(LEGACY_PASSWORD_NOTE, VAULT_MEMORY_NOTE) self.store.save_system(name, loaded) self.system_message = loaded restored = self.store.load_session(name, ctx=self.ctx_id) if self.persist else [] self.messages = [{"role": "system", "content": self.system_message}] + restored self.apply_system() self.sync_chat_scope() if not silent: print(paint("profile: %s (%d chars knowledge, %d restored messages)" % (name, len(self.system_message), len(restored)), Ansi.GREEN)) def update_system(self, text): self.system_message = text if self.bot == "main": self.store.save_system(self.profile, text) else: self.store.save_bot_system(self.profile, self.bot, text) self.apply_system() self.store.log_event(self.profile, "system", "remember", "system message updated (%d chars)" % len(text)) def switch_bot(self, name, silent=False): target = self.store.resolve_bot(self.profile, name) if target is None: return "unknown bot @%s (available: %s)" % (name, ", ".join(item["name"] for item in self.store.list_bots(self.profile))) if self.messages and self.persist: self.store.save_session(self.profile, self.messages, self.bot, ctx=self.ctx_id) self.bot = target loaded = self.store.load_bot_system(self.profile, target) if loaded is None: loaded = DEFAULT_SYSTEM if target == "main": self.store.save_system(self.profile, loaded) else: self.store.save_bot_system(self.profile, target, loaded) self.system_message = loaded restored = self.store.load_session(self.profile, target, ctx=self.ctx_id) if self.persist else [] self.messages = [{"role": "system", "content": self.system_message}] + restored self.apply_system() self.sync_chat_scope() if not silent: print(paint("bot: %s (%d restored messages)" % (target, len(restored)), Ansi.GREEN)) return "switched to bot '%s'" % target def scrub_secret_from_history(self): for item in self.messages: content = item.get("content") if isinstance(content, str) and content: item["content"] = self.store.redact(content, self.profile) for recorded in item.get("tool_calls") or []: args = (recorded.get("function") or {}).get("arguments") if isinstance(args, str) and args: recorded["function"]["arguments"] = self.store.redact(args, self.profile) self.chat.reset_muse() def ask_approval(self, command, guidance=True): if self.yolo or self.auto or not self.persist: mode = "auto" elif getattr(self, "prompter", None) is not None: mode = "prompter" elif sys.stdin.isatty(): mode = "tty" else: mode = "headless" def decide(value): LOG.event("INFO", "approval", "decision", "approval %s" % ("granted" if value else "denied"), profile=getattr(self, "profile", ""), bot=getattr(self, "bot", ""), meta={"command": (command or "")[:300], "decision": value, "mode": mode}) return value if self.yolo or self.auto: return decide(True) if not self.persist: return decide(self.config.auto_approve) prompter = getattr(self, "prompter", None) if prompter is not None: choice = prompter("approve", command, ["Allow once", "Always allow (approvals off)", "Deny"]) if choice == "Always allow (approvals off)": self.user_stepped_in = True self.yolo = True self.config.auto_approve = True return decide(True) if choice == "Allow once": self.user_stepped_in = True return decide(True) return decide(False) if not sys.stdin.isatty(): return decide(False) print(paint("run: %s" % command, Ansi.YELLOW)) decision = "unknown" for _retry in range(APPROVAL_MAX_RETRIES): try: answer = input(paint("allow? [y]once [Y]always [n]o ", Ansi.YELLOW)).strip() except (EOFError, KeyboardInterrupt): return decide(False) decision = Approval.parse_allow(answer) if decision != "unknown": break print(paint("unrecognized answer %r, use y (once), Y (always) or n (deny)" % answer, Ansi.YELLOW)) self.user_stepped_in = True if decision == "always": self.yolo = True self.config.auto_approve = True print(paint("yolo mode on, approvals off for this session", Ansi.YELLOW)) return decide(True) if decision == "once": return decide(True) if not guidance or self.auto: return decide(False) try: typed = input(paint("denied. What should I do instead? (empty aborts this turn) ", Ansi.YELLOW)).strip() except (EOFError, KeyboardInterrupt): typed = "" decide(False) raise Denied(typed) def show_call(self, call): if self.quiet: return preview_args = tool_args_text(call["arguments"]) if call["name"] == "store_secret": try: masked = json.loads(preview_args or "{}") masked["value"] = "[hidden]" preview_args = json.dumps(masked) except ValueError: preview_args = "[hidden]" if Glow.enabled(): verb, target, extra, pretty = Glow.describe_call(call["name"], preview_args) target = self.store.redact(target, self.profile) extra = self.store.redact(extra, self.profile) preview = self.store.redact(pretty, self.profile)[:400].replace("\n", " ") self._last_event = (call["name"], verb, target) print() inner = max(20, Glow.width() - 2) print(Glow.bar(call["name"]) + Glow.event_row(Glow.icon_for(call["name"]), verb, target, extra, inner, Glow.accent_for(call["name"]))) print(Glow.bar(call["name"]) + Glow.preview_row(preview, inner)) return preview = self.store.redact(preview_args, self.profile)[:400].replace("\n", " ") print(paint("┌─ %s" % call["name"], Ansi.MAGENTA)) print(paint("│ %s" % preview, Ansi.DIM)) def show_result(self, result, elapsed_ms): if self.quiet: return self.show_diff() note = getattr(self.tools, "result_note", None) if note: self.tools.result_note = None print(paint(note, Ansi.DIM)) if Glow.enabled(): name, verb, target = self._last_event or ("", "Done", "") failed = result == "denied by user" or (result or "").startswith(("error:", "[error]", "[missing]")) if name == "fork": match = re.match(r"agent (\d+) started", result or "") if match: target = "%s · #%s" % (target, match.group(1)) if target else "#%s" % match.group(1) inner = max(20, Glow.width() - 2) print(Glow.bar(name) + Glow.done_row(verb, target, len(result), elapsed_ms, failed, inner)) self._last_event = None self.show_lanes(name, result or "") return print(paint("└─ %d chars · %dms" % (len(result), elapsed_ms), Ansi.DIM)) def show_lanes(self, name, result): boarded = getattr(self.tools, "board_shown", False) self.tools.board_shown = False started = name == "fork" and result.startswith("agent ") collected = name == "poll" and result.startswith(("[done]", "[timeout]")) watching = name == "poll" and result.startswith("[running]") if not (started or collected or watching): return records = list_agents(self.profile) running = [record for record in records if record["status"] == "running"] pending = [record for record in records if record["status"] in ("done", "timeout", "error") and not record.get("collected")] if boarded: if collected and not running and not pending: print(Glow.event_row("◆", "lanes", "", "all collected")) return if len(running) >= 2 or pending: for row in Glow.lanes(records): print(row) elif collected and not running and not pending: print(Glow.event_row("◆", "lanes", "", "all collected")) def show_diff(self): preview = getattr(self.tools, "diff_preview", None) if preview is None: return self.tools.diff_preview = None rendered = render_diff(preview["path"], preview["old"], preview["new"], colors=color_enabled()) if rendered: print(rendered) def checkpoint(self): if not self.persist: return self.store.save_session(self.profile, self.messages, self.bot, ctx=self.ctx_id) if self._turn_open: self.store.save_turn_state(self.profile, self.bot, {"open": True, "goal": self._turn_goal, "steps": self._turn_steps, "tools": self._turn_tools, "updated": now_iso()}, ctx=self.ctx_id) def close_turn(self): if not self.persist: self._turn_open = False return self.store.save_session(self.profile, self.messages, self.bot, ctx=self.ctx_id) self._turn_open = False if self._turn_goal: self.store.save_turn_state(self.profile, self.bot, {"open": False, "goal": self._turn_goal, "steps": self._turn_steps, "tools": self._turn_tools, "updated": now_iso()}, ctx=self.ctx_id) def mention_error(self, tag): bots = ", ".join(item["name"] for item in self.store.list_bots(self.profile)) return ("unknown bot @%s (available: %s; providers: %s; @path references a file)" % (tag, bots, ", ".join(PROVIDER_LABELS))) def mention_confirm(self, selectors, cleared, directory): bits = [] if cleared and selectors["provider"] is None and selectors["model"] is None: bits.append("pins cleared") if selectors["provider"] is not None: bits.append("provider pinned to %s" % selectors["provider"]) if selectors["model"] is not None: bits.append("model pinned to %s" % selectors["model"]) if selectors["bot"] is not None: bits.append("switched to bot '%s'" % selectors["bot"]) if not bits: bits.append("nothing to select") suffix = "saved as default for %s" % directory if self.persist else "not saved (persist off)" return "%s (%s)" % ("; ".join(bits), suffix) def apply_mentions(self, text, capture=False, max_steps=MAX_STEPS): """Parse leading @tags. Returns (text, result): result is not None when the turn is fully handled (unknown-tag error, bare-selector confirm, or a routed bot turn).""" tags, rest = split_leading_tags(text) directory = dir_key() selectors = {"provider": None, "model": None, "bot": None} cleared = False files = [] loud = not capture and not self.quiet for tag in tags: kind, value = resolve_tag(tag, self, directory) if kind == "unknown": error = self.mention_error(tag) if loud: print(error) return text, error if kind == "auto": cleared = True selectors["provider"] = None selectors["model"] = None continue if kind == "file": files.append(value) continue if kind == "route": selectors["provider"], selectors["model"] = value continue selectors[kind] = value if cleared or selectors["provider"] is not None: self.chat.provider_pin = selectors["provider"] if cleared or selectors["model"] is not None: self.chat.model_pin = selectors["model"] if (cleared or any(selectors.values())) and self.persist: clear = [key for key in ("provider", "model") if cleared and selectors[key] is None] self.store.save_dir_defaults(directory, provider=selectors["provider"], model=selectors["model"], bot=selectors["bot"], clear=clear) prompt = expand_file_mentions(rest, directory) if files: prompt = " ".join(files + ([prompt] if prompt.strip() else [])) if not tags: return prompt, None if not prompt.strip(): if selectors["bot"] is not None and selectors["bot"] != self.bot: self.switch_bot(selectors["bot"], silent=True) confirm = self.mention_confirm(selectors, cleared, directory) if loud: print(confirm) return None, confirm if selectors["bot"] is not None and selectors["bot"] != self.bot: return None, self.run_mention(selectors["bot"], prompt, text, capture, max_steps) return prompt, None def phase(self, name, detail=""): """Report what the turn is really doing (thinking, tool, typing) to an optional on_phase(name, detail) listener; listener errors never reach the turn.""" listener = getattr(self, "on_phase", None) if listener is not None: try: listener(name, detail) except Exception: pass def run_turn(self, text, capture=False, max_steps=MAX_STEPS, _routed=False): LOG.event("INFO", "agent", "turn_start", "turn started", profile=self.profile, bot=self.bot, meta={"goal": (text or "")[:500], "capture": capture, "max_steps": max_steps, "depth": self.depth, "routed": _routed}) started = time.time() try: result = self._run_turn_inner(text, capture, max_steps, _routed) except Exception as exc: LOG.event("INFO", "agent", "turn_end", "turn failed: %s" % short_error(exc), profile=self.profile, bot=self.bot, meta={"exec_ms": int((time.time() - started) * 1000), "steps": getattr(self, "_turn_steps", 0), "tools_used": getattr(self, "_turn_tools", 0), "outcome": "error", "reply_chars": 0}) raise LOG.event("INFO", "agent", "turn_end", "turn finished", profile=self.profile, bot=self.bot, meta={"exec_ms": int((time.time() - started) * 1000), "steps": getattr(self, "_turn_steps", 0), "tools_used": getattr(self, "_turn_tools", 0), "outcome": "ok", "reply_chars": len(result or "")}) return result def _run_turn_inner(self, text, capture=False, max_steps=MAX_STEPS, _routed=False): self.apply_system() if not _routed: text, result = self.apply_mentions(text, capture, max_steps) if result is not None: return result self.messages.append({"role": "user", "content": text}) self.store.log_event(self.profile, "user", "message", self.store.redact(text, self.profile)) self._turn_open = True self._turn_goal = text[:500] self._turn_steps = 0 self._turn_tools = 0 self.checkpoint() started = time.time() backends = [] pieces = [] last_text = "" sink = pieces.append loud = not capture and not self.quiet self.tools.live = loud seen_actions = collections.deque(maxlen=400) seen_results = collections.deque(maxlen=400) stall = 0 last_pair = None run_len = 0 nudged = False for _step in range(Consts.TOTAL_STEP_CAP): if self.deadline is not None and time.time() > self.deadline: self.timed_out = True last_text = (last_text + "\n[time limit reached]").strip() break payload = select_tools(conversation_text(self.messages), self.tools.mcp_schemas()) tokens = conversation_tokens(self.messages, payload) if tokens > CONTEXT_CAP * COMPACT_RATIO: if loud: print(paint("compacting context (%d tokens)..." % tokens, Ansi.DIM)) self.messages, _changed = compact_messages(self.messages, self.chat) self.apply_system() spinner = None if loud: spinner = Spinner("thinking") spinner.start() self.phase("thinking") try: reply = self.chat.complete(self.messages, payload, stream_sink=sink, deadline=self.deadline) except BackendError as exc: if loud: print(paint("backend error: %s" % exc, Ansi.RED)) self.messages.pop() self.checkpoint() return last_text or "backend error: %s" % exc finally: if spinner is not None: spinner.stop() reply["content"] = self.store.redact(reply["content"] or "", self.profile) if loud and reply["content"].strip(): print(render_markdown(reply["content"])) if reply["backend"] not in backends: backends.append(reply["backend"]) if loud and (len(backends) > 1 or reply["backend"] != MUSE_LABEL): print(paint("(via %s)" % reply["backend"], Ansi.DIM)) if loud and reply["reasoning"] and not reply["content"] and not reply["tool_calls"]: print(paint(reply["reasoning"][:2000], Ansi.GRAY)) reply["tool_calls"] = [call for call in reply["tool_calls"] if TOOL_NAME_RE.match(call["name"])] history_calls = [{"id": call["id"], "type": "function", "function": {"name": call["name"], "arguments": call["arguments"]}} for call in reply["tool_calls"]] self.messages.append({"role": "assistant", "content": reply["content"] or None, "tool_calls": history_calls or None}) if not reply["tool_calls"]: if reply["content"]: last_text = reply["content"] elif reply["reasoning"]: last_text = reply["reasoning"] if last_text: self.phase("typing") self.store.log_event(self.profile, "assistant", "message", last_text) break aborted = False step_pairs = [] for pos, call in enumerate(reply["tool_calls"]): self.show_call(call) self.phase("tool", call["name"]) call_started = time.time() try: result = self.tools.dispatch(call["name"], call["arguments"]) except Denied as denied: result = "denied by user" self.show_result(result, (time.time() - call_started) * 1000) self.messages.append({"role": "tool", "tool_call_id": call["id"], "content": result}) self.store.log_event(self.profile, "tool", call["name"], result) step_pairs.append((action_signature(call["name"], call["arguments"]), result_digest(result))) for skipped in reply["tool_calls"][pos + 1:]: self.messages.append({"role": "tool", "tool_call_id": skipped["id"], "content": "skipped: stopped after denial"}) if denied.guidance: self.messages.append({"role": "user", "content": denied.guidance}) self.store.log_event(self.profile, "user", "guidance", self.store.redact(denied.guidance, self.profile)) else: aborted = True last_text = "stopped by user" break self.show_result(result, (time.time() - call_started) * 1000) self.messages.append({"role": "tool", "tool_call_id": call["id"], "content": result}) self.store.log_event(self.profile, "tool", call["name"], result) step_pairs.append((action_signature(call["name"], call["arguments"]), result_digest(result))) if call["name"] == "store_secret": self.scrub_secret_from_history() if aborted: break productive = self.user_stepped_in self.user_stepped_in = False for pair in step_pairs: if pair[0] not in seen_actions or pair[1] not in seen_results: productive = True seen_actions.append(pair[0]) seen_results.append(pair[1]) if pair == last_pair: run_len += 1 else: last_pair = pair run_len = 1 nudged = False self._turn_steps += 1 self._turn_tools += len(step_pairs) self.checkpoint() if productive: stall = 0 else: stall += 1 if run_len == LOOP_NUDGE_AT and not nudged: nudged = True nudge = "loop warning: you called %s %d times in a row with identical results. Change approach, try different arguments, or stop instead of repeating it." % (last_pair[0][:200], run_len) self.messages.append({"role": "user", "content": nudge}) self.store.log_event(self.profile, "user", "guidance", nudge) if loud: print(paint(nudge, Ansi.YELLOW)) if run_len >= LOOP_STOP_AT: last_text = "stuck in a loop: %s repeated %d times with identical results" % (last_pair[0][:200], run_len) if loud: print(paint(last_text, Ansi.YELLOW)) self.store.log_event(self.profile, "system", "stall", last_text) break if stall >= max_steps: last_text = "no progress for %d steps: actions and results keep repeating. Say 'continue' to resume with fresh direction." % max_steps if loud: print(paint(last_text, Ansi.YELLOW)) self.store.log_event(self.profile, "system", "stall", last_text) break else: if loud: print(paint("step budget exhausted (%d total steps)" % Consts.TOTAL_STEP_CAP, Ansi.YELLOW)) self.close_turn() total = time.time() - started if loud: if Glow.enabled(): print(Glow.hairline()) print(paint("tokens≈%d · %s · %.1fs" % (conversation_tokens(self.messages), "+".join(backends), total), Ansi.DIM)) return last_text def run_verified(self, prompt): """One-shot turn with strict subagent acceptance, see the GoalVerifier spec. Returns (final_text, verified). Workers never verify, and a user abort never continues.""" if self.depth != 0: return self.run_turn(prompt), True return self.verify_loop(prompt, self.run_turn(prompt)) def ensure_verified(self, goal, final): """Verify a finished one-shot turn against its goal, running follow-up turns until the verdict passes or VERIFY_MAX_ROUNDS is spent. Returns only whether the goal was delivered.""" final_text, success = self.verify_loop(goal, final) failures = 0 if success else getattr(self, "_last_verify_failures", 1) LOG.event("INFO", "agent", "verified", "goal %s" % ("delivered" if success else "not delivered"), profile=self.profile, bot=self.bot, meta={"success": success, "failures": failures}) return success def verify_loop(self, goal, final): goal = " ".join(str(goal or "").split())[:VERIFY_MAX_GOAL] if final == "stopped by user": self.mark_turn_open(goal) return final, False for _attempt in range(1 + VERIFY_MAX_ROUNDS): verdict = self.verify_goal(goal, final) self._last_verify_failures = len(verdict.failures) self.show_verdict(verdict, goal) self.store.log_event(self.profile, "system", "verify", "ok" if verdict.ok else "failed: %d items" % len(verdict.failures)) if verdict.ok: return final, True if _attempt >= VERIFY_MAX_ROUNDS: break final = self.run_turn(verdict.followup()) if final == "stopped by user": self.mark_turn_open(goal) return final, False self.mark_turn_open(goal) return final, False def mark_turn_open(self, goal): if self.persist: self.store.save_turn_state(self.profile, self.bot, {"open": True, "goal": goal[:500], "steps": self._turn_steps, "tools": self._turn_tools, "updated": now_iso()}, ctx=self.ctx_id) def verify_goal(self, goal, final): """Run one strict acceptance check in a worker subagent.""" task = GoalVerifier.build_task(goal, final, GoalVerifier.transcript(self.messages), GoalVerifier.tried_actions(self.messages)) runner = self.runner_override if runner is None: def runner(task, profile, timeout): return default_runner(self.config, self.store.seal, profile, self.depth + 1, task, timeout, max_steps=VERIFY_STEPS) try: agent_id = spawn_agent(task, self.profile, VERIFY_TIMEOUT, self.config, self.store.seal, self.depth + 1, runner) except Exception as exc: return GoalVerdict(False, [GoalFailure("goal verification", "verifier did not start: %s" % short_error(exc), "re-run the request")], "", "") status, text = poll_agent(agent_id, wait=VERIFY_TIMEOUT, profile=self.profile) if status in ("done", "timeout", "error"): with AGENTS_LOCK: if agent_id in AGENTS: AGENTS[agent_id]["collected"] = True if status != "done": return GoalVerdict(False, [GoalFailure("goal verification", "verifier %s: %s" % (status, " ".join(text.split())[:200]), "re-run the verification")], "", text[:VERIFY_MAX_COMMENTARY]) with AGENTS_LOCK: full = (AGENTS.get(agent_id) or {}).get("result") or text return GoalVerdict.from_text(full) def show_verdict(self, verdict, goal): if self.quiet: return print(paint(verdict.report(goal), Ansi.GREEN if verdict.ok else Ansi.YELLOW)) def run_mention(self, target, rest, original, capture=False, max_steps=MAX_STEPS): if self.persist: self.store.save_session(self.profile, self.messages, self.bot, ctx=self.ctx_id) other = self.store.load_session(self.profile, target, ctx=self.ctx_id) else: other = [] system = self.store.load_bot_system(self.profile, target) or DEFAULT_SYSTEM saved_messages, saved_system, saved_bot = self.messages, self.system_message, self.bot self.messages = [{"role": "system", "content": system}] + other self.system_message = system self.bot = target self.apply_system() self.sync_chat_scope() try: answer = self.run_turn(rest, capture=capture, max_steps=max_steps, _routed=True) finally: if self.persist: self.store.save_session(self.profile, self.messages, target, ctx=self.ctx_id) self.messages, self.system_message, self.bot = saved_messages, saved_system, saved_bot self.apply_system() self.sync_chat_scope() self.store.log_event(self.profile, "user", "message", self.store.redact(original, self.profile)) self.messages.append({"role": "user", "content": original}) self.messages.append({"role": "assistant", "content": answer, "reasoning": "", "tool_calls": [], "backend": "mention"}) if self.persist: self.store.save_session(self.profile, self.messages, self.bot, ctx=self.ctx_id) return answer def default_runner(config, seal, profile, depth, task, timeout, max_steps=WORKER_STEPS, stats=None): store = Store(config, seal) boot_settings(config, store) try: worker = Agent(config, store, persist=False, quiet=True, depth=depth) if profile != worker.profile: worker.switch_profile(profile, silent=True) worker.deadline = time.time() + timeout try: return worker.run_turn(task, capture=True, max_steps=max_steps) finally: if stats is not None: stats["tools"] = worker.tools.calls finally: store.close() def spawn_agent(task, profile, timeout, config, seal, depth=0, runner=None): with AGENTS_LOCK: if len(AGENTS) >= AGENTS_MAX_RECORDS: old = sorted(key for key, record in AGENTS.items() if record["status"] != "running" and record.get("collected")) for key in old[:len(AGENTS) - AGENTS_MAX_RECORDS + 1]: del AGENTS[key] live = sum(1 for record in AGENTS.values() if record["status"] == "running") if live >= AGENTS_MAX_PARALLEL: raise AgentsFull("too many live agents (%d of %d)" % (live, AGENTS_MAX_PARALLEL)) agent_id = AGENTS_NEXT[0] AGENTS_NEXT[0] += 1 record = {"id": agent_id, "task": task, "profile": profile, "status": "running", "result": "", "started": time.time(), "ended": None, "thread": None, "spilled": None, "collected": False, "tools": None} AGENTS[agent_id] = record def target(): try: if runner is None: result = default_runner(config, seal, profile, depth, task, timeout, stats=record) else: result = runner(task, profile, timeout) status = "timeout" if "[time limit reached]" in result else "done" except Exception as exc: result = "worker failed: " + short_error(exc) status = "error" with AGENTS_LOCK: record["result"] = result record["status"] = status record["ended"] = time.time() worker_thread = threading.Thread(target=target, daemon=True) with AGENTS_LOCK: record["thread"] = worker_thread worker_thread.start() return agent_id def poll_agent(agent_id, wait=0, profile=None): with AGENTS_LOCK: record = AGENTS.get(agent_id) if profile is not None and record is not None and record.get("profile") != profile: record = None worker_thread = record["thread"] if record else None if record is None: return "missing", "no agent %d" % agent_id if worker_thread is not None: worker_thread.join(timeout=max(0, wait)) with AGENTS_LOCK: status = record["status"] result = record["result"] elapsed = (record["ended"] or time.time()) - record["started"] idle = record.get("tools") == 0 if status == "running": return status, "agent %d still running (%ds elapsed)" % (agent_id, elapsed) if status == "error": return status, result text = truncate(result, 6000, 2000) or "(empty result)" if idle: text += "\n[note: this worker ran 0 tool calls, so it changed nothing; check its answer before relying on it]" return status, text def list_agents(profile=None): with AGENTS_LOCK: return [{"id": record["id"], "task": record["task"], "status": record["status"], "elapsed": int((record["ended"] or time.time()) - record["started"]), "collected": record.get("collected", False), "tools": record.get("tools")} for record in AGENTS.values() if profile is None or record.get("profile") == profile] def clear_agents(profile=None): with AGENTS_LOCK: finished = [key for key, record in AGENTS.items() if record["status"] != "running" and (profile is None or record.get("profile") == profile)] for key in finished: del AGENTS[key] return len(finished) def running_agents(): with AGENTS_LOCK: return sum(1 for record in AGENTS.values() if record["status"] == "running") def parse_schedule_at(raw): text = str(raw or "").strip() if not text: raise ValueError("empty datetime") if text[-1:] in ("Z", "z"): text = text[:-1] + "+00:00" try: moment = _fromisoformat(text) except ValueError: raise ValueError("invalid datetime, use ISO like 2026-10-08T09:00") return moment.astimezone(timezone.utc).isoformat() def parse_duration(raw): match = re.fullmatch(r"(\d+)\s*([smhdw])?", str(raw or "").strip().lower()) if not match: return None return int(match.group(1)) * {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}[match.group(2) or "s"] def format_delay(seconds): seconds = int(seconds) if seconds < 0: return "overdue" if seconds < 60: return "%ds" % seconds if seconds < 3600: return "%dm" % (seconds // 60) if seconds < 86400: return "%dh" % (seconds // 3600) return "%dd" % (seconds // 86400) def local_display(utc_iso): try: return _fromisoformat(utc_iso).astimezone().strftime("%Y-%m-%d %H:%M") except ValueError: return utc_iso def format_schedule_lines(items, now): if not items: return "(no schedules)" lines = [] for item in items: try: due_in = (_fromisoformat(item["next_run"]) - now).total_seconds() except ValueError: due_in = -1 when = "every %s" % format_delay(item["every"]) if item["every"] else "at %s" % local_display(item["next_run"]) marked = " [%s]" % " ".join(item["tags"]) if item["tags"] else "" lines.append("#%d [%s] %s (%s) %s%s :: %s" % (item["id"], item["status"], when, format_delay(due_in), item["name"] or "(no name)", marked, item["prompt"][:80])) if item["last"]: extra = " :: %s" % item["result"][:120] if item["result"] else "" lines.append(" last: %s%s" % (item["last"], extra)) return "\n".join(lines) def note_schedule_result(db_path, row_id, status, result=""): try: db = sqlite3.connect(db_path, timeout=30) db.execute("PRAGMA journal_mode=WAL") db.execute("PRAGMA busy_timeout=30000") try: db.execute("UPDATE schedules SET last_status = ?, last_result = ?, updated = ? WHERE id = ?", (status, result[:2000], datetime.now(timezone.utc).isoformat(), row_id)) db.commit() finally: db.close() except sqlite3.Error: pass def watch_schedule(db_path, row_id, agent_id, timeout, profile=""): status, text = poll_agent(agent_id, wait=timeout + SCHEDULER_LEASE_MARGIN) note_schedule_result(db_path, row_id, status, text) LOG.event("INFO", "sched", "finished", "schedule #%s finished: %s" % (row_id, status), profile=profile, meta={"id": row_id, "status": status, "result_chars": len(text or "")}) def schedule_running(last_status, updated, timeout): """Lease check: a running claim younger than timeout plus margin may live.""" if last_status != "running": return False try: age = (datetime.now(timezone.utc) - _fromisoformat(updated)).total_seconds() except ValueError: return False return age < (timeout or 600) + SCHEDULER_LEASE_MARGIN def scheduler_tick(config, seal, runner=None): fired = 0 skipped_cap = 0 skipped_overlap = 0 now = datetime.now(timezone.utc) db = sqlite3.connect(config.db_path, timeout=30) db.execute("PRAGMA journal_mode=WAL") db.execute("PRAGMA busy_timeout=30000") try: rows = db.execute("SELECT id, prompt, profile, every_sec, next_run, timeout, last_status, updated FROM schedules WHERE status = 'pending' AND next_run <= ? ORDER BY next_run", (now.isoformat(),)).fetchall() for row_id, prompt, profile, every_sec, next_run, timeout, last_status, updated in rows: if fired >= SCHEDULER_MAX_FIRES: break if every_sec and schedule_running(last_status, updated, timeout): skipped_overlap += 1 continue if running_agents() >= AGENTS_MAX_PARALLEL: skipped_cap += 1 continue stamp = datetime.now(timezone.utc).isoformat() if every_sec: try: claimed_next = _fromisoformat(next_run) except ValueError: continue while claimed_next <= datetime.now(timezone.utc): claimed_next += timedelta(seconds=every_sec) done = db.execute("UPDATE schedules SET next_run = ?, updated = ?, last_status = 'running' WHERE id = ? AND status = 'pending' AND next_run = ?", (claimed_next.isoformat(), stamp, row_id, next_run)) else: done = db.execute("UPDATE schedules SET status = 'done', updated = ? WHERE id = ? AND status = 'pending'", (stamp, row_id)) db.commit() if done.rowcount != 1: continue if prompt.startswith(Seal.PREFIX): try: prompt = seal.unlock(prompt) except SealError as exc: note_schedule_result(config.db_path, row_id, "error: " + short_error(exc)) continue try: agent_id = spawn_agent(prompt, profile, timeout, config, seal, 0, runner) except AgentsFull: if every_sec: db.execute("UPDATE schedules SET next_run = ?, updated = ?, last_status = ? WHERE id = ?", (next_run, stamp, last_status or "", row_id)) else: db.execute("UPDATE schedules SET status = 'pending', updated = ? WHERE id = ?", (stamp, row_id)) db.commit() skipped_cap += 1 continue watcher = threading.Thread(target=watch_schedule, args=(config.db_path, row_id, agent_id, timeout, profile), daemon=True) watcher.start() fired += 1 LOG.event("INFO", "sched", "fired", "schedule #%s fired for %s" % (row_id, profile), profile=profile, meta={"id": row_id, "profile": profile, "every_sec": every_sec or 0}) finally: db.close() if skipped_cap or skipped_overlap: print("scheduler: skipped %d at parallel cap, %d overlapping" % (skipped_cap, skipped_overlap)) return fired def run_scheduler_loop(config, seal, stop_flag): while not stop_flag.is_set(): try: scheduler_tick(config, seal) except Exception as exc: print("scheduler error: %s" % short_error(exc)) stop_flag.wait(SCHEDULER_INTERVAL) def start_scheduler(config, seal): stop_flag = threading.Event() worker = threading.Thread(target=run_scheduler_loop, args=(config, seal, stop_flag), daemon=True) worker.start() return stop_flag COMMANDS = ("help", "profile", "profiles", "bots", "bot", "env", "ctx", "skills", "sysinfo", "models", "secret", "schedule", "schedules", "unschedule", "records", "record", "graph", "tags", "tools", "install", "search", "audit", "restore", "release", "fork", "agents", "agent", "compact", "clear", "demo", "quit", "exit") def complete_command(text, state): options = ["/" + name for name in COMMANDS if name.startswith(text.lstrip("/"))] return options[state] if state < len(options) else None def mention_options(prefix, agent, directory=None, cap=200): found = [] seen = set() low = (prefix or "").lower() try: bots = agent.store.list_bots(agent.profile) except Exception: bots = [] for item in bots: for name in [item["name"]] + list(item.get("nicknames") or []): if name.lower().startswith(low) and name not in seen: seen.add(name) found.append("@" + name) labels = list(PROVIDER_LABELS) + [MENTION_AUTO] for backend in agent.config.backends or []: label = ((backend or {}).get("label") or "").strip() if label: labels.append(label) for label in labels: if label.lower().startswith(low) and label not in seen: seen.add(label) found.append("@" + label) try: models = sorted(known_model_ids(agent.config, getattr(agent.chat, "health", None))) except Exception: models = [] for model in models: if model.lower().startswith(low) and model not in seen: seen.add(model) found.append("@" + model) for rel in complete_files(prefix or "", directory or dir_key()): if rel not in seen: seen.add(rel) found.append("@" + rel) return found[:cap] def complete_mention(text, state, agent): options = mention_options(text[1:], agent) if (text or "").startswith("@") else [] return options[state] if state < len(options) else None def complete_input(text, state, agent=None): if (text or "").startswith("@") and agent is not None: return complete_mention(text, state, agent) return complete_command(text, state) def handle_command(agent, text): parts = text[1:].split(None, 1) name = parts[0].lower() arg = parts[1].strip() if len(parts) > 1 else "" if name in ("quit", "exit"): return False if name == "help": print(HELP_TEXT) elif name == "profiles": names = agent.store.list_profiles() for item in names: print("%s %s" % ("*" if item == agent.profile else " ", item)) if not names: print("(no profiles)") elif name == "bots": for item in agent.store.list_bots(agent.profile): nicks = " aka %s" % ", ".join(item["nicknames"]) if item["nicknames"] else "" print("%s %s%s" % ("*" if item["name"] == agent.bot else " ", item["name"], nicks)) elif name == "bot": if not arg: print(paint("use /bot <name>, see /bots", Ansi.RED)) else: print(agent.switch_bot(arg)) elif name == "profile": if not arg: print("current profile: %s (%d chars)" % (agent.profile, len(agent.system_message))) elif not PROFILE_RE.match(arg): print(paint("invalid profile name", Ansi.RED)) else: agent.switch_profile(arg) elif name == "env": if not arg: print("current environment: %s" % agent.env) elif arg not in ("home", "sandbox"): print(paint("use /env home or /env sandbox", Ansi.RED)) elif arg == "sandbox": try: ensure_box() except BackendError as exc: print(paint("sandbox unavailable: %s" % exc, Ansi.RED)) else: agent.env = "sandbox" print("environment: sandbox (isolated container)") else: agent.env = "home" print("environment: home") elif name == "ctx": print(ShellSession.report(agent.store, getattr(agent, "ctx_id", ""))) elif name == "skills": bits = arg.split() if bits and bits[0] == "install": if len(bits) < 2: print(paint("use /skills install <source> [name=x] [scope=project|home] [mode=copy|symlink] [force]", Ansi.RED)) return True call_args = {"source": bits[1]} for token in bits[2:]: if token == "force": call_args["force"] = True elif "=" in token: key, _, value = token.partition("=") if key in ("name", "scope", "mode"): call_args[key] = value print(agent.tools.dispatch("install_skill", json.dumps(call_args))) return True agent.skills = discover_skills(agent.config.home, os.getcwd()) agent.apply_system() if not agent.skills: print("(no skills)") for skill_name in sorted(agent.skills): skill = agent.skills[skill_name] print("- %s: %s %s" % (skill_name, skill["description"][:200], skill_provenance_note(skill))) wanted = sorted(item for item in SKILL_BLUEPRINTS if item not in agent.skills) if wanted: print("blueprints, built on first load:") for skill_name in wanted: print("- %s: %s" % (skill_name, SKILL_BLUEPRINTS[skill_name]["hint"])) elif name == "sysinfo": print(collect_sysinfo()) elif name == "models": print(models_report(agent.chat)) elif name == "secret": sub = arg.split(None, 1) action = sub[0].lower() if sub else "" rest = sub[1].strip() if len(sub) > 1 else "" if action == "set" and rest: if not SECRET_RE.match(rest): print(paint("invalid secret name, use letters, digits, dot, underscore, hyphen", Ansi.RED)) else: try: value = getpass.getpass("value for '%s': " % rest) except (EOFError, KeyboardInterrupt): print("\naborted") return True if not value: print("empty value, aborted") else: agent.store.save_secret(rest, value, None, None, agent.profile) print("stored secret '%s'" % rest) elif action in ("list", "") and not rest: print(agent.tools.dispatch("list_secrets", "{}")) elif action == "delete" and rest: if agent.store.load_secret(rest, agent.profile) is None: print(paint("unknown secret '%s'" % rest, Ansi.RED)) elif agent.ask_approval("delete secret '%s'" % rest, guidance=False): agent.store.delete_secret(rest, agent.profile) print("deleted secret '%s'" % rest) else: print("aborted") else: print(paint("use /secret set <name> or /secret list or /secret delete <name>", Ansi.RED)) elif name == "schedule": parts = arg.split(None, 2) if len(parts) != 3 or parts[0] not in ("at", "every"): print(paint("use /schedule at <ISO datetime> <prompt> or /schedule every <30s|10m|2h|1d> <prompt>", Ansi.RED)) else: mode, when, prompt = parts if mode == "at": try: next_run = parse_schedule_at(when) except ValueError as exc: print(paint("error: %s" % exc, Ansi.RED)) return True every_sec = 0 else: every_sec = parse_duration(when) if every_sec is None or every_sec < 60: print(paint("error: every must be a duration of at least 60s", Ansi.RED)) return True next_run = (datetime.now(timezone.utc) + timedelta(seconds=every_sec)).isoformat() row_id = agent.store.add_schedule("", prompt, agent.profile, every_sec, next_run, 600) print("scheduled #%d" % row_id) elif name == "schedules": print(format_schedule_lines(agent.store.list_schedules(agent.profile), datetime.now(timezone.utc))) elif name == "unschedule": try: row_id = int(arg or "0") except ValueError: print(paint("use /unschedule <id>", Ansi.RED)) return True if agent.store.remove_schedule(row_id, agent.profile): print("deleted schedule #%d" % row_id) else: print(paint("no schedule #%d" % row_id, Ansi.RED)) elif name == "records": print(agent.tools.dispatch("record_search", json.dumps({"query": arg}))) elif name == "record": bits = arg.split() if not bits: print(paint("use /record <mem:id> [offset]", Ansi.RED)) else: try: offset = int(bits[1]) if len(bits) > 1 else 0 except ValueError: print(paint("offset must be a number", Ansi.RED)) return True print(agent.tools.dispatch("record_read", json.dumps({"id": bits[0], "offset": offset}))) elif name == "graph": if not arg: print(paint("use /graph <mem:id|secret:name|sched:id>", Ansi.RED)) else: print(agent.tools.dispatch("graph_query", json.dumps({"node": arg}))) elif name == "tools": if arg: match = [schema["function"] for schema in TOOL_SCHEMAS if schema["function"]["name"] == arg] if not match: print(paint("unknown tool '%s'" % arg, Ansi.RED)) return True print("%s [%s]" % (match[0]["name"], "core" if match[0]["name"] in CORE_TOOLS else "lazy")) print(match[0]["description"]) print("trigger tags: %s" % ", ".join(TOOL_TAGS.get(match[0]["name"], ()))) return True names = [schema["function"]["name"] for schema in TOOL_SCHEMAS if tool_env_ok(schema["function"]["name"])] cores = sorted(tool for tool in names if tool in CORE_TOOLS) lazy = sorted(tool for tool in names if tool not in CORE_TOOLS) print("core, always loaded:\n %s" % "\n ".join(cores)) print("lazy, loads when named:\n %s" % "\n ".join(lazy)) elif name == "search": if not arg: print(paint("use /search <query>", Ansi.RED)) else: print(agent.tools.dispatch("search", json.dumps({"query": arg}))) elif name == "install": bits = arg.split() action = bits[0] if bits else "status" if action not in ("status", "install", "upgrade", "reinstall", "uninstall"): print(paint("use /install [status|install|upgrade|reinstall|uninstall] [targets...]", Ansi.RED)) else: print(agent.tools.dispatch("install", json.dumps({"action": action, "targets": bits[1:] or list(INSTALL_TARGETS)}))) elif name == "tags": print(agent.tools.dispatch("tags", json.dumps({"prefix": arg} if arg else {}))) elif name == "audit": print(agent.tools.dispatch("audit", json.dumps({"path": arg} if arg else {}))) elif name == "restore": try: row_id = int(arg or "0") except ValueError: print(paint("use /restore <audit id>", Ansi.RED)) return True print(agent.tools.dispatch("restore", json.dumps({"id": row_id}))) agent.show_diff() elif name == "release": bits = arg.split(None, 1) if len(bits) != 2 or bits[0] not in ("major", "minor", "patch"): print(paint("use /release <major|minor|patch> <message>", Ansi.RED)) else: print(agent.tools.dispatch("release", json.dumps({"part": bits[0], "message": bits[1]}))) elif name == "fork": if not arg: print(paint("use /fork <task>", Ansi.RED)) else: agent_id = spawn_agent(arg, agent.profile, 600, agent.config, agent.store.seal, agent.depth + 1, agent.runner_override) print("agent %d started, REPL stays free" % agent_id) elif name == "agents": records = list_agents(agent.profile) if not records: print("(no agents)") elif Glow.enabled(): running = sum(1 for record in records if record["status"] == "running") print(Glow.event_row("◆", "agents", "%d total" % len(records), "%d running" % running)) for record in records[:50]: print(Glow.agent_row(record)) if len(records) > 50: print(paint(" · +%d more" % (len(records) - 50), Ansi.DIM)) else: for record in records: print("#%d [%s] %ds %s" % (record["id"], record["status"], record["elapsed"], record["task"][:60])) elif name == "agent": if arg == "clear": print("purged %d finished agents" % clear_agents(agent.profile)) else: try: agent_id = int(arg) except ValueError: print(paint("use /agent <id> or /agent clear", Ansi.RED)) return True status, text = poll_agent(agent_id, 0, agent.profile) print("[%s]\n%s" % (status, text)) elif name == "compact": agent.messages, changed = compact_messages(agent.messages, agent.chat) agent.apply_system() print("compacted" if changed else "nothing to compact") elif name == "clear": agent.reset_history() print("history cleared") elif name == "demo": tui_demo() else: print(paint("unknown command, try /help", Ansi.RED)) return True class Telegram: def __init__(self, token): self.token = token def call(self, method, params, timeout=70): body = json.dumps(params).encode("utf-8") request = urllib.request.Request(TELEGRAM_API + self.token + "/" + method, data=body, headers={"Content-Type": "application/json"}, method="POST") with urllib.request.urlopen(request, timeout=timeout) as response: return json.loads(response.read().decode("utf-8", "replace")) def telegram_env_path(home): return os.path.join(home, "telegram.env") def load_telegram_token(home): token = os.environ.get("TELEGRAM_BOT_TOKEN") or "" if token: return token path = telegram_env_path(home) if not os.path.exists(path): return "" with open(path, "r", encoding="utf-8") as handle: for line in handle.read().splitlines(): if line.startswith("TELEGRAM_BOT_TOKEN="): return line.split("=", 1)[1].strip().strip("'\"") return "" def telegram_send(bot, chat_id, text): for pos in range(0, len(text), 4000): bot.call("sendMessage", {"chat_id": chat_id, "text": text[pos:pos + 4000] or "(empty reply)"}) def telegram_transcribe(bot, file_id): try: info = bot.call("getFile", {"file_id": file_id}) remote = (info.get("result") or {}).get("file_path", "") if not remote: return "error: telegram returned no file" with urllib.request.urlopen(TELEGRAM_FILE_API + bot.token + "/" + remote, timeout=60) as response: audio = response.read(20000000) engine = ensure_box() return box_transcribe(engine, audio) except (OSError, ValueError, BackendError) as exc: return "error: transcription failed: " + short_error(exc) def handle_telegram_update(agent, bot, update): message = update.get("message") or {} chat_id = (message.get("chat") or {}).get("id") if not chat_id: return text = message.get("text") or "" voice = bool(message.get("voice") or message.get("audio")) LOG.context["chat"] = str(chat_id) try: outcome, reply_chars = _handle_telegram_update_inner(agent, bot, update) except Exception as exc: LOG.event("INFO", "telegram", "update", "telegram update failed: %s" % short_error(exc), profile=getattr(agent, "profile", ""), bot=getattr(agent, "bot", ""), chat=str(chat_id), meta={"chat": str(chat_id), "text_chars": len(text), "reply_chars": 0, "voice": voice, "outcome": "error"}) raise finally: LOG.context.pop("chat", None) LOG.event("INFO", "telegram", "update", "telegram update %s" % outcome, profile=getattr(agent, "profile", ""), bot=getattr(agent, "bot", ""), chat=str(chat_id), meta={"chat": str(chat_id), "text_chars": len(text), "reply_chars": reply_chars, "voice": voice, "outcome": outcome}) def _handle_telegram_update_inner(agent, bot, update): message = update.get("message") or {} chat_id = (message.get("chat") or {}).get("id") if not chat_id: return "ignored", 0 text = message.get("text") or "" if text == "/start": telegram_send(bot, chat_id, "tai online. Send any message, /new for a fresh start.") return "greeted", 0 if text == "/new": agent.reset_history() telegram_send(bot, chat_id, "fresh start.") return "reset", 0 voice = message.get("voice") or message.get("audio") if voice: bot.call("sendChatAction", {"chat_id": chat_id, "action": "typing"}) text = telegram_transcribe(bot, voice.get("file_id", "")) if text.startswith("error"): telegram_send(bot, chat_id, text) return "transcribe_error", len(text) if not text.strip(): return "ignored", 0 bot.call("sendChatAction", {"chat_id": chat_id, "action": "typing"}) reply = agent.run_turn(text, capture=True) telegram_send(bot, chat_id, reply) return "replied", len(reply or "") def run_telegram_bot(agent): token = load_telegram_token(agent.config.home) if not token: print("missing TELEGRAM_BOT_TOKEN", file=sys.stderr) return 2 bot = Telegram(token) try: me = bot.call("getMe", {}) except (OSError, ValueError) as exc: print("telegram auth failed: %s" % short_error(exc), file=sys.stderr) return 2 print("telegram bot online as @%s" % (me.get("result") or {}).get("username", "?")) start_scheduler(agent.config, agent.store.seal) offset = 0 while True: try: data = bot.call("getUpdates", {"offset": offset, "timeout": 50}) except (OSError, ValueError) as exc: print("poll error: %s, retrying" % short_error(exc)) time.sleep(5) continue for update in data.get("result") or []: offset = update.get("update_id", offset) + 1 try: handle_telegram_update(agent, bot, update) except Exception as exc: print("update error: %s" % short_error(exc)) def run_host(argv, timeout=120): try: done = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=timeout) return done.returncode == 0, (done.stdout or "") + (done.stderr or "") except (OSError, subprocess.SubprocessError) as exc: return False, short_error(exc) class Install: """Install probes (static; tests patch these live).""" @staticmethod def shell_present(shell): return shutil.which(shell) is not None @staticmethod def venv_available(): if importlib.util.find_spec("venv") is None: return False if importlib.util.find_spec("ensurepip") is not None: return True return shutil.which("pip3") is not None or shutil.which("pip") is not None @staticmethod def ensure_venv(home): found = venv_python(home) if found: return True, "kept %s" % found if not Install.venv_available(): return False, "python venv module unavailable" try: done = subprocess.run([sys.executable, "-m", "venv", venv_dir(home)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=300) except (OSError, subprocess.SubprocessError) as exc: return False, short_error(exc) if done.returncode != 0 or not venv_python(home): return False, ((done.stderr or "").strip()[:200] or "venv creation failed") return True, "created %s" % venv_python(home) def venv_available(): return Install.venv_available() def venv_dir(home): return os.path.join(home, "venv") def venv_python(home): cand = os.path.join(venv_dir(home), "bin", "python") if os.path.isfile(cand) and os.access(cand, os.X_OK): return cand return None def ensure_venv(home): return Install.ensure_venv(home) def service_exec(home, script, *args): parts = [] python = venv_python(home) if python: parts.append(python) parts.append(script) parts.extend(args) return " ".join(parts) def unit_dir(home): return os.path.join(os.path.expanduser("~"), ".config", "systemd", "user") def service_state(name): if not shutil.which("systemctl"): return "unknown", "unknown" active, _out = run_host(["systemctl", "--user", "is-active", name], timeout=15) enabled, _out = run_host(["systemctl", "--user", "is-enabled", name], timeout=15) return ("active" if active else "inactive"), ("enabled" if enabled else "disabled") def activate_service(name): if not shutil.which("systemctl"): return "systemctl missing, unit written only" run_host(["systemctl", "--user", "daemon-reload"]) active, _enabled = service_state(name) if active == "active": ok, out = run_host(["systemctl", "--user", "restart", name]) return "restarted, changes effective immediately" if ok else "restart failed: " + out.strip()[:150] ok, out = run_host(["systemctl", "--user", "enable", "--now", name]) if ok: user = os.environ.get("USER") or getpass.getuser() run_host(["loginctl", "enable-linger", user]) return "enabled and started" return "enable failed: " + out.strip()[:150] def remove_service_unit(home, name): path = os.path.join(unit_dir(home), name) if shutil.which("systemctl"): run_host(["systemctl", "--user", "disable", "--now", name]) run_host(["systemctl", "--user", "daemon-reload"]) if os.path.exists(path): try: os.remove(path) return True, "removed " + path except OSError as exc: return False, short_error(exc) return True, "not present" def script_version(path): try: with open(path, "r", encoding="utf-8") as handle: match = VERSION_RE.search(handle.read()) except OSError: return None return ".".join(match.groups()) if match else None def shell_rc_path(shell): """Where each shell's startup file lives. bash/zsh: a dotfile sourced for interactive shells only. fish: config.fish, which fish sources on every startup (interactive or not, login or not) -- hence the `status is-interactive` guard inside FISH_PLEXER_BLOCK rather than relying on the file only being read at the right time.""" home = os.path.expanduser("~") if shell == "fish": config_home = os.environ.get("XDG_CONFIG_HOME") or os.path.join(home, ".config") return os.path.join(config_home, "fish", "config.fish") if shell == "zsh": return os.path.join(home, ".zshrc") return os.path.join(home, ".bashrc") def shell_present(shell): return Install.shell_present(shell) def _rc_block_present(path, block): try: with open(path, "r", encoding="utf-8") as handle: return block in handle.read() except OSError: return False def _rc_block_remove(path, mark_begin, mark_end): try: with open(path, "r", encoding="utf-8") as handle: raw = handle.read() except OSError: return False stripped = re.sub(re.escape(mark_begin) + r".*?" + re.escape(mark_end) + r"\n?", "", raw, flags=re.DOTALL) if stripped == raw: return False backup = path + ".bak-tai" if raw and not os.path.exists(backup): with open(backup, "w", encoding="utf-8") as handle: handle.write(raw) with open(path, "w", encoding="utf-8") as handle: handle.write(stripped) return True def _rc_block_upsert(path, mark_begin, mark_end, block): try: with open(path, "r", encoding="utf-8") as handle: raw = handle.read() except OSError: raw = "" if block in raw: return False backup = path + ".bak-tai" if raw and not os.path.exists(backup): with open(backup, "w", encoding="utf-8") as handle: handle.write(raw) stripped = re.sub(re.escape(mark_begin) + r".*?" + re.escape(mark_end) + r"\n?", "", raw, flags=re.DOTALL) if stripped and not stripped.endswith("\n"): stripped += "\n" parent = os.path.dirname(path) if parent and not os.path.isdir(parent): os.makedirs(parent, exist_ok=True) with open(path, "w", encoding="utf-8") as handle: handle.write(stripped + block) return True def hook_present(path): return _rc_block_present(path, BASHRC_BLOCK) def remove_bashrc_block(path): return _rc_block_remove(path, BASHRC_MARK_BEGIN, BASHRC_MARK_END) def zsh_hook_present(path): return _rc_block_present(path, ZSH_NOTFOUND_BLOCK) def remove_zsh_hook_block(path): return _rc_block_remove(path, BASHRC_MARK_BEGIN, BASHRC_MARK_END) def upsert_zsh_hook_block(path): return _rc_block_upsert(path, BASHRC_MARK_BEGIN, BASHRC_MARK_END, ZSH_NOTFOUND_BLOCK) def fish_hook_present(path): return _rc_block_present(path, FISH_NOTFOUND_BLOCK) def remove_fish_hook_block(path): return _rc_block_remove(path, BASHRC_MARK_BEGIN, BASHRC_MARK_END) def upsert_fish_hook_block(path): return _rc_block_upsert(path, BASHRC_MARK_BEGIN, BASHRC_MARK_END, FISH_NOTFOUND_BLOCK) def plexer_hook_present(path): return _rc_block_present(path, PLEXER_BASHRC_BLOCK) def remove_plexer_bashrc_block(path): return _rc_block_remove(path, PLEXER_BASHRC_MARK_BEGIN, PLEXER_BASHRC_MARK_END) def upsert_plexer_bashrc_block(path): return _rc_block_upsert(path, PLEXER_BASHRC_MARK_BEGIN, PLEXER_BASHRC_MARK_END, PLEXER_BASHRC_BLOCK) # zsh reuses PLEXER_BASHRC_BLOCK byte-for-byte -- zsh is POSIX-enough that # the plain `[`/`case "$-"` bash content works unchanged; only the target # file differs (~/.zshrc, not ~/.bashrc). No ZSH_PLEXER_BLOCK constant exists # on purpose -- there is nothing shell-specific to put in one. def zsh_plexer_hook_present(path): return _rc_block_present(path, PLEXER_BASHRC_BLOCK) def remove_zsh_plexer_block(path): return _rc_block_remove(path, PLEXER_BASHRC_MARK_BEGIN, PLEXER_BASHRC_MARK_END) def upsert_zsh_plexer_block(path): return _rc_block_upsert(path, PLEXER_BASHRC_MARK_BEGIN, PLEXER_BASHRC_MARK_END, PLEXER_BASHRC_BLOCK) def fish_plexer_hook_present(path): return _rc_block_present(path, FISH_PLEXER_BLOCK) def remove_fish_plexer_block(path): return _rc_block_remove(path, PLEXER_BASHRC_MARK_BEGIN, PLEXER_BASHRC_MARK_END) def upsert_fish_plexer_block(path): return _rc_block_upsert(path, PLEXER_BASHRC_MARK_BEGIN, PLEXER_BASHRC_MARK_END, FISH_PLEXER_BLOCK) def box_venv_state(engine): try: probe = box_exec(engine, ["test", "-x", BOX_PYTHON], timeout=30) except (OSError, subprocess.SubprocessError): return "unknown" return "venv ready" if probe.returncode == 0 else "no venv (reinstall container to rebuild)" def shell_hook_status_line(label, shell, present_fn): path = shell_rc_path(shell) note = "" if shell_present(shell) else " (%s not found)" % shell return "%s: %s in %s%s" % (label, "installed" if present_fn(path) else "not installed", path, note) def status_lines(home, store=None): lines = [] binary = os.path.join(os.path.expanduser("~"), ".local", "bin", "tai.py") if os.path.isfile(binary): lines.append("binary: installed %s (%s, running %s)" % (binary, script_version(binary) or "unknown", VERSION)) else: lines.append("binary: not installed") lines.append("bash-hook: %s in ~/.bashrc" % ("installed" if hook_present(os.path.join(os.path.expanduser("~"), ".bashrc")) else "not installed")) lines.append(shell_hook_status_line("zsh-hook", "zsh", zsh_hook_present)) lines.append(shell_hook_status_line("fish-hook", "fish", fish_hook_present)) lines.append("plexer-hook: %s in ~/.bashrc" % ("installed" if plexer_hook_present(os.path.join(os.path.expanduser("~"), ".bashrc")) else "not installed")) lines.append(shell_hook_status_line("zsh-plexer-hook", "zsh", zsh_plexer_hook_present)) lines.append(shell_hook_status_line("fish-plexer-hook", "fish", fish_plexer_hook_present)) python = venv_python(home) if python: lines.append("venv: ready %s" % python) elif venv_available(): lines.append("venv: not created") else: lines.append("venv: unavailable (no venv module)") for name, label in (("tai-scheduler.service", "scheduler-service"), ("tai-telegram.service", "telegram-service")): unit = os.path.join(unit_dir(home), name) if not os.path.isfile(unit): lines.append("%s: not installed" % label) continue active, enabled = service_state(name) lines.append("%s: unit present, %s, %s" % (label, active, enabled)) engine = container_engine() if not engine: lines.append("container: no engine (install podman)") else: state = box_state(engine) if state == "running": lines.append("container: running, %s" % box_venv_state(engine)) else: lines.append("container: %s" % state) present = [name for name in sorted(BUNDLED_SKILLS) if os.path.isfile(os.path.join(home, "skills", name, "SKILL.md"))] missing = [name for name in sorted(BUNDLED_SKILLS) if name not in present] modified = [name for name in present if not bundled_skill_pristine(home, name)] detail = "skills: %d/%d bundled present" % (len(present), len(BUNDLED_SKILLS)) if missing: detail += ", missing: %s" % ", ".join(missing) if modified: detail += ", modified: %s" % ", ".join(modified) lines.append(detail) lines.append(vault_line(home, store)) return lines def vault_line(home, store=None): db_path = os.path.join(home, "memory.db") if not os.path.isfile(db_path): return "vault: empty, install ops never touch data" if store is None: return "vault: kept %s, install ops never touch data" % db_path try: secrets = store.db.execute("SELECT COUNT(*) FROM secrets").fetchone()[0] schedules = store.db.execute("SELECT COUNT(*) FROM schedules").fetchone()[0] records = store.db.execute("SELECT COUNT(*) FROM records").fetchone()[0] except sqlite3.Error: return "vault: kept %s, install ops never touch data" % db_path return "vault: kept (%d secrets, %d schedules, %d records), install ops never touch data" % (secrets, schedules, records) def op_binary(mode, home): target = os.path.join(os.path.expanduser("~"), ".local", "bin", "tai.py") src = os.path.abspath(__file__) current = script_version(target) if mode == "install" and current is not None: return "binary: already installed %s (running %s)" % (current, VERSION) if mode == "uninstall": if not os.path.exists(target): return "binary: not present" try: os.remove(target) return "binary: removed %s" % target except OSError as exc: return "binary: remove failed: %s" % short_error(exc) try: os.makedirs(os.path.dirname(target), exist_ok=True) with open(src, "rb") as handle: blob = handle.read() with open(target, "wb") as handle: handle.write(blob) os.chmod(target, 0o755) except OSError as exc: return "binary: copy failed: %s" % short_error(exc) if current is None: return "binary: installed %s" % VERSION return "binary: refreshed %s -> %s" % (current, VERSION) def op_hook(mode, home): path = os.path.join(os.path.expanduser("~"), ".bashrc") if mode == "uninstall": return "bash-hook: %s" % ("removed" if remove_bashrc_block(path) else "not present") if mode == "reinstall": remove_bashrc_block(path) try: changed = upsert_bashrc_block(path) except OSError as exc: return "bash-hook: write failed: %s" % short_error(exc) if changed: return "bash-hook: installed, run: source ~/.bashrc" return "bash-hook: already present" def op_plexer_hook(mode, home): path = os.path.join(os.path.expanduser("~"), ".bashrc") if mode == "uninstall": return "plexer-hook: %s" % ("removed" if remove_plexer_bashrc_block(path) else "not present") if mode == "reinstall": remove_plexer_bashrc_block(path) try: changed = upsert_plexer_bashrc_block(path) except OSError as exc: return "plexer-hook: write failed: %s" % short_error(exc) if changed: return "plexer-hook: installed, run: source ~/.bashrc (new interactive shells auto-wrap; existing ones are unaffected until restarted)" return "plexer-hook: already present" def op_shell_hook(mode, shell, label, upsert_fn, remove_fn, source_hint): """Shared body for the zsh/fish hook variants -- mirrors op_hook/op_plexer_hook exactly, plus one addition: install/upgrade skip loudly (never write a dangling hook into a config file for a shell that isn't even on this machine) when the shell binary isn't found. uninstall/reinstall/status still act on the file regardless, so a hook installed before a shell was later removed still cleans up.""" path = shell_rc_path(shell) if mode in ("install", "upgrade") and not shell_present(shell): return "%s: %s not found, skipped" % (label, shell) if mode == "uninstall": return "%s: %s" % (label, "removed" if remove_fn(path) else "not present") if mode == "reinstall": remove_fn(path) try: changed = upsert_fn(path) except OSError as exc: return "%s: write failed: %s" % (label, short_error(exc)) if changed: return "%s: installed, run: %s" % (label, source_hint) return "%s: already present" % label def op_zsh_hook(mode, home): return op_shell_hook(mode, "zsh", "zsh-hook", upsert_zsh_hook_block, remove_zsh_hook_block, "source ~/.zshrc") def op_fish_hook(mode, home): return op_shell_hook(mode, "fish", "fish-hook", upsert_fish_hook_block, remove_fish_hook_block, "open a new fish shell") def op_zsh_plexer_hook(mode, home): return op_shell_hook( mode, "zsh", "zsh-plexer-hook", upsert_zsh_plexer_block, remove_zsh_plexer_block, "source ~/.zshrc (new interactive shells auto-wrap; existing ones are unaffected until restarted)", ) def op_fish_plexer_hook(mode, home): return op_shell_hook( mode, "fish", "fish-plexer-hook", upsert_fish_plexer_block, remove_fish_plexer_block, "open a new fish shell (new interactive shells auto-wrap; existing ones are unaffected until restarted)", ) def op_venv(mode, home): if mode == "uninstall": folder = venv_dir(home) if not os.path.isdir(folder): return "venv: not present" try: shutil.rmtree(folder) return "venv: removed %s" % folder except OSError as exc: return "venv: remove failed: %s" % short_error(exc) if mode == "reinstall" and os.path.isdir(venv_dir(home)): try: shutil.rmtree(venv_dir(home)) except OSError as exc: return "venv: rebuild failed: %s" % short_error(exc) ok, detail = ensure_venv(home) return "venv: %s" % detail def op_service(mode, home, name, label, body): unit = os.path.join(unit_dir(home), name) if mode == "uninstall": _done, detail = remove_service_unit(home, name) return "%s: %s" % (label, detail) existed = os.path.isfile(unit) if mode == "install" and existed: active, enabled = service_state(name) return "%s: already installed (%s, %s)" % (label, active, enabled) if mode == "reinstall" and shutil.which("systemctl"): run_host(["systemctl", "--user", "disable", "--now", name]) try: os.makedirs(os.path.dirname(unit), exist_ok=True) with open(unit, "w", encoding="utf-8") as handle: handle.write(body) except OSError as exc: return "%s: unit write failed: %s" % (label, short_error(exc)) return "%s: unit %s, %s" % (label, "rewritten" if existed else "written", activate_service(name)) def op_scheduler_service(mode, home): body = SCHEDULER_UNIT % service_exec(home, os.path.abspath(__file__)) return op_service(mode, home, "tai-scheduler.service", "scheduler-service", body) def op_telegram_service(mode, home): if mode == "uninstall": _done, detail = remove_service_unit(home, "tai-telegram.service") return "telegram-service: %s" % detail unit = os.path.join(unit_dir(home), "tai-telegram.service") if mode == "install" and os.path.isfile(unit): active, enabled = service_state("tai-telegram.service") return "telegram-service: already installed (%s, %s)" % (active, enabled) token = os.environ.get("TELEGRAM_BOT_TOKEN") or load_telegram_token(home) if not token: return "telegram-service: needs TELEGRAM_BOT_TOKEN in the environment" try: me = Telegram(token).call("getMe", {}) except (OSError, ValueError) as exc: return "telegram-service: token rejected: %s" % short_error(exc) env_path = telegram_env_path(home) try: with open(env_path, "w", encoding="utf-8") as handle: handle.write("TELEGRAM_BOT_TOKEN=%s\n" % token) os.chmod(env_path, 0o600) except OSError as exc: return "telegram-service: env write failed: %s" % short_error(exc) body = TELEGRAM_UNIT % (service_exec(home, os.path.abspath(__file__)), env_path) if mode == "reinstall" and shutil.which("systemctl"): run_host(["systemctl", "--user", "disable", "--now", "tai-telegram.service"]) try: os.makedirs(os.path.dirname(unit), exist_ok=True) with open(unit, "w", encoding="utf-8") as handle: handle.write(body) except OSError as exc: return "telegram-service: unit write failed: %s" % short_error(exc) who = (me.get("result") or {}).get("username", "?") return "telegram-service: @%s, %s" % (who, activate_service("tai-telegram.service")) def op_container(mode, _home): engine = container_engine() if not engine: return "container: no engine (install podman)" if mode == "uninstall": run_host([engine, "rm", "-f", BOX_NAME]) ok, _out = run_host([engine, "rmi", "-f", BOX_IMAGE]) return "container: removed%s" % (", image purged" if ok else "") if mode == "reinstall": run_host([engine, "rm", "-f", BOX_NAME]) run_host([engine, "rmi", "-f", BOX_IMAGE]) try: engine = ensure_box() except BackendError as exc: return "container: failed: %s" % exc state = box_state(engine) detail = ", %s" % box_venv_state(engine) if state == "running" else "" if mode == "reinstall": return "container: rebuilt and %s%s" % (state, detail) if mode == "install" and state == "running": return "container: already running%s" % detail return "container: %s%s" % (state, detail) def bundled_skill_digest(text): return hashlib.sha256(text.encode("utf-8")).hexdigest()[:8] def bundled_skill_pristine(home, name): folder = os.path.join(home, "skills", name) for rel, expected in BUNDLED_SKILLS[name].items(): path = os.path.join(folder, rel) try: with open(path, "r", encoding="utf-8") as handle: if handle.read() != expected: return False except OSError: return False return True def write_bundled_skill(home, name, force): """Write one bundled skill's files. Returns (written, kept, errors, overwritten). A missing file is written; an equal file is left; a differing file is kept untouched unless force, and reported loudly. User-added extra files are never touched under any mode.""" base = os.path.join(home, "skills") folder = os.path.join(base, name) files = BUNDLED_SKILLS[name] written, kept, errors, overwritten = [], [], [], [] try: os.makedirs(folder, exist_ok=True) except OSError as exc: return written, kept, ["%s: %s" % (name, short_error(exc))], overwritten for rel in sorted(files): path = os.path.join(folder, rel) differ = False try: if os.path.isfile(path): with open(path, "r", encoding="utf-8") as handle: if handle.read() == files[rel]: continue if not force: kept.append(rel) continue differ = True elif os.path.dirname(path): os.makedirs(os.path.dirname(path), exist_ok=True) except OSError as exc: errors.append("%s/%s: %s" % (name, rel, short_error(exc))) continue try: with open(path, "w", encoding="utf-8") as handle: handle.write(files[rel]) except OSError as exc: errors.append("%s/%s: %s" % (name, rel, short_error(exc))) continue written.append(rel) if differ: overwritten.append(rel) if not errors and not kept: meta = { "source": "tai-bundled", "mode": "bundled", "scope": "home", "upstream": BUNDLED_SKILL_UPSTREAM[name], "bundled_version": VERSION, "files": dict((rel, bundled_skill_digest(files[rel])) for rel in sorted(files)), "installed_at": datetime.now(timezone.utc).isoformat(), } try: save_skill_install_meta(base, name, meta) except OSError as exc: errors.append("%s sidecar: %s" % (name, short_error(exc))) return written, kept, errors, overwritten def remove_bundled_skill(home, name): """Remove one bundled skill's tracked files. Returns (removed, leftovers, errors). Tracked files that still match the bundled content (or the sidecar hash) go; user-modified content and user-added files stay and are reported loudly.""" base = os.path.join(home, "skills") folder = os.path.join(base, name) files = BUNDLED_SKILLS[name] meta = load_skill_install_meta(base, name) or {} tracked = meta.get("files") or {} removed, leftovers, errors = [], [], [] if not os.path.isdir(folder): sidecar = skill_install_meta_path(base, name) if os.path.isfile(sidecar): try: os.remove(sidecar) except OSError as exc: errors.append("%s sidecar: %s" % (name, short_error(exc))) return removed, leftovers, errors for rel in sorted(files): path = os.path.join(folder, rel) if not os.path.isfile(path): continue try: with open(path, "r", encoding="utf-8") as handle: current = handle.read() except OSError as exc: errors.append("%s/%s: %s" % (name, rel, short_error(exc))) continue if current == files[rel] or bundled_skill_digest(current) == tracked.get(rel): try: os.remove(path) removed.append(rel) except OSError as exc: errors.append("%s/%s: %s" % (name, rel, short_error(exc))) else: leftovers.append(rel) for root, _dirs, found in os.walk(folder): for entry in found: full = os.path.join(root, entry) if os.path.relpath(full, folder) not in files: leftovers.append(os.path.relpath(full, folder)) if not leftovers and not errors: try: shutil.rmtree(folder) except OSError as exc: errors.append("%s: %s" % (name, short_error(exc))) return removed, leftovers, errors sidecar = skill_install_meta_path(base, name) if os.path.isfile(sidecar): try: os.remove(sidecar) except OSError as exc: errors.append("%s sidecar: %s" % (name, short_error(exc))) return removed, leftovers, errors def op_skills(mode, home): names = sorted(BUNDLED_SKILLS) if mode == "uninstall": gone, kept, errors = [], [], [] for name in names: folder = os.path.join(home, "skills", name) sidecar = skill_install_meta_path(os.path.join(home, "skills"), name) if not os.path.isdir(folder) and not os.path.isfile(sidecar): continue _removed, leftovers, errs = remove_bundled_skill(home, name) errors += errs if leftovers: kept.append("%s (%s)" % (name, ", ".join(sorted(leftovers)))) elif not errs: gone.append(name) if not gone and not kept and not errors: return "skills: not present" parts = [] if gone: parts.append("removed %d (%s)" % (len(gone), ", ".join(gone))) if kept: parts.append("kept modified %d (%s)" % (len(kept), "; ".join(kept))) if errors: parts.append("errors: %s" % "; ".join(errors)) return "skills: %s" % ", ".join(parts) outcomes = {"installed": [], "refreshed": [], "reinstalled": [], "current": [], "kept": [], "errors": []} for name in names: folder = os.path.join(home, "skills", name) existed = os.path.isfile(os.path.join(folder, "SKILL.md")) written, kept, errs, overwritten = write_bundled_skill(home, name, force=(mode == "reinstall")) outcomes["errors"] += errs if kept: outcomes["kept"].append("%s (%s)" % (name, ", ".join(kept))) elif mode == "reinstall" and (written or overwritten): outcomes["reinstalled"].append(name) elif written and not existed: outcomes["installed"].append(name) elif written: outcomes["refreshed"].append(name) elif not errs: outcomes["current"].append(name) parts = [] for key in ("installed", "refreshed", "reinstalled"): if outcomes[key]: parts.append("%s %d (%s)" % (key, len(outcomes[key]), ", ".join(outcomes[key]))) if outcomes["current"]: parts.append("already current %d (%s)" % (len(outcomes["current"]), ", ".join(outcomes["current"]))) if outcomes["kept"]: parts.append("kept modified %d (%s)" % (len(outcomes["kept"]), "; ".join(outcomes["kept"]))) if outcomes["errors"]: parts.append("errors: %s" % "; ".join(outcomes["errors"])) if len(parts) == 1 and len(outcomes["current"]) == len(names): return "skills: already present (%d)" % len(names) return "skills: %s" % ", ".join(parts) def install_report(action, targets, home, store=None): if action == "status": return "\n".join(status_lines(home, store)) lines = [] for target in targets: if target == "binary": lines.append(op_binary(action, home)) elif target == "bash-hook": lines.append(op_hook(action, home)) elif target == "zsh-hook": lines.append(op_zsh_hook(action, home)) elif target == "fish-hook": lines.append(op_fish_hook(action, home)) elif target == "plexer-hook": lines.append(op_plexer_hook(action, home)) elif target == "zsh-plexer-hook": lines.append(op_zsh_plexer_hook(action, home)) elif target == "fish-plexer-hook": lines.append(op_fish_plexer_hook(action, home)) elif target == "venv": lines.append(op_venv(action, home)) elif target == "scheduler-service": lines.append(op_scheduler_service(action, home)) elif target == "telegram-service": lines.append(op_telegram_service(action, home)) elif target == "container": lines.append(op_container(action, home)) elif target == "skills": lines.append(op_skills(action, home)) lines.append(vault_line(home, store)) return "\n".join(lines) def install_telegram(): token = os.environ.get("TELEGRAM_BOT_TOKEN") or "" if token: print("using TELEGRAM_BOT_TOKEN from environment") else: try: token = getpass.getpass("Telegram bot token: ").strip() except (EOFError, KeyboardInterrupt): print("\naborted") return 1 if not token: print("empty token, aborted") return 1 try: me = Telegram(token).call("getMe", {}) except (OSError, ValueError) as exc: print("token rejected: %s" % short_error(exc)) return 1 print("token ok for @%s" % (me.get("result") or {}).get("username", "?")) home = os.environ.get("TAI_HOME") or os.path.join(os.path.expanduser("~"), ".tai") os.makedirs(home, exist_ok=True) env_path = telegram_env_path(home) with open(env_path, "w", encoding="utf-8") as handle: handle.write("TELEGRAM_BOT_TOKEN=%s\n" % token) try: os.chmod(env_path, 0o600) except OSError: pass print("wrote %s" % env_path) try: ensure_box() except BackendError as exc: print("container failed: %s" % exc) return 1 unit_folder = os.path.join(os.path.expanduser("~"), ".config", "systemd", "user") os.makedirs(unit_folder, exist_ok=True) unit_path = os.path.join(unit_folder, "tai-telegram.service") with open(unit_path, "w", encoding="utf-8") as handle: handle.write(TELEGRAM_UNIT % (service_exec(home, os.path.abspath(__file__)), env_path)) print("wrote %s" % unit_path) print("service: %s" % activate_service("tai-telegram.service")) return 0 def uninstall_telegram(): unit_path = os.path.join(os.path.expanduser("~"), ".config", "systemd", "user", "tai-telegram.service") if shutil.which("systemctl"): run_host(["systemctl", "--user", "disable", "--now", "tai-telegram.service"]) if os.path.exists(unit_path): try: os.remove(unit_path) print("removed %s" % unit_path) except OSError as exc: print("unit remove failed: %s" % short_error(exc)) else: print("no service unit found") if shutil.which("systemctl"): run_host(["systemctl", "--user", "daemon-reload"]) engine = container_engine() if not engine: print("no container engine found") return 0 ok, _out = run_host([engine, "rm", "-f", BOX_NAME]) print("container remove: %s" % ("ok" if ok else "not present")) ok, _out = run_host([engine, "rmi", "-f", BOX_IMAGE]) print("image purge: %s" % ("ok" if ok else "not present")) return 0 def upsert_bashrc_block(path): return _rc_block_upsert(path, BASHRC_MARK_BEGIN, BASHRC_MARK_END, BASHRC_BLOCK) def install_self(): home = os.environ.get("TAI_HOME") or os.path.join(os.path.expanduser("~"), ".tai") print(op_binary("upgrade", home)) print(op_hook("install", home)) # bash-hook always goes in, unconditionally, for back-compat -- it's harmless clutter # in ~/.bashrc for a zsh/fish user who never touches bash. $SHELL additionally gets its # own matching hook so the one-shot CLI bootstrap (--install) actually works out of the # box on a non-bash login shell, not just when the user later runs /install zsh-hook. login_shell = os.path.basename(os.environ.get("SHELL") or "") rc = "~/.bashrc" if login_shell == "zsh": print(op_zsh_hook("install", home)) rc = "~/.zshrc" elif login_shell == "fish": print(op_fish_hook("install", home)) rc = "~/.config/fish/config.fish" print(op_venv("install", home)) print(op_skills("install", home)) print("restart your shell or run: source %s" % rc) return 0 def apply_dir_defaults(agent, directory=None): key = dir_key(directory) saved = agent.store.load_dir_defaults(key) if not saved: return "" bits = [] if saved.get("provider"): offered = set((cand["provider"] or "").lower() for cand in agent.chat.candidates()) if saved["provider"] in offered: agent.chat.provider_pin = saved["provider"] bits.append("provider=%s" % saved["provider"]) else: print(paint("dir default provider '%s' is not available here, ignoring it (@auto clears it)" % saved["provider"], Ansi.DIM), file=sys.stderr) if saved.get("model") and not getattr(agent.config, "explicit_model", ""): agent.chat.model_pin = saved["model"] bits.append("model=%s" % saved["model"]) if saved.get("bot") and saved["bot"] != agent.bot: switched = agent.switch_bot(saved["bot"], silent=True) if switched.startswith("switched"): bits.append("bot=%s" % saved["bot"]) if not bits: return "" return "dir defaults for %s: %s" % (key, " ".join(bits)) def boot(args): args.yes = args.yes or args.yolo or args.auto config = Config(args) passphrase, default_key = resolve_passphrase() try: seal = Seal(config.home, passphrase) except SealError as exc: if default_key: raise try: old = Seal(config.home, DEFAULT_PASSPHRASE) except SealError: raise exc try: seal = rotate_seal(config, old, passphrase) except (OSError, sqlite3.Error) as fail: raise SealError(str(fail)) print("seal upgraded from default key to TAI_PASSPHRASE") seal.default_key = default_key and seal.enabled store = Store(config, seal) if LOG.enabled and LOG.db is None: LOG.attach_store(store) if store.schema_notes: print("vault schema upgraded: %s" % ", ".join(store.schema_notes)) LOG.event("INFO", "vault", "schema_upgraded", "vault schema upgraded: %s" % ", ".join(store.schema_notes), profile=config.profile, meta={"notes": ", ".join(store.schema_notes)}) applied_settings = boot_settings(config, store) if applied_settings: print("settings from vault: %s" % ", ".join(applied_settings)) ctx = ShellSession.current() ctx.register(store) ctx.export() try: backed_up = ensure_self_backup(config, store) except (OSError, sqlite3.Error) as exc: print("warning: self-backup failed: %s" % short_error(exc)) backed_up = None if backed_up is not None: print("self-backup: %s" % os.path.basename(backed_up)) agent = Agent(config, store, yolo=args.yolo or args.auto, auto=args.auto, ctx=ctx) try: loaded = apply_dir_defaults(agent) except OSError: loaded = "" if loaded: print(paint(loaded, Ansi.DIM)) if getattr(args, "scheduler", False): mode = "scheduler" elif getattr(args, "telegram", False): mode = "telegram" elif getattr(args, "tui_local", False): mode = "tui-local" elif getattr(args, "serve_mcp", None) is not None: mode = "serve-mcp" elif getattr(args, "cap_speak", False): mode = "cap-speak" elif getattr(args, "eval_retrieval", False): mode = "eval" elif getattr(args, "update_models", False): mode = "update-models" elif getattr(args, "prompt", None): mode = "one-shot" else: mode = "repl" LOG.event("INFO", "app", "boot", "tai %s booted (%s)" % (VERSION, mode), profile=agent.profile, meta={"version": VERSION, "home": config.home, "mode": mode}) return agent # --------------------------------------------------------------------------- # # TUI framework: retained component tree, compositor, app loop. # # A tiny Textual-shaped, DOM-like UI toolkit in one section of this file: # Panel-rooted components (Panel, Text, Button, Options, Dialog, Question, # Terminal, Progress, Log), rows/cols/dock/layer layout with cells, percent, # fr and auto sizes, a cuts/chops/occlusion compositor with named layers and # z-order, bubbling events, SGR mouse input, focus chains with modal traps, # and tick-driven animations. Sub-terminals are real ptys streamed through an # in-house VT screen emulator. No dependencies beyond the standard library. # Full guide: tui.md. Live tour: /demo in the REPL or ./tai.py --demo. # --------------------------------------------------------------------------- # TUI_FPS = 30 TUI_INPUT_PAINT_GAP = 0.008 TUI_GEOMETRY = ("x", "y", "w", "h") TUI_DECLRMM = 69 TUI_CELL_KEY = operator.itemgetter(1) TUI_SWITCH_COST = 40 TUI_MOVE_COST = 8 TUI_SCROLL_SLACK = 80 TUI_MIN_ROWS = 8 TUI_MIN_COLS = 20 TUI_LAYERS = {"base": 0, "panel": 10, "dialog": 20, "toast": 30} TUI_COLORS = {"black": 0, "red": 1, "green": 2, "yellow": 3, "blue": 4, "magenta": 5, "cyan": 6, "white": 7} TUI_THEME_ROLES = ("bg", "surface", "fg", "muted", "border", "accent", "on_accent", "selection", "success", "warning", "error", "info", "bubble_in", "bubble_out", "bubble_fg", "meta_in", "meta_out", "tick_read", "service_bg", "service_fg", "unread", "unread_muted", "panel", "accent2", "accent3", "code_bg", "tool_bg") TUI_THEME_DEFAULT = "dark" class TuiTheme: """Named palette: 12 role colors plus a dark flag. Role values use the same color language as TuiStyle (names, #rrggbb, ints).""" def __init__(self, name, dark, roles): self.name = name self.dark = bool(dark) self.roles = dict(roles) def color(self, role): return self.roles.get(role) TUI_THEMES = {} def tui_register_theme(theme): missing = [role for role in TUI_THEME_ROLES if not theme.color(role)] if missing: raise ValueError("theme '%s' lacks roles: %s" % (theme.name, ", ".join(missing))) TUI_THEMES[theme.name] = theme return theme def _tui_builtin_themes(): tui_register_theme(TuiTheme("dark", True, { "bg": "#0a0e1a", "surface": "#141b2e", "fg": "#e5e9f5", "muted": "#7f8aa8", "border": "#2a3558", "accent": "#8b5cf6", "on_accent": "#ffffff", "selection": "#22305f", "success": "#34d399", "warning": "#fbbf24", "error": "#fb7185", "info": "#38bdf8", "bubble_in": "#18223d", "bubble_out": "#4338ca", "bubble_fg": "#f1f5ff", "meta_in": "#6b7aa3", "meta_out": "#c7d2fe", "tick_read": "#67e8f9", "service_bg": "#1d2750", "service_fg": "#a5b4fc", "unread": "#8b5cf6", "unread_muted": "#3a4668", "panel": "#0e1424", "accent2": "#22d3ee", "accent3": "#f472b6", "code_bg": "#0b1226", "tool_bg": "#101a33"})) tui_register_theme(TuiTheme("light", False, { "bg": "#f5f7ff", "surface": "#ffffff", "fg": "#1b2036", "muted": "#6a7494", "border": "#cdd5f0", "accent": "#6d4aff", "on_accent": "#ffffff", "selection": "#dcd6ff", "success": "#0f9d6b", "warning": "#b7791f", "error": "#d6336c", "info": "#0b7fc2", "bubble_in": "#ffffff", "bubble_out": "#dcd6ff", "bubble_fg": "#1b2036", "meta_in": "#8a93b2", "meta_out": "#6b5bd6", "tick_read": "#0b7fc2", "service_bg": "#dfe4fa", "service_fg": "#4a55a0", "unread": "#6d4aff", "unread_muted": "#b9c0dd", "panel": "#eaeefc", "accent2": "#0891b2", "accent3": "#db2777", "code_bg": "#eef1fd", "tool_bg": "#e3e8fb"})) tui_register_theme(TuiTheme("nord", True, { "bg": "#2e3440", "surface": "#3b4252", "fg": "#eceff4", "muted": "#7c8698", "border": "#4c566a", "accent": "#88c0d0", "on_accent": "#2e3440", "selection": "#434c5e", "success": "#a3be8c", "warning": "#ebcb8b", "error": "#bf616a", "info": "#81a1c1", "bubble_in": "#3b4252", "bubble_out": "#4c6a8a", "bubble_fg": "#eceff4", "meta_in": "#7c8698", "meta_out": "#88c0d0", "tick_read": "#88c0d0", "service_bg": "#4c566a", "service_fg": "#eceff4", "unread": "#88c0d0", "unread_muted": "#4c566a", "panel": "#292e39", "accent2": "#8fbcbb", "accent3": "#b48ead", "code_bg": "#272c36", "tool_bg": "#333a48"})) tui_register_theme(TuiTheme("gruvbox", True, { "bg": "#282828", "surface": "#3c3836", "fg": "#ebdbb2", "muted": "#928374", "border": "#504945", "accent": "#83a598", "on_accent": "#282828", "selection": "#458588", "success": "#b8bb26", "warning": "#fabd2f", "error": "#fb4934", "info": "#8ec07c", "bubble_in": "#3c3836", "bubble_out": "#458588", "bubble_fg": "#ebdbb2", "meta_in": "#928374", "meta_out": "#83a598", "tick_read": "#83a598", "service_bg": "#504945", "service_fg": "#ebdbb2", "unread": "#458588", "unread_muted": "#665c54", "panel": "#1d2021", "accent2": "#8ec07c", "accent3": "#d3869b", "code_bg": "#1d2021", "tool_bg": "#32302f"})) tui_register_theme(TuiTheme("ansi", True, { "bg": "black", "surface": "black", "fg": "white", "muted": "bright_black", "border": "white", "accent": "cyan", "on_accent": "black", "selection": "blue", "success": "green", "warning": "yellow", "error": "red", "info": "cyan", "bubble_in": "black", "bubble_out": "blue", "bubble_fg": "white", "meta_in": "white", "meta_out": "cyan", "tick_read": "bright_blue", "service_bg": "black", "service_fg": "white", "unread": "blue", "unread_muted": "white", "panel": "black", "accent2": "cyan", "accent3": "magenta", "code_bg": "black", "tool_bg": "black"})) _tui_builtin_themes() TUI_THEME = TUI_THEMES[TUI_THEME_DEFAULT] def tui_theme(): return TUI_THEME def tui_set_theme(name): global TUI_THEME if name not in TUI_THEMES: raise ValueError("unknown theme '%s' (available: %s)" % (name, ", ".join(sorted(TUI_THEMES)))) TUI_THEME = TUI_THEMES[name] return TUI_THEME def tui_resolve_theme_name(flag): """Precedence: --theme flag, TAI_THEME env, default. Raises ValueError naming the available themes when the pick is unknown.""" name = flag or os.environ.get("TAI_THEME") or TUI_THEME_DEFAULT if name not in TUI_THEMES: raise ValueError("unknown theme '%s' (available: %s)" % (name, ", ".join(sorted(TUI_THEMES)))) return name def tui_cycle_theme_name(current): names = list(TUI_THEMES) if current in names: return names[(names.index(current) + 1) % len(names)] return TUI_THEME_DEFAULT class TuiWatched: """Base of state objects a panel paints from. Assign one to a public panel attribute (or insert it into a list, dict or set kept there) and a later write to any of its public fields touches that panel, so a widget never shows stale cells because someone forgot touch(). Equal writes are ignored. Subclass it for your own models; fields starting with an underscore are private and never touch. Copies and pickles do not carry the owner links.""" def __setattr__(self, name, value): fields = self.__dict__ if name in fields and fields[name] == value: return fields[name] = value if name[0] != "_": self._changed() def _changed(self): owners = self.__dict__.get("_owners") if owners: for panel in list(owners): panel.touch() def adopt(self, panel): """Register a panel that paints from this object, so a field written later repaints it.""" owners = self.__dict__.get("_owners") if owners is None: owners = self.__dict__["_owners"] = weakref.WeakSet() owners.add(panel) def __getstate__(self): state = dict(self.__dict__) state.pop("_owners", None) return state def __copy__(self): clone = self.__class__.__new__(self.__class__) clone.__dict__.update(self.__getstate__()) return clone class TuiStyle(TuiWatched): """Foreground/background/attribute bundle. Colors are names ("red", "bright_cyan"), 256-color ints, "#rrggbb" strings, or (r, g, b) tuples.""" def __init__(self, fg=None, bg=None, bold=False, dim=False, underline=False, reverse=False, strike=False): fields = self.__dict__ fields["fg"] = fg fields["bg"] = bg fields["bold"] = bold fields["dim"] = dim fields["underline"] = underline fields["reverse"] = reverse fields["strike"] = strike def _changed(self): self.__dict__.pop("_key", None) TuiWatched._changed(self) def key(self): """Hashable identity of the look; cached, and any later write to a field drops the cache.""" fields = self.__dict__ key = fields.get("_key") if key is None: key = fields["_key"] = (TuiStyle.color_key(self.fg), TuiStyle.color_key(self.bg), self.bold, self.dim, self.underline, self.reverse, self.strike) return key def sgr(self): parts = ["0"] if self.bold: parts.append("1") if self.dim: parts.append("2") if self.underline: parts.append("4") if self.reverse: parts.append("7") if self.strike: parts.append("9") fg = TuiStyle.color_sgr(self.fg, False) if fg: parts.append(fg) bg = TuiStyle.color_sgr(self.bg, True) if bg: parts.append(bg) return "\x1b[" + ";".join(parts) + "m" def merged(self, other): if other is None: return self merged = TuiStyle(self.fg, self.bg, self.bold, self.dim, self.underline, self.reverse, self.strike) if other.fg is not None: merged.fg = other.fg if other.bg is not None: merged.bg = other.bg merged.bold = merged.bold or other.bold merged.dim = merged.dim or other.dim merged.underline = merged.underline or other.underline merged.reverse = merged.reverse or other.reverse merged.strike = merged.strike or other.strike return merged @staticmethod def sgr_from_key(key): """The sgr() of the style that produced key(): lets the compositor emit bytes from cell keys alone, with no style object kept alive.""" fg, bg, bold, dim, underline, reverse, strike = key parts = ["0"] if bold: parts.append("1") if dim: parts.append("2") if underline: parts.append("4") if reverse: parts.append("7") if strike: parts.append("9") fg_sgr = TuiStyle.color_sgr(fg, False) if fg_sgr: parts.append(fg_sgr) bg_sgr = TuiStyle.color_sgr(bg, True) if bg_sgr: parts.append(bg_sgr) return "\x1b[" + ";".join(parts) + "m" @staticmethod def color_key(color): if color is None or isinstance(color, int) or isinstance(color, str): return color return tuple(color) @staticmethod def color_sgr(color, background): if color is None: return "" if isinstance(color, int): return "%d;5;%d" % (48 if background else 38, max(0, min(255, color))) if isinstance(color, (tuple, list)) and len(color) == 3: return "%d;2;%d;%d;%d" % ((48 if background else 38,) + tuple(max(0, min(255, int(part))) for part in color)) if isinstance(color, str): text = color.strip().lower() if text.startswith("role:"): resolved = tui_theme().color(text[len("role:"):]) return TuiStyle.color_sgr(resolved, background) if resolved else "" if text.startswith("#") and len(text) == 7: try: rgb = tuple(int(text[pos:pos + 2], 16) for pos in (1, 3, 5)) except ValueError: return "" return "%d;2;%d;%d;%d" % ((48 if background else 38,) + rgb) bright = text.startswith("bright_") if bright: text = text[len("bright_"):] if text in TUI_COLORS: base = (90 if bright else 30) if not background else (100 if bright else 40) return str(base + TUI_COLORS[text]) return "" class TuiSpan(TuiWatched): """One styled text run: the compositor's paint primitive.""" def __init__(self, text, style=None): fields = self.__dict__ fields["text"] = text fields["style"] = style or TuiStyle() def tui_ease(name, progress): """Easing curve for animate(): progress 0..1 in, eased 0..1 out.""" t = max(0.0, min(1.0, progress)) if name == "linear": return t if name == "in_quad": return t * t if name == "out_quad": return 1.0 - (1.0 - t) * (1.0 - t) if name == "in_cubic": return t * t * t if name == "out_cubic": return 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t) if name == "in_out_quad": return 2.0 * t * t if t < 0.5 else 1.0 - (-2.0 * t + 2.0) ** 2 / 2.0 return 4.0 * t * t * t if t < 0.5 else 1.0 - (-2.0 * t + 2.0) ** 3 / 2.0 class TuiEvent: """Base event: bubble=True walks target, parent chain; stop() halts it.""" name = "event" bubble = True def __init__(self, **fields): self.__dict__.update(fields) self.target = None self.stopped = False def stop(self): self.stopped = True @property def handler_name(self): return "on_" + self.name class TuiKey(TuiEvent): name = "key" class TuiClick(TuiEvent): name = "click" class TuiScroll(TuiEvent): name = "scroll" class TuiFocus(TuiEvent): name = "focus" bubble = False class TuiBlur(TuiEvent): name = "blur" bubble = False class TuiResize(TuiEvent): name = "resize" bubble = False class TuiTick(TuiEvent): name = "tick" bubble = False class TuiModeReply(TuiEvent): """The terminal's answer to a DECRQM query (mode, state): 1 set, 2 reset, 0 unknown, 3 and 4 fixed. Consumed by the app, never delivered to panels.""" name = "mode_reply" bubble = False class TuiMount(TuiEvent): name = "mount" bubble = False class TuiUnmount(TuiEvent): name = "unmount" bubble = False class TuiSelect(TuiEvent): name = "select" class TuiPress(TuiEvent): name = "press" class TuiClose(TuiEvent): name = "close" class TuiPaste(TuiEvent): name = "paste" class TuiChange(TuiEvent): name = "change" bubble = False def tui_int(value, default=0): try: return int(value) except (TypeError, ValueError): return default def tui_flatten(spans): cells = [] for span in spans: for char in span.text: cells.append((char, span.style)) return cells def tui_runs(cells): """Merge neighbor cells with equal style into runs. Cells mostly share one style object, so identity short-circuits the key comparison.""" runs = [] last = last_key = None for char, style in cells: if style is last: runs[-1][0].append(char) continue key = style.key() last = style if runs and key == last_key: runs[-1][0].append(char) continue last_key = key runs.append([[char], style]) return [TuiSpan("".join(chars), style) for chars, style in runs] def tui_pad_spans(spans, width, fill_style): return tui_slice_spans(spans, 0, width, fill_style) def tui_span_width(spans): width = 0 for span in spans: width += len(span.text) return width def tui_slice_spans(spans, start, length, fill_style): """Columns [start, start+length) of a span row, padded with fill_style to exactly length. Works on whole spans, never per character; neighbors sharing a style object (or an equal key) merge into one run.""" start = max(0, start) length = max(0, length) end = start + length out = [] last = last_key = None pos = 0 for span in spans: if pos >= end: break text = span.text size = len(text) nxt = pos + size if nxt > start: cut_lo = start - pos if start > pos else 0 cut_hi = end - pos if end < nxt else size piece = text if cut_lo == 0 and cut_hi == size else text[cut_lo:cut_hi] if piece: style = span.style if style is last: out[-1].text += piece else: key = style.key() if out and key == last_key: out[-1].text += piece else: out.append(TuiSpan(piece, style)) last_key = key last = style pos = nxt covered = min(pos, end) - start if pos > start else 0 if covered < length: fill = " " * (length - covered) if fill_style is last: out[-1].text += fill else: key = fill_style.key() if out and key == last_key: out[-1].text += fill else: out.append(TuiSpan(fill, fill_style)) return out TUI_OBSERVE_DEPTH = 4 class TuiObserved: """Marker and shared behavior of the containers a panel keeps its state in. An in-place mutation (append, item assignment, update, ...) touches the owning panel, so a widget never shows stale cells because someone forgot touch(). Copies and pickles are plain containers: the owner link never travels.""" __slots__ = () def _adopt(self, value): """A container inserted into an observed one is observed too, down to TUI_OBSERVE_DEPTH levels.""" if self._depth < TUI_OBSERVE_DEPTH and (type(value) in TUI_PLAIN or isinstance(value, TUI_OBSERVING)): return tui_observe(self._owner, value, self._depth + 1) if isinstance(value, TuiWatched) and self._owner is not None: value.adopt(self._owner) return value def _changed(self): owner = self._owner if owner is not None: owner.touch() def tui_watch(base, names): """Subclass body that runs the base method, then touches the owner.""" body = {} for name in names: def method(self, *args, _call=getattr(base, name), **kwargs): result = _call(self, *args, **kwargs) self._changed() return result method.__name__ = name body[name] = method return body class TuiList(TuiObserved, list): __slots__ = ("_owner", "_depth") def __reduce__(self): return (list, (list(self),)) def append(self, value): list.append(self, self._adopt(value)) self._changed() def insert(self, index, value): list.insert(self, index, self._adopt(value)) self._changed() def extend(self, values): list.extend(self, [self._adopt(value) for value in values]) self._changed() def __iadd__(self, values): self.extend(values) return self def __setitem__(self, index, value): if isinstance(index, slice): value = [self._adopt(item) for item in value] else: value = self._adopt(value) list.__setitem__(self, index, value) self._changed() for _name, _method in tui_watch(list, ("remove", "pop", "clear", "sort", "reverse", "__delitem__", "__imul__")).items(): setattr(TuiList, _name, _method) class TuiDict(TuiObserved, dict): __slots__ = ("_owner", "_depth") def __reduce__(self): return (dict, (dict(self),)) def __setitem__(self, key, value): dict.__setitem__(self, key, self._adopt(value)) self._changed() def setdefault(self, key, default=None): if key not in self: dict.__setitem__(self, key, self._adopt(default)) self._changed() return dict.__getitem__(self, key) def update(self, *args, **kwargs): dict.update(self, {key: self._adopt(value) for key, value in dict(*args, **kwargs).items()}) self._changed() def __ior__(self, other): self.update(other) return self for _name, _method in tui_watch(dict, ("pop", "popitem", "clear", "__delitem__")).items(): setattr(TuiDict, _name, _method) class TuiSet(TuiObserved, set): __slots__ = ("_owner", "_depth") def __reduce__(self): return (set, (set(self),)) for _name, _method in tui_watch(set, ("add", "discard", "remove", "pop", "clear", "update", "difference_update", "intersection_update", "symmetric_difference_update", "__ior__", "__iand__", "__ixor__", "__isub__")).items(): setattr(TuiSet, _name, _method) TUI_PLAIN = {list: TuiList, dict: TuiDict, set: TuiSet} TUI_OBSERVING = (TuiList, TuiDict, TuiSet) def tui_observe(owner, value, depth=0): """An observing copy of a list, dict or set (nested containers included) whose in-place mutations touch owner. The caller's original is left alone: the panel keeps the copy.""" if isinstance(value, TuiList): kind = TuiList elif isinstance(value, TuiDict): kind = TuiDict elif isinstance(value, TuiSet): kind = TuiSet else: kind = TUI_PLAIN[type(value)] nested = depth < TUI_OBSERVE_DEPTH if kind is TuiSet: copy = TuiSet(value) else: copy = kind() if kind is TuiDict: for key, item in dict.items(value): dict.__setitem__(copy, key, tui_item(owner, item, depth, nested)) else: list.extend(copy, [tui_item(owner, item, depth, nested) for item in value]) copy._depth = depth copy._owner = owner return copy def tui_item(owner, item, depth, nested): """What an observing copy keeps for one item: a nested observing copy of a container, the item itself after its owner link is set when it is a TuiWatched, else the item.""" if nested and tui_watchable(item): return tui_observe(owner, item, depth + 1) if isinstance(item, TuiWatched): item.adopt(owner) return item def tui_watchable(value): return type(value) in TUI_PLAIN or isinstance(value, TUI_OBSERVING) class TuiPanel: """Base of every component: box, children, layout, paint, events. Geometry specs accept ints (cells), "50%" (of parent content box), "1fr" (share of leftover flow space), "auto" (measured content), and "center" (x/y only). flow arranges unpositioned children ("rows", "cols", or None for manual placement); dock carves an edge first; positioned children (x/y given) overlay out of flow; z plus the named layer decide paint order through the compositor.""" _seq_counter = [0] # Attribute writes on a mounted panel are the change signal. Geometry and # structure names relayout (and the layout pass diffs rects); the layout # outputs and links are bookkeeping; everything else repaints this panel # only. A scalar written back unchanged is not a change. _RELAYOUT = frozenset(("x", "y", "w", "h", "flow", "dock", "offset", "align", "valign", "padding", "gap", "border")) _RESTACK = frozenset(("layer", "z")) _QUIET = frozenset(("ax", "ay", "aw", "ah", "clip", "parent", "app", "children", "seq")) _SCALARS = (int, float, str, bool, type(None), tuple) # A panel that sets shifts = True promises that changing scroll_y by n # moves its rows n up or down and rewrites little else; the compositor # then tries a terminal scroll before rewriting cells. It is only a hint: # the compositor checks it against the real cells and falls back. shifts = False _shift = 0 def __init__(self, id=None, title="", x=None, y=None, w="auto", h="auto", flow="rows", dock=None, layer=None, z=0, offset=None, align="left", valign="top", padding=0, gap=0, border=False, border_style=None, style=None, title_style=None, rounded=False, focus_border_style=None, visible=True, focusable=False, enabled=True, modal=False, scrollable=False): TuiPanel._seq_counter[0] += 1 self._cells = None self._cells_size = None self.seq = TuiPanel._seq_counter[0] self.id = id self.title = title or "" self.x = x self.y = y self.w = w self.h = h self.flow = flow self.dock = dock self.layer = layer self.z = z self.offset = offset or (0, 0) self.align = align self.valign = valign self.padding = TuiPanel.norm_padding(padding) self.gap = max(0, tui_int(gap)) self.border = bool(border) self.border_style = border_style or TuiStyle(fg="white") self.rounded = bool(rounded) self.focus_border_style = focus_border_style self.style = style or TuiStyle() self.title_style = title_style or TuiStyle(bold=True) self.visible = visible self.focusable = focusable self.enabled = enabled self.modal = modal self.scrollable = scrollable self.scroll_x = 0 self.scroll_y = 0 self.children = [] self.parent = None self.app = None self.ax = 0 self.ay = 0 self.aw = 0 self.ah = 0 self.clip = (0, 0, 0, 0) self._arrange = None self._geom = None self._measure_seen = None self._pending_anims = [] def __setattr__(self, name, value): fields = self.__dict__ old = fields.get(name, self) if name[0] != "_" and name not in TuiPanel._QUIET: kind = type(value) if kind in TUI_PLAIN: value = tui_observe(self, value) elif kind in TuiPanel._SCALARS: pass elif kind in TUI_OBSERVING and value._owner is not self: value = tui_observe(self, value) elif isinstance(value, TuiWatched): value.adopt(self) object.__setattr__(self, name, value) if name[0] == "_" or name in TuiPanel._QUIET: return app = fields.get("app") if app is None: return if name == "scroll_y" and self.shifts and type(old) is int and type(value) is int: self._shift += value - old if type(old) is type(value): if type(value) in TuiPanel._SCALARS: if old == value: return elif type(value) is TuiStyle and old.key() == value.key(): return if name[:3] == "on_": if name == "on_tick": app.tree_changed() elif name in TuiPanel._RELAYOUT: app.relayout(self) self.touch() elif name == "visible": self.forget_measure() app.relayout(self, restack=True) elif name == "scroll_x" or name == "scroll_y": if not self._scroll_in_range(name, value): app.relayout(self, only=True) self.touch() elif name in TuiPanel._RESTACK: app.restack() else: self.touch() def _scroll_in_range(self, name, value): """True when a scroll offset write stays inside the range the last arrange measured, so nothing needs clamping.""" seen = self._measure_seen if seen is None or type(value) is not int: return False if name == "scroll_x": return 0 <= value <= max(0, seen[0] - self._content_w()) return 0 <= value <= max(0, seen[1] - self._content_h()) def touch(self): """Mark this panel's painted cells stale; only its rect repaints.""" self._cells = None self.forget_measure() if self.app is not None: self.app.damage(self) @staticmethod def norm_padding(padding): if isinstance(padding, (tuple, list)): if len(padding) == 2: return (tui_int(padding[0]), tui_int(padding[1]), tui_int(padding[0]), tui_int(padding[1])) if len(padding) == 4: return tuple(tui_int(part) for part in padding) value = tui_int(padding) return (value, value, value, value) def add(self, *children): for child in children: child.parent = self self.children.append(child) if self.app is not None: child._bind_app(self.app) self.forget_measure() if self.app is not None: self.app.relayout(self, inner=True, restack=True) return children[0] if len(children) == 1 else children def remove(self, child): if child in self.children: self.children.remove(child) child.parent = None child._unbind_app() self.forget_measure() if self.app is not None: self.app.relayout(self, inner=True, restack=True) return child def clear(self): for child in list(self.children): self.remove(child) def walk(self): yield self for child in self.children: for node in child.walk(): yield node def find(self, id): for node in self.walk(): if node.id == id: return node return None def root(self): node = self while node.parent is not None: node = node.parent return node def depth(self): depth = 0 node = self while node.parent is not None: node = node.parent depth += 1 return depth def is_shown(self): node = self while node is not None: if not node.visible: return False node = node.parent return True def effective_z(self): return TUI_LAYERS.get(self.layer, 0) + tui_int(self.z) def chrome_w(self): pad_top, pad_right, pad_bottom, pad_left = self.padding return (2 if self.border else 0) + pad_left + pad_right def chrome_h(self): pad_top, pad_right, pad_bottom, pad_left = self.padding return (2 if self.border else 0) + pad_top + pad_bottom def _bind_app(self, app): self.app = app self._cells = None for anim in self._pending_anims: app.animations.append(anim) self._pending_anims = [] self.emit(TuiMount(panel=self)) for child in self.children: child._bind_app(app) def _unbind_app(self): self.emit(TuiUnmount(panel=self)) for child in self.children: child._unbind_app() self.app = None # -- events ------------------------------------------------------ # def dispatch(self, event): node = self first = True while node is not None: if first or (event.bubble and not event.stopped): handler = getattr(node, event.handler_name, None) if callable(handler): handler(event) if not event.bubble or event.stopped: break node = node.parent first = False return event def emit(self, event): event.target = self return self.dispatch(event) # -- geometry ---------------------------------------------------- # @staticmethod def _fr_weight(spec): if isinstance(spec, str) and spec.endswith("fr"): try: return max(0.0, float(spec[:-2] or 1)) except ValueError: return 0.0 return 0.0 @staticmethod def _pct(spec, total): if isinstance(spec, str) and spec.endswith("%"): try: return int(total * float(spec[:-1]) / 100.0) except ValueError: return 0 return None def _fixed_len(self, spec, total, content_len): if isinstance(spec, int): return max(0, spec) if isinstance(spec, str): pct = TuiPanel._pct(spec, total) if pct is not None: return max(0, pct) if spec == "auto": return max(0, content_len) return 0 _measure_memo = None memoize_measure = True _mcache = None _stale = None _arranged = None _owes_arrange = False def mark_pending(self): """Flag this panel and its ancestors as owing an arrange. The flag stays until arrange() reaches the panel, so a change made under a hidden ancestor is still applied when it shows again.""" node = self while node is not None: node._owes_arrange = True node = node.parent def measured(self): """measure() once per layout pass: intrinsic sizes depend on content only, so the repeated asks of one pass (parent, flow, cross axis, scroll clamp) share one answer. Between passes the answer is kept until a touch of this panel or any descendant drops it, so a partial pass does not re-measure subtrees nobody changed. Outside a pass, a plain measure().""" memo = TuiPanel._measure_memo if memo is None or not self.memoize_measure: return self.measure() size = memo.get(self) if size is None: size = self._mcache if TuiPanel._stale is not None else None if size is None: size = self.measure() self._mcache = size memo[self] = size return size def forget_measure(self): """Drop the kept measure of this panel and of every ancestor.""" node = self while node is not None: node._mcache = None node = node.parent def measure(self): """Intrinsic content size (w, h), chrome excluded.""" widths = [] heights = [] flow_w = 0 flow_h = 0 shown = [child for child in self.children if child.visible] for child in shown: cw, ch = child.measured() outer_w = child._fixed_len(child.w, 0, cw) + child.chrome_w() outer_h = child._fixed_len(child.h, 0, ch) + child.chrome_h() if child.dock or child.x is not None or child.y is not None: widths.append(outer_w) heights.append(outer_h) elif self.flow == "cols": flow_w += outer_w heights.append(outer_h) else: flow_h += outer_h widths.append(outer_w) gaps = self.gap * max(0, len([child for child in shown if not child.dock and child.x is None and child.y is None]) - 1) if self.flow == "cols": return (max(widths + [flow_w + gaps]), max(heights + [0])) return (max(widths + [0]), max(heights + [flow_h + gaps])) def content_sized(self): """True when this panel's outer size follows its content, so a change inside it can change what its parent lays out or measures. A panel with a fixed, percent or fr size on both axes is a layout boundary: relayout stops there. (The parent's measure() reads both axes of every child, which is why one auto axis is enough.)""" return self.parent is not None and (self.w == "auto" or self.h == "auto") def arrange(self, x, y, w, h, clip): if TuiPanel._stale is not None: if not self._owes_arrange and self._arrange == (x, y, w, h, clip): return TuiPanel._arranged.append(self) self._owes_arrange = False self._arrange = (x, y, w, h, clip) ox, oy = self.offset self.ax = x + tui_int(ox) self.ay = y + tui_int(oy) self.aw = max(0, tui_int(w)) self.ah = max(0, tui_int(h)) self.clip = TuiPanel.intersect(clip, (self.ax, self.ay, self.ax + self.aw, self.ay + self.ah)) pad_top, pad_right, pad_bottom, pad_left = self.padding edge = 1 if self.border else 0 box = [self.ax + edge + pad_left, self.ay + edge + pad_top, max(0, self.aw - edge * 2 - pad_left - pad_right), max(0, self.ah - edge * 2 - pad_top - pad_bottom)] shown = [child for child in self.children if child.visible] docked = [child for child in shown if child.dock] floating = [child for child in shown if not child.dock and (child.x is not None or child.y is not None)] flowing = [child for child in shown if not child.dock and child.x is None and child.y is None] for child in docked: self._place_dock(child, box) if self.flow is None: for child in flowing: cw, ch = child.measured() child_w = child._fixed_len(child.w, box[2], cw) + child.chrome_w() child_h = child._fixed_len(child.h, box[3], ch) + child.chrome_h() child.arrange(box[0], box[1], child_w, child_h, self._content_clip()) elif self.flow == "cols": self._place_flow(flowing, box, horizontal=True) else: self._place_flow(flowing, box, horizontal=False) for child in floating: self._place_float(child, box) self._clamp_scroll() @staticmethod def intersect(first, second): return (max(first[0], second[0]), max(first[1], second[1]), min(first[2], second[2]), min(first[3], second[3])) def _content_clip(self): pad_top, pad_right, pad_bottom, pad_left = self.padding edge = 1 if self.border else 0 inner = (self.ax + edge + pad_left, self.ay + edge + pad_top, self.ax + self.aw - edge - pad_right, self.ay + self.ah - edge - pad_bottom) return TuiPanel.intersect(self.clip, inner) def _place_dock(self, child, box): cw, ch = child.measured() side = (child.dock or "").lower() if side in ("top", "bottom"): child_h = child._fixed_len(child.h, box[3], ch) + child.chrome_h() child_h = min(child_h, box[3]) at_y = box[1] if side == "top" else box[1] + box[3] - child_h child.arrange(box[0], at_y, box[2], child_h, self._content_clip()) if side == "top": box[1] += child_h box[3] -= child_h else: child_w = child._fixed_len(child.w, box[2], cw) + child.chrome_w() child_w = min(child_w, box[2]) at_x = box[0] if side != "right" else box[0] + box[2] - child_w child.arrange(at_x, box[1], child_w, box[3], self._content_clip()) if side != "right": box[0] += child_w box[2] -= child_w def _place_flow(self, flowing, box, horizontal): total = box[2] if horizontal else box[3] measures = [child.measured() for child in flowing] sizes = [] fr_total = 0.0 fixed = 0 for child, (cw, ch) in zip(flowing, measures): spec = child.w if horizontal else child.h weight = TuiPanel._fr_weight(spec) if weight > 0: sizes.append(None) fr_total += weight else: content = cw if horizontal else ch size = child._fixed_len(spec, total, content) + (child.chrome_w() if horizontal else child.chrome_h()) sizes.append(size) fixed += size gaps = self.gap * max(0, len(flowing) - 1) leftover = max(0, total - fixed - gaps) if fr_total > 0: shares = [] for child, size in zip(flowing, sizes): if size is None: exact = leftover * TuiPanel._fr_weight(child.w if horizontal else child.h) / fr_total shares.append([int(exact), exact - int(exact)]) given = sum(share[0] for share in shares) order = sorted(range(len(shares)), key=lambda pos: shares[pos][1], reverse=True) for pos in order[:leftover - given]: shares[pos][0] += 1 it = iter(share[0] for share in shares) sizes = [size if size is not None else next(it) for size in sizes] else: sizes = [size or 0 for size in sizes] used = sum(sizes) + gaps cursor = box[0] if horizontal else box[1] main_align = self.align if horizontal else self.valign if main_align in ("center", "middle"): cursor += max(0, (total - used) // 2) elif main_align in ("right", "bottom"): cursor += max(0, total - used) for child, size in zip(flowing, sizes): if horizontal: child_w, child_h = size, self._cross_size(child, box, horizontal) at_x, at_y = cursor, self._cross_pos(child_h, box, horizontal) cursor += size + self.gap else: child_h, child_w = size, self._cross_size(child, box, horizontal) at_y, at_x = cursor, self._cross_pos(child_w, box, horizontal) cursor += size + self.gap child.arrange(at_x, at_y, child_w, child_h, self._content_clip()) def _cross_size(self, child, box, horizontal): cw, ch = child.measured() if horizontal: spec = child.h total, content = box[3], ch else: spec = child.w total, content = box[2], cw if TuiPanel._fr_weight(spec) > 0: return total if spec == "auto" and not horizontal: return total if isinstance(spec, int) or (isinstance(spec, str) and (spec.endswith("%") or spec == "auto")): return child._fixed_len(spec, total, content) + (child.chrome_h() if horizontal else child.chrome_w()) return total def _cross_pos(self, child_size, box, horizontal): if horizontal: cross_align = self.valign start, total = box[1], box[3] else: cross_align = self.align start, total = box[0], box[2] if cross_align in ("center", "middle"): return start + max(0, (total - child_size) // 2) if cross_align in ("right", "bottom"): return start + max(0, total - child_size) return start def _place_float(self, child, box): cw, ch = child.measured() child_w = child._fixed_len(child.w, box[2], cw) + child.chrome_w() child_h = child._fixed_len(child.h, box[3], ch) + child.chrome_h() at_x = box[0] + self._place_pos(child.x, box[2], child_w) at_y = box[1] + self._place_pos(child.y, box[3], child_h) child.arrange(at_x, at_y, child_w, child_h, self._content_clip()) @staticmethod def _place_pos(spec, total, size): if spec is None: return 0 if isinstance(spec, int): return spec if spec >= 0 else total - size + spec if isinstance(spec, str): if spec == "center": return max(0, (total - size) // 2) pct = TuiPanel._pct(spec, total) if pct is not None: return pct return 0 def _clamp_scroll(self): cw, ch = self.measured() self._measure_seen = (cw, ch) self.scroll_x = max(0, min(self.scroll_x, max(0, cw - self._content_w()))) self.scroll_y = max(0, min(self.scroll_y, max(0, ch - self._content_h()))) def content_rect(self): """Screen rect (x0, y0, x1, y1) of the content area, clipped.""" edge = 1 if self.border else 0 pad_top, pad_right, pad_bottom, pad_left = self.padding x0, y0, x1, y1 = self.clip return (max(x0, self.ax + edge + pad_left), max(y0, self.ay + edge + pad_top), min(x1, self.ax + self.aw - edge - pad_right), min(y1, self.ay + self.ah - edge - pad_bottom)) def _content_w(self): return max(0, self.aw - self.chrome_w()) def _content_h(self): return max(0, self.ah - self.chrome_h()) def scroll_by(self, dx, dy): self.scroll_x += dx self.scroll_y += dy self._clamp_scroll() if self.app is not None: self.touch() def on_scroll(self, event): if self.scrollable and self.enabled: event.stop() if event.direction == "up": self.scroll_by(0, -3) elif event.direction == "down": self.scroll_by(0, 3) elif event.direction == "left": self.scroll_by(-3, 0) elif event.direction == "right": self.scroll_by(3, 0) # -- paint ------------------------------------------------------- # def content_rows(self): """Content-area rows as span lists; subclasses override.""" width = self._content_w() fill = [TuiSpan(" " * width, self.style)] if width else [] return [list(fill) for _row in range(self._content_h())] def paint(self): width = self._content_w() height = self._content_h() rows = [] content = self.content_rows() scroll_x = self.scroll_x scroll_y = self.scroll_y for row_pos in range(height): source = content[scroll_y + row_pos] if scroll_y + row_pos < len(content) else [] if scroll_x == 0 and source and tui_span_width(source) == width: rows.append(source) else: rows.append(tui_slice_spans(source, scroll_x, width, self.style)) pad_top, pad_right, pad_bottom, pad_left = self.padding edge = 1 if self.border else 0 if pad_left or pad_right: left = [TuiSpan(" " * pad_left, self.style)] if pad_left else [] right = [TuiSpan(" " * pad_right, self.style)] if pad_right else [] rows = [left + spans + right for spans in rows] inner_w = width + pad_left + pad_right blank = [TuiSpan(" " * inner_w, self.style)] if inner_w else [] rows = [list(blank) for _row in range(pad_top)] + rows + [list(blank) for _row in range(pad_bottom)] if edge: rows = self._add_border(rows, inner_w) while len(rows) < self.ah: rows.append([TuiSpan(" " * self.aw, self.style)] if self.aw else []) rows = rows[:self.ah] if self.aw < self.chrome_w() + 2: rows = [tui_pad_spans(spans, self.aw, self.style) for spans in rows] return rows def paint_cells(self, compositor): """The panel's cell rows, exactly aw wide and ah tall. The default converts paint(); a widget whose rows are stable between paints overrides it to reuse them (TuiLog).""" return compositor.cells_from(self.paint(), self.aw) def _add_border(self, rows, inner_w): tl, tr, bl, br = "╭╮╰╯" if self.rounded else "┌┐└┘" line = self.border_style if self.focus_border_style is not None and self.app is not None and self.app.focused is self: line = self.focus_border_style top = [TuiSpan(tl + "─", line)] title = " %s " % self.title.strip() if self.title.strip() else "" room = max(0, inner_w - 2) if len(title) > room: title = title[:max(0, room - 1)] + ("…" if room else "") top.append(TuiSpan(title, self.title_style)) top.append(TuiSpan("─" * max(0, room - len(title) + 1) + tr, line)) bottom = [TuiSpan(bl + "─" * inner_w + br, line)] framed = [top] for spans in rows: framed.append([TuiSpan("│", line)] + list(spans) + [TuiSpan("│", line)]) framed.append(bottom) return framed # -- animation --------------------------------------------------- # def anim_start(self, prop): """The number an animation of `prop` starts from. x, y, w and h may be specs (a percent, "center", "auto", a negative offset), so they start from where the panel was last placed, which is what the eye sees.""" value = getattr(self, prop, 0) if prop not in TUI_GEOMETRY: return float(value or 0) if not hasattr(self, "ax"): return float(value) if isinstance(value, (int, float)) else 0.0 if prop == "w": return float(self.aw - self.chrome_w()) if prop == "h": return float(self.ah - self.chrome_h()) parent = self.parent if parent is None: return float(value) if isinstance(value, (int, float)) else 0.0 edge = 1 if parent.border else 0 pad = parent.padding ox, oy = self.offset if prop == "x": return float(self.ax - tui_int(ox) - (parent.ax + edge + pad[3])) return float(self.ay - tui_int(oy) - (parent.ay + edge + pad[0])) def animate(self, prop, to, duration=None, speed=None, easing="in_out_cubic", delay=0.0, on_done=None): if duration is None: distance = abs(float(to) - self.anim_start(prop)) duration = distance / float(speed) if speed else 0.4 anim = {"panel": self, "prop": prop, "to": float(to), "duration": max(0.001, float(duration)), "easing": easing, "delay": max(0.0, float(delay)), "on_done": on_done, "started": None, "from": None} if self.app is not None: self.app.animations.append(anim) self.app.wake() else: self._pending_anims.append(anim) return anim def focus(self): if self.app is not None: self.app.set_focus(self) def blur(self): if self.app is not None and self.app.focused is self: self.app.set_focus(None) def close(self): if self.app is not None: self.app.close_panel(self) class TuiRows(TuiPanel): """Vertical flow container.""" def __init__(self, *children, **kwargs): kwargs.setdefault("flow", "rows") super().__init__(**kwargs) if children: self.add(*children) class TuiCols(TuiPanel): """Horizontal flow container.""" def __init__(self, *children, **kwargs): kwargs.setdefault("flow", "cols") super().__init__(**kwargs) if children: self.add(*children) TUI_SYNC_START = "\x1b[?2026h" TUI_SYNC_END = "\x1b[?2026l" TUI_MOUSE_ENABLE = "\x1b[?1000h\x1b[?1006h" TUI_MOUSE_DISABLE = "\x1b[?1006l\x1b[?1000l" TUI_ALT_ENTER = "\x1b[?1049h" TUI_ALT_LEAVE = "\x1b[?1049l" TUI_KITTY_ENABLE = "\x1b[>10u" TUI_KITTY_DISABLE = "\x1b[<u" TUI_PASTE_ENABLE = "\x1b[?2004h" TUI_PASTE_DISABLE = "\x1b[?2004l" TUI_PASTE_OPEN = b"\x1b[200~" TUI_PASTE_CLOSE = b"\x1b[201~" TUI_PASTE_CAP = 1 << 20 TUI_COMPLETE_MAXH = 8 TUI_BLANK_KEY = (None, None, False, False, False, False, False) TUI_BLANK = (" ", TUI_BLANK_KEY) TUI_POOL_KEYS = 512 TUI_POOL_CHARS = 4096 TUI_SGR_CAP = 4096 TUI_DAMAGE_RECTS = 64 TUI_ROW_MEMO = 4096 TUI_SCROLLS = 4 class TuiCellPool(dict): """Interned cells of one style key: char -> (char, key). Equal cells are the same object, so frame diffs short-circuit on identity.""" def __init__(self, key): super().__init__() self.key = key def __missing__(self, char): if len(self) >= TUI_POOL_CHARS: self.clear() cell = self[char] = (char, self.key) return cell class TuiCompositor: """Lazy compositor. Panels cache their painted cells (touch() drops one), a frame is built front to back with per-row coverage so every cell is written once and a fully covered panel is never painted, and the screen it believes is shown persists, so an update recomposes and diffs only the damaged rects. compose_reference is the original full algorithm, kept as the oracle the lazy path is verified against. Output is one single-write frame in synchronized-output markers: atomic where the terminal honors it, ignored everywhere else.""" def __init__(self): self.last = None self.pool = {} self.sgr_memo = {} self.sgr_theme = None self.stats = {"painted": 0, "composed": 0, "emitted": 0, "frames": 0, "scrolled": 0} self.margins = False def invalidate(self): self.last = None self.sgr_memo = {} def flatten(self, root): panels = [node for node in root.walk() if node.is_shown() and node.aw > 0 and node.ah > 0] panels.sort(key=TuiCompositor.stack_key) return panels @staticmethod def stack_key(node): parts = [] while node is not None: parts.append((node.effective_z(), node.seq)) node = node.parent return tuple(reversed(parts)) # -- cells ------------------------------------------------------- # def sgr(self, key): theme = tui_theme() if theme is not self.sgr_theme or len(self.sgr_memo) >= TUI_SGR_CAP: self.sgr_memo = {} self.sgr_theme = theme code = self.sgr_memo.get(key) if code is None: code = self.sgr_memo[key] = TuiStyle.sgr_from_key(key) return code def cells_from(self, painted, width): """Span rows -> cell rows of exactly width interned cells.""" pool = self.pool if len(pool) >= TUI_POOL_KEYS: pool.clear() keys = {} out = [] blank = [TUI_BLANK] * width for spans in painted: row = [] for span in spans: style = span.style key = keys.get(id(style)) if key is None: key = keys[id(style)] = style.key() chars = pool.get(key) if chars is None: chars = pool[key] = TuiCellPool(key) row.extend(map(chars.__getitem__, span.text)) if len(row) < width: row.extend(blank[:width - len(row)]) out.append(row) return out def cell(self, char, key): """The interned cell of one char in one style key.""" pool = self.pool chars = pool.get(key) if chars is None: if len(pool) >= TUI_POOL_KEYS: pool.clear() chars = pool[key] = TuiCellPool(key) return chars[char] def cells_of(self, panel, cached): """The panel's cell rows, painting only when stale. None when the widget's paint raised: it then covers nothing this frame and never garbles the rest.""" size = (panel.aw, panel.ah) if cached and panel._cells is not None and panel._cells_size == size: return panel._cells try: cells = panel.paint_cells(self) except Exception: return None self.stats["painted"] += 1 if cached: panel._cells = cells panel._cells_size = size return cells # -- compose ----------------------------------------------------- # @staticmethod def gaps(covered, lo, hi): """Sub-intervals of [lo, hi) not yet claimed in this row.""" found = [] pos = lo for start, end in covered: if end <= pos: continue if start >= hi: break if start > pos: found.append((pos, start)) pos = end if pos >= hi: return found if pos < hi: found.append((pos, hi)) return found @staticmethod def claim(covered, lo, hi): covered.append((lo, hi)) covered.sort() merged = [covered[0]] for start, end in covered[1:]: if start <= merged[-1][1]: if end > merged[-1][1]: merged[-1] = (merged[-1][0], end) else: merged.append((start, end)) covered[:] = merged def fill(self, stack, x0, y0, x1, y1, cached): """Cell rows for the rect [x0,x1) x [y0,y1), topmost panel first. A panel only paints when some of its rows are still uncovered.""" width = x1 - x0 rows = [[TUI_BLANK] * width for _y in range(y0, y1)] covers = [[] for _y in range(y0, y1)] left = width * (y1 - y0) for panel in reversed(stack): if left <= 0: break cx0, cy0, cx1, cy1 = panel.clip cx0, cy0, cx1, cy1 = max(cx0, x0), max(cy0, y0), min(cx1, x1), min(cy1, y1) if cx0 >= cx1 or cy0 >= cy1: continue todo = [] for y in range(cy0, cy1): free = TuiCompositor.gaps(covers[y - y0], cx0, cx1) if free: todo.append((y, free)) if not todo: continue cells = self.cells_of(panel, cached) if cells is None: continue origin = panel.ax for y, free in todo: local = y - panel.ay if local < 0 or local >= len(cells): continue source = cells[local] target = rows[y - y0] for start, end in free: target[start - x0:end - x0] = source[start - origin:end - origin] left -= end - start TuiCompositor.claim(covers[y - y0], cx0, cx1) self.stats["composed"] += width * (y1 - y0) return rows def compose(self, root, rows, cols): """One whole frame from a fresh paint of the tree (no caches).""" return self.fill(self.flatten(root), 0, 0, cols, rows, False) def compose_reference(self, root, rows, cols): """The original whole-tree algorithm: paint everything bottom to top, cut every row at run edges, keep the topmost run per interval. Slow on purpose and never used for output: it is the oracle the lazy pipeline is verified against (tests, TAI_TUI_VERIFY).""" panels = self.flatten(root) runs_by_row = {} for panel in panels: try: painted = panel.paint() except Exception: continue # one broken widget garbles its own rect, never the frame x0, y0, x1, y1 = panel.clip x0 = max(0, x0) y0 = max(0, y0) x1 = min(cols, x1) y1 = min(rows, y1) if x0 >= x1 or y0 >= y1: continue for local, spans in enumerate(painted): ay = panel.ay + local if ay < y0 or ay >= y1: continue runs_by_row.setdefault(ay, []).append((max(panel.ax, x0), min(panel.ax + panel.aw, x1), panel.ax, spans)) frame = [[(" ", (None, None, False, False, False, False, False)) for _x in range(cols)] for _y in range(rows)] styles = {} keys = {} for ay, runs in runs_by_row.items(): cuts = set() for x0, x1, _origin, _spans in runs: cuts.add(x0) cuts.add(x1) edges = sorted(cuts) for pos in range(len(edges) - 1): start, end = edges[pos], edges[pos + 1] if start >= end: continue winner = None for run in runs: if run[0] <= start and run[1] >= end: winner = run if winner is None: continue _x0, _x1, origin, spans = winner out_row = frame[ay] col = origin for span in spans: text = span.text nxt = col + len(text) lo = max(col, start) hi = min(nxt, end) if lo < hi: style = span.style key = keys.get(id(style)) if key is None: key = keys[id(style)] = style.key() styles[key] = style out_row[lo:hi] = [(char, key) for char in text[lo - col:hi - col]] col = nxt if col >= end: break return frame # -- output ------------------------------------------------------ # def diff_row(self, out, y, x0, new, old, current): """Append the moves and glyphs that turn old into new for one row segment starting at column x0. Returns the style now selected.""" count = len(new) col = 0 while col < count: if new[col] == old[col]: col += 1 continue start = col while col < count and new[col] != old[col]: col += 1 out.append("\x1b[%d;%dH" % (y + 1, x0 + start + 1)) for char, key in new[start:col]: if key != current: out.append(self.sgr(key)) current = key out.append(char) self.stats["emitted"] += col - start return current def refresh(self, stack, rows, cols, rects, shifts=()): """Bring the believed-shown screen up to date. rects None means the whole screen (first frame, resize, invalidate), else the damaged rects (x0, y0, x1, y1); shifts are scroll hints (x0, y0, x1, y1, dy) for panels whose scroll_y moved. Returns only the bytes that changed.""" self.stats["frames"] += 1 out = [] current = None if self.last is None or len(self.last) != rows or len(self.last[0]) != cols: self.last = [[(None, None)] * cols for _y in range(rows)] rects = None if rects is None: rects = [(0, 0, cols, rows)] shifts = () fresh = [(rect, self.fill(stack, rect[0], rect[1], rect[2], rect[3], True)) for rect in TuiCompositor.merge_rects(rects, cols, rows)] if shifts: self.scroll(stack, shifts, fresh, cols, rows, out) current = None for (x0, y0, x1, y1), block in fresh: for offset, row in enumerate(block): shown = self.last[y0 + offset] if row == shown[x0:x1]: continue current = self.diff_row(out, y0 + offset, x0, row, shown[x0:x1], current) shown[x0:x1] = row return "".join(out) @staticmethod def cost(pairs): """Bytes-ish cost of turning each old row segment into its new one, counted the way diff_row writes it: the changed cells, a cursor move per run of changed cells, and a style switch per change of style along the changed cells (all in C-level passes over the row).""" total = 0 for new, old in pairs: if new == old: continue mask = list(map(operator.ne, new, old)) changed = sum(mask) runs = sum(map(operator.gt, mask, [False] + mask)) keys = list(map(TUI_CELL_KEY, itertools.compress(new, mask))) switches = sum(map(operator.ne, keys, keys[1:])) + 1 total += changed + TUI_MOVE_COST * runs + TUI_SWITCH_COST * switches return total def scroll(self, stack, shifts, fresh, cols, rows, out): """Turn scroll hints into terminal scrolls where that writes less than rewriting cells. A hint (x0, y0, x1, y1, dy) says the rows of that rect moved up by dy (down when negative). It is checked against the cells that are really there: new rows equal to the old rows moved by dy, and the cost of every cell the scroll would leave wrong (the strips left and right of the rect move with it) is counted exactly. Taken, it emits a scroll of the whole rows y0..y1 (margins, then index or reverse index per row), moves the believed screen the same way and queues the strips for the normal diff, which repairs them. That diff is what keeps this correct: it compares the new content with the believed screen wherever anything may differ.""" for x0, y0, x1, y1, dy in shifts[:TUI_SCROLLS]: x0, y0, x1, y1 = max(0, x0), max(0, y0), min(cols, x1), min(rows, y1) height = y1 - y0 size = abs(dy) if x0 >= x1 or height < 3 or size >= height: continue owner = None for (rx0, ry0, rx1, ry1), block in fresh: if rx0 <= x0 and ry0 <= y0 and rx1 >= x1 and ry1 >= y1: owner = (rx0, ry0, block) break if owner is None: continue rx0, ry0, block = owner last = self.last new_rows = [row[x0 - rx0:x1 - rx0] for row in block[y0 - ry0:y1 - ry0]] if dy > 0: moved = sum(1 for pos in range(height - size) if new_rows[pos] == last[y0 + pos + size][x0:x1]) else: moved = sum(1 for pos in range(size, height) if new_rows[pos] == last[y0 + pos - size][x0:x1]) if moved < 2: continue bounded = self.margins and (x0 > 0 or x1 < cols) strips = [] if bounded else [(0, y0, x0, y1)] if x0 > 0 else [] if x1 < cols and not bounded: strips.append((x1, y0, cols, y1)) strip_blocks = [(rect, self.fill(stack, rect[0], rect[1], rect[2], rect[3], True)) for rect in strips] blank = [TUI_BLANK] * cols def moved_row(y): """What the terminal would show in row y after the scroll.""" src = y + size if dy > 0 else y - size if y0 <= src < y1: return last[src] if not bounded else last[y][:x0] + last[src][x0:x1] + last[y][x1:] return blank if not bounded else last[y][:x0] + blank[x0:x1] + last[y][x1:] now = TuiCompositor.cost((new, last[y0 + pos][x0:x1]) for pos, new in enumerate(new_rows)) then = TuiCompositor.cost((new, moved_row(y0 + pos)[x0:x1]) for pos, new in enumerate(new_rows)) for (sx0, _sy0, sx1, _sy1), sblock in strip_blocks: then += TuiCompositor.cost((new, moved_row(y0 + pos)[sx0:sx1]) for pos, new in enumerate(sblock)) if then + TUI_SCROLL_SLACK + 2 * size >= now: continue opening = "\x1b[0m\x1b[%d;%dr" % (y0 + 1, y1) closing = "\x1b[r" column = 1 if bounded: opening += "\x1b[?%dh\x1b[%d;%ds" % (TUI_DECLRMM, x0 + 1, x1) closing = "\x1b[?%dl" % TUI_DECLRMM + closing column = x0 + 1 if dy > 0: out.append("%s\x1b[%d;%dH%s%s" % (opening, y1, column, "\x1bD" * size, closing)) else: out.append("%s\x1b[%d;%dH%s%s" % (opening, y0 + 1, column, "\x1bM" * size, closing)) if bounded: last[y0:y1] = [moved_row(y) for y in range(y0, y1)] elif dy > 0: last[y0:y1] = last[y0 + size:y1] + [[TUI_BLANK] * cols for _row in range(size)] else: last[y0:y1] = [[TUI_BLANK] * cols for _row in range(size)] + last[y0:y1 - size] self.stats["scrolled"] += height fresh.extend(strip_blocks) @staticmethod def merge_rects(rects, cols, rows): """Clamp to the screen and drop rects contained in another.""" clamped = [] for x0, y0, x1, y1 in rects: x0, y0, x1, y1 = max(0, x0), max(0, y0), min(cols, x1), min(rows, y1) if x0 < x1 and y0 < y1: clamped.append((x0, y0, x1, y1)) clamped.sort(key=lambda r: (r[2] - r[0]) * (r[3] - r[1]), reverse=True) kept = [] for rect in clamped: if not any(big[0] <= rect[0] and big[1] <= rect[1] and big[2] >= rect[2] and big[3] >= rect[3] for big in kept): kept.append(rect) return kept def render(self, frame): """Diff a whole frame against what is shown and wrap it for the terminal.""" out = [TUI_SYNC_START, "\x1b[?25l"] if self.last is None: self.last = [[(None, None) for _cell in row] for row in frame] current = None for row_pos, (row, old_row) in enumerate(zip(frame, self.last)): current = self.diff_row(out, row_pos, 0, row, old_row, current) out.append("\x1b[0m") self.last = [[cell for cell in row] for row in frame] return "".join(out) @staticmethod def render_text(frame): return "\n".join("".join(char for char, _key in row).rstrip() for row in frame) @staticmethod def place_cursor(x, y, visible): if visible: return "\x1b[%d;%dH\x1b[?25h" % (y + 1, x + 1) return "\x1b[?25l" class TuiHitMap: """Cell-grid index over painted panels so hit-testing stays cheap no matter how many layers stack up. Lazy: a layout only hands over its stack; the grid is built by the first click or wheel event after it, so frames with no mouse input never pay for it.""" CELL_W = 8 CELL_H = 4 def __init__(self): self.grid = {} self.pending = None def rebuild(self, panels): self.pending = list(panels) def build(self): grid = {} for panel in self.pending: x0, y0, x1, y1 = panel.clip if x0 >= x1 or y0 >= y1: continue for gy in range(y0 // self.CELL_H, (y1 - 1) // self.CELL_H + 1): for gx in range(x0 // self.CELL_W, (x1 - 1) // self.CELL_W + 1): grid.setdefault((gx, gy), []).append(panel) self.grid = grid self.pending = None def at(self, x, y): if self.pending is not None: self.build() for panel in reversed(self.grid.get((x // self.CELL_W, y // self.CELL_H), [])): if not panel.is_shown(): continue x0, y0, x1, y1 = panel.clip if x0 <= x < x1 and y0 <= y < y1: return panel return None def tui_utf8_head(buf): """Decode one char from head of buf. Returns (char, length, need_more).""" first = buf[0] if first < 0x80: return (chr(first), 1, False) if 0xC2 <= first <= 0xDF: need = 2 elif 0xE0 <= first <= 0xEF: need = 3 elif 0xF0 <= first <= 0xF4: need = 4 else: return ("�", 1, False) if len(buf) < need: return (None, 0, True) try: return (bytes(buf[:need]).decode("utf-8"), need, False) except UnicodeDecodeError: return ("�", 1, False) TUI_CSI_KEYS = {"A": "up", "B": "down", "C": "right", "D": "left", "H": "home", "F": "end", "Z": "backtab"} TUI_TILDE_KEYS = {"1": "home", "2": "insert", "3": "delete", "4": "end", "5": "pageup", "6": "pagedown", "7": "home", "8": "end", "11": "f1", "12": "f2", "13": "f3", "14": "f4", "15": "f5", "17": "f6", "18": "f7", "19": "f8", "20": "f9", "21": "f10", "23": "f11", "24": "f12"} TUI_SS3_KEYS = {"P": "f1", "Q": "f2", "R": "f3", "S": "f4", "A": "up", "B": "down", "C": "right", "D": "left", "H": "home", "F": "end"} TUI_KITTY_NAMED = {27: "escape", 13: "enter", 9: "tab", 127: "backspace", 57414: "enter", 57417: "left", 57418: "right", 57419: "up", 57420: "down", 57421: "pageup", 57422: "pagedown", 57423: "home", 57424: "end", 57425: "insert", 57426: "delete"} TUI_KITTY_MODS = {57441: "shift", 57447: "shift", 57442: "ctrl", 57448: "ctrl", 57443: "alt", 57449: "alt", 57444: "super", 57450: "super"} TUI_KITTY_PAD = {57400: "0", 57401: "1", 57402: "2", 57403: "3", 57404: "4", 57405: "5", 57406: "6", 57407: "7", 57408: "8", 57409: ".", 57410: "/", 57411: "*", 57412: "-", 57413: "+", 57415: "="} TUI_KITTY_US_SHIFT = {"1": "!", "2": "@", "3": "#", "4": "$", "5": "%", "6": "^", "7": "&", "8": "*", "9": "(", "0": ")", "-": "_", "=": "+", "[": "{", "]": "}", "\\": "|", ";": ":", "'": '"', ",": "<", ".": ">", "/": "?", "`": "~"} def tui_modifiers(code): mods = [] bits = code - 1 if bits & 1: mods.append("shift") if bits & 2: mods.append("alt") if bits & 4: mods.append("ctrl") if bits & 8: mods.append("meta") return mods def tui_parse_mouse_sgr(text): """Parse "<b;x;yM/m" body. Returns (event, ok).""" pressed = text.endswith("M") body = text[1:-1] if text[:1] == "<" and text[-1:] in ("M", "m") else None if body is None: return (None, False) parts = body.split(";") if len(parts) != 3: return (None, False) try: code, x, y = (int(part) for part in parts) except ValueError: return (None, False) mods = [name for name, bit in (("shift", 4), ("alt", 8), ("ctrl", 16)) if code & bit] button = code & ~28 if button in (64, 65, 66, 67): direction = {64: "up", 65: "down", 66: "left", 67: "right"}[button] return (TuiScroll(x=x - 1, y=y - 1, direction=direction, modifiers=mods), True) if button == 3: return (TuiClick(x=x - 1, y=y - 1, button=0, modifiers=mods, pressed=False), True) if button in (0, 1, 2) and pressed: return (TuiClick(x=x - 1, y=y - 1, button=button, modifiers=mods, pressed=True), True) if button in (0, 1, 2): return (TuiClick(x=x - 1, y=y - 1, button=button, modifiers=mods, pressed=False), True) return (None, True) def tui_parse_mouse_legacy(data): """Parse 3 bytes after ESC[M. Returns (event, ok).""" code, x, y = data[0] - 32, data[1] - 32, data[2] - 32 mods = [name for name, bit in (("shift", 4), ("alt", 8), ("ctrl", 16)) if code & bit] button = code & ~28 if button in (64, 65): return (TuiScroll(x=x - 1, y=y - 1, direction="up" if button == 64 else "down", modifiers=mods), True) if button == 3: return (TuiClick(x=x - 1, y=y - 1, button=0, modifiers=mods, pressed=False), True) if button in (0, 1, 2): return (TuiClick(x=x - 1, y=y - 1, button=button, modifiers=mods, pressed=True), True) return (None, True) def tui_parse_kitty(body, rest): """Decode one kitty CSI-u key body. Returns (event|None, rest). Stack forms, unknown numbers, and release events are consumed silently.""" if not body or body[0] in "?>!<=" or not all(char.isdigit() or char in ":;" for char in body): return (None, rest) parts = body.split(";") try: number = int(parts[0].split(":")[0] or 0) except ValueError: return (None, rest) mods, etype = 1, 1 if len(parts) > 1 and parts[1]: fields = parts[1].split(":") mods = tui_int(fields[0], 1) if fields[0] else 1 if len(fields) > 1 and fields[1]: etype = tui_int(fields[1], 1) if etype == 3: return (None, rest) text = "" for extra in parts[2:]: for piece in extra.split(":"): if piece.isdigit(): point = int(piece) if point <= 0x10FFFF: text += chr(point) if number in TUI_KITTY_MODS: if etype == 2: return (None, rest) name = TUI_KITTY_MODS[number] return (TuiKey(key=name, char="", modifiers=[name]), rest) if number in TUI_KITTY_NAMED: if etype == 2: return (None, rest) name = TUI_KITTY_NAMED[number] names = tui_modifiers(((mods - 1) & 0b111) + 1) key = "+".join(names + [name]) if names else name return (TuiKey(key=key, char="", modifiers=names), rest) if number in TUI_KITTY_PAD: char = TUI_KITTY_PAD[number] return (TuiKey(key=char, char=char), rest) if 1 <= number <= 26: letter = chr(number + 96) return (TuiKey(key="ctrl+" + letter, char=chr(number), modifiers=["ctrl"]), rest) if number == 0: if not text: return (None, rest) elif 57344 <= number <= 63743 or not 32 <= number <= 0x10FFFF or 0xD800 <= number <= 0xDFFF: return (None, rest) if text: key = "space" if text == " " else text return (TuiKey(key=key, char=text), rest) char = chr(number) names = tui_modifiers(((mods - 1) & 0b111) + 1) if names == ["shift"] and "a" <= char <= "z": upper = chr(ord(char) - 32) return (TuiKey(key=upper, char=upper), rest) if names == ["shift"] and char in TUI_KITTY_US_SHIFT: shifted = TUI_KITTY_US_SHIFT[char] return (TuiKey(key=shifted, char=shifted), rest) if names == ["ctrl"] and "a" <= char <= "z": return (TuiKey(key="ctrl+" + char, char=chr(ord(char) - 96), modifiers=names), rest) if names: return (TuiKey(key="+".join(names + [char]), char=char, modifiers=names), rest) key = "space" if char == " " else char return (TuiKey(key=key, char=char), rest) def tui_paste_head(buf): """True when buf starts with, or could still become, the bracketed paste opener. Used to keep slow pastes buffered instead of forcing a lone-ESC resolution through them.""" head = bytes(buf[:len(TUI_PASTE_OPEN)]) return head == TUI_PASTE_OPEN[:len(head)] def tui_parse_paste(buf, rest): """Parse a bracketed paste body. Bytes between the markers arrive literally: no escape parsing inside. An over-cap paste is dropped whole, never truncated into the session.""" rest = bytes(rest) close = rest.find(TUI_PASTE_CLOSE) if close < 0: if len(buf) > TUI_PASTE_CAP: return (None, b"") return ("need_more", buf) if close > TUI_PASTE_CAP: return (None, rest[close + len(TUI_PASTE_CLOSE):]) text = rest[:close].decode("utf-8", "replace") return (TuiPaste(text=text), rest[close + len(TUI_PASTE_CLOSE):]) def tui_parse_one(buf, final): """Parse one event from head of buf. Returns (event|"need_more"|None, rest). None means empty buffer; "need_more" means wait for bytes (or flush).""" if not buf: return (None, buf) if buf[0] != 0x1B: if buf[0] in (0x0D, 0x0A): if buf[0] == 0x0D and len(buf) > 1 and buf[1] == 0x0A: return (TuiKey(key="enter", char="\n"), buf[2:]) if buf[0] == 0x0D and len(buf) == 1 and not final: return ("need_more", buf) return (TuiKey(key="enter", char="\n"), buf[1:]) if buf[0] == 0x09: return (TuiKey(key="tab", char="\t"), buf[1:]) if buf[0] == 0x7F: return (TuiKey(key="backspace", char="\x7f"), buf[1:]) if buf[0] < 0x20: letter = chr(buf[0] + 0x60) if buf[0] != 0 else "@" return (TuiKey(key="ctrl+" + letter, char=chr(buf[0])), buf[1:]) char, length, need_more = tui_utf8_head(buf) if need_more: return ("need_more", buf) if char == " ": return (TuiKey(key="space", char=char), buf[length:]) return (TuiKey(key=char, char=char), buf[length:]) if len(buf) == 1: return (("escape", buf[1:]) if final else ("need_more", buf)) if isinstance(buf, bytearray): buf = bytes(buf) second = buf[1:2] if second == b"\x1b": return ("escape", buf[1:]) if second == b"[": if len(buf) >= 3 and buf[2:3] == b"M": if len(buf) < 6: return ("need_more", buf) event, _ok = tui_parse_mouse_legacy(buf[3:6]) return ((event, buf[6:]) if event is not None else (None, buf[6:])) if len(buf) >= 3 and buf[2:3] == b"<": end = -1 for pos in range(3, min(len(buf), 32)): if buf[pos:pos + 1] in (b"M", b"m"): end = pos break if end < 0: return ("need_more", buf) try: text = buf[2:end + 1].decode("ascii") except UnicodeDecodeError: return (None, buf[end + 1:]) event, _ok = tui_parse_mouse_sgr(text) return ((event, buf[end + 1:]) if event is not None else (None, buf[end + 1:])) end = -1 for pos in range(2, min(len(buf), 64)): if 0x40 <= buf[pos] <= 0x7E: end = pos break if end < 0: return ("need_more", buf) try: body = buf[2:end].decode("ascii") final_char = chr(buf[end]) except UnicodeDecodeError: return (None, buf[end + 1:]) rest = buf[end + 1:] if final_char == "~" and body == "200": return tui_parse_paste(buf, rest) if final_char == "u": return tui_parse_kitty(body, rest) if final_char == "y" and body[:1] == "?" and body[-1:] == "$": mode, _sep, state = body[1:-1].partition(";") return (TuiModeReply(mode=tui_int(mode, -1), state=tui_int(state, 0)), rest) if final_char == "~" and ";" not in body: return ((TuiKey(key=TUI_TILDE_KEYS.get(body, "unknown"), char=""), rest) if body in TUI_TILDE_KEYS else (None, rest)) if ";" in body: base, _, mod = body.partition(";") mods = tui_modifiers(tui_int(mod, 1)) base_key = TUI_TILDE_KEYS.get(base) if final_char == "~" else TUI_CSI_KEYS.get(final_char) if base_key is not None: return (TuiKey(key="+".join(mods + [base_key]) if mods else base_key, char="", modifiers=mods), rest) return (None, rest) if final_char in TUI_CSI_KEYS and not body: return (TuiKey(key=TUI_CSI_KEYS[final_char], char=""), rest) return (None, rest) if second == b"O": if len(buf) < 3: return ("need_more", buf) key = TUI_SS3_KEYS.get(chr(buf[2])) return ((TuiKey(key=key, char=""), buf[3:]) if key is not None else (None, buf[3:])) if second in (b"]", b"P", b"X", b"^", b"_"): return (None, buf[2:]) char, length, need_more = tui_utf8_head(buf[1:]) if need_more: return ("need_more", buf) if char == "\r": return (TuiKey(key="alt+enter", char="\n", modifiers=["alt"]), buf[1 + length:]) return (TuiKey(key="alt+" + char, char=char), buf[1 + length:]) class TuiInput: """Incremental stdin parser. feed() bytes in, events out; flush() turns a trailing lone ESC into the Escape key once input idles.""" def __init__(self): self.buf = b"" def feed(self, data): if data: self.buf += bytes(data) return self._drain(False) def flush(self): events = self._drain(True) while self.buf and self.buf[:1] == b"\x1b" and not tui_paste_head(self.buf): before = len(self.buf) events.extend(self._drain(True)) if len(self.buf) == before: events.append(TuiKey(key="escape", char="\x1b")) self.buf = self.buf[1:] events.extend(self._drain(True)) return events def _drain(self, final): events = [] while self.buf: parsed, rest = tui_parse_one(self.buf, final) if parsed == "need_more": break if parsed == "escape": events.append(TuiKey(key="escape", char="\x1b")) elif isinstance(parsed, TuiEvent): events.append(parsed) rest = rest if isinstance(rest, bytes) else bytes(rest) if rest == self.buf: break self.buf = rest return events class TuiVtScreen: """In-memory VT100/ANSI screen: grid plus streaming byte parser. Terminal widgets feed pty output through feed() and paint from the grid. A cell is None (never written) or a (char, fg, bg, attr) tuple with SGR-fragment colors and a bitmask (1 bold, 2 dim, 4 underline, 8 reverse, 16 strike). Covers shells plus well-behaved fullscreen apps; unknown sequences are skipped, never fatal. Wide characters count as one column each (known gap, see tui.md).""" ATTR_BOLD = 1 ATTR_DIM = 2 ATTR_UNDERLINE = 4 ATTR_REVERSE = 8 ATTR_STRIKE = 16 ATTR_ITALIC = 32 ATTR_BLINK = 64 ATTR_HIDDEN = 128 TAB_WIDTH = 8 GRAPHICS = {"`": "\u25c6", "a": "\u2592", "f": "\u00b0", "g": "\u00b1", "j": "\u2518", "k": "\u2510", "l": "\u250c", "m": "\u2514", "n": "\u253c", "o": "\u23ba", "p": "\u23bb", "q": "\u2500", "r": "\u23bc", "s": "\u23bd", "t": "\u251c", "u": "\u2524", "v": "\u2534", "w": "\u252c", "x": "\u2502", "y": "\u2264", "z": "\u2265", "{": "\u03c0", "|": "\u2260", "}": "\u00a3", "~": "\u00b7"} TAIL_GUARD = 65536 def __init__(self, rows, cols): self.rows = max(1, int(rows)) self.cols = max(1, int(cols)) self.grid = [[None] * self.cols for _row in range(self.rows)] self.cx = 0 self.cy = 0 self.fg = None self.bg = None self.attr = 0 self.cursor_visible = True self.region_top = 0 self.region_bottom = self.rows - 1 self.lr_mode = False self.margin_left = 0 self.margin_right = self.cols - 1 self._wrap = False self._saved = None self._alt = None self._buf = "" self._decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") self.history = None self.history_pushed = 0 self.app_cursor = False self.bracketed_paste = False self.title = "" self.replies = bytearray() self._graphics = False self._last_char = "" @property def alt_active(self): return self._alt is not None def take_replies(self): data = bytes(self.replies) self.replies.clear() return data def blank(self): return (" ", None, self.bg, 0) def text_lines(self): """Rendered rows as plain strings. Trailing never-written cells are cut (a written "> " keeps its space); fully-written rows, where written and padded blanks are indistinguishable, rstrip.""" lines = [] for row in self.grid: end = len(row) while end > 0 and row[end - 1] is None: end -= 1 text = "".join(cell[0] if cell else " " for cell in row[:end]) lines.append(text if end < len(row) else text.rstrip()) return lines def feed(self, data): if not data: return self._buf += self._decoder.decode(bytes(data), False) if len(self._buf) > self.TAIL_GUARD: self._buf = self._buf[-self.TAIL_GUARD:] buf = self._buf pos = 0 end = len(buf) while pos < end: char = buf[pos] if char == "\x1b": nxt = self._parse_esc(buf, pos, end) if nxt is None: break pos = nxt elif char == "\x9b": nxt = self._parse_csi(buf, pos + 1, end) if nxt is None: break pos = nxt elif char in "\x90\x9d\x9e\x9f": nxt = self._parse_until_st(buf, pos + 1, end) if nxt is None: break pos = nxt else: self._ground(char) pos += 1 self._buf = buf[pos:] def _ground(self, char): if char == "\n" or char == "\x0b" or char == "\x0c": self._wrap = False self.line_feed() elif char == "\r": self._wrap = False self.cx = 0 elif char == "\b": self._wrap = False self.cx = max(0, self.cx - 1) elif char == "\t": self._wrap = False self.cx = min(self.cols - 1, (self.cx // self.TAB_WIDTH + 1) * self.TAB_WIDTH) elif char in ("\x07", "\x00", "\x0e", "\x0f"): pass elif ord(char) < 0x20 or char == "\x7f": pass else: if self._wrap: self._wrap = False self.cx = 0 self.line_feed() if self._graphics: char = self.GRAPHICS.get(char, char) self._last_char = char self.grid[self.cy][self.cx] = (char, self.fg, self.bg, self.attr) if self.cx == self.cols - 1: self._wrap = True else: self.cx += 1 def _parse_esc(self, buf, pos, end): if pos + 1 >= end: return None nxt = buf[pos + 1] if nxt == "[": return self._parse_csi(buf, pos + 2, end) if nxt == "]": stop = self._parse_until_st(buf, pos + 2, end) if stop is not None: self._osc(buf[pos + 2:stop]) return stop if nxt in "PX^_": return self._parse_until_st(buf, pos + 2, end) if nxt in "()#%": if pos + 2 >= end: return None if nxt == "(": self._graphics = buf[pos + 2] == "0" return pos + 3 if nxt == "7": self._saved = (self.cx, self.cy, self.fg, self.bg, self.attr) elif nxt == "8": if self._saved is not None: self.cx, self.cy, self.fg, self.bg, self.attr = self._saved self._clamp_cursor() elif nxt == "M": self._wrap = False if self.cy == self.region_top: if self._inside_margins(): self.scroll_down(self.region_top, self.region_bottom) else: self.cy = max(0, self.cy - 1) elif nxt == "c": self.reset() elif nxt == "D": self.line_feed() elif nxt == "E": self.cx = 0 self.line_feed() return pos + 2 def _osc(self, body): body = body.rstrip("\x07\x1b\\") code, _sep, text = body.partition(";") if code in ("0", "2"): self.title = text[:256] def _parse_csi(self, buf, pos, end): cur = pos while cur < end and (buf[cur].isdigit() or buf[cur] in ";:?></=!\"$ "): cur += 1 if cur - pos > 256: return pos + 2 if cur >= end: return None final = buf[cur] if not "@" <= final <= "~": return pos + 2 self._dispatch_csi(buf[pos:cur], final) return cur + 1 def _parse_until_st(self, buf, pos, end): cur = pos while cur < end: if buf[cur] == "\x07": return cur + 1 if buf[cur] == "\x1b": if cur + 1 >= end: return None if buf[cur + 1] == "\\": return cur + 2 cur += 2 continue cur += 1 if cur - pos > 4096: return cur return None def _params(self, body, count, default=1): if body[:1] in ("?", ">", "="): body = body[1:] elif body[:1] in ("!", "\"", "$"): body = "" parts = body.replace(":", ";").split(";")[:64] values = [] for part in parts: part = part.strip() if not part: values.append(default) continue try: values.append(int(part)) except ValueError: values.append(default) if count is not None: while len(values) < count: values.append(default) return values def _dispatch_csi(self, body, final): private = body[:1] == "?" or body[:1] == ">" if final == "m": self._sgr(body) return if final == "c" and body in ("", "0", ">", ">0"): self.replies += b"\x1b[>0;0;0c" if body[:1] == ">" else b"\x1b[?1;2c" return if final == "p" and body.endswith("$"): mode = self._params(body[:-1], 1, 0)[0] state = {25: self.cursor_visible, 1: self.app_cursor, 2004: self.bracketed_paste, 69: self.lr_mode, 1049: self._alt is not None}.get(mode) if private else None self.replies += ("\x1b[%s%d;%d$y" % ("?" if private else "", mode, 0 if state is None else 1 if state else 2)).encode("ascii") return if final == "n" and not private: code = self._params(body, 1, 0)[0] if code == 5: self.replies += b"\x1b[0n" elif code == 6: self.replies += ("\x1b[%d;%dR" % (self.cy + 1, self.cx + 1)).encode("ascii") return if final == "h" or final == "l": self._mode(body, final == "h") return if private: return if final == "A": self._wrap = False floor = self.region_top if self._in_region() else 0 self.cy = max(floor, self.cy - self._params(body, 1)[0]) elif final == "B": self._wrap = False ceil = self.region_bottom if self._in_region() else self.rows - 1 self.cy = min(ceil, self.cy + self._params(body, 1)[0]) elif final == "C": self._wrap = False self.cx = min(self.cols - 1, self.cx + self._params(body, 1)[0]) elif final == "D": self._wrap = False self.cx = max(0, self.cx - self._params(body, 1)[0]) elif final == "E": self._wrap = False self.cy = min(self.rows - 1, self.cy + self._params(body, 1)[0]) self.cx = 0 elif final == "F": self._wrap = False self.cy = max(0, self.cy - self._params(body, 1)[0]) self.cx = 0 elif final == "G": self._wrap = False self.cx = min(self.cols - 1, max(0, self._params(body, 1)[0] - 1)) elif final == "H" or final == "f": self._wrap = False row, col = self._params(body, 2) self.cy = min(self.rows - 1, max(0, row - 1)) self.cx = min(self.cols - 1, max(0, col - 1)) elif final == "d": self._wrap = False self.cy = min(self.rows - 1, max(0, self._params(body, 1)[0] - 1)) elif final == "J": self._wrap = False self._erase_display(self._params(body, 1, 0)[0]) elif final == "K": self._wrap = False self._erase_line(self._params(body, 1, 0)[0]) elif final == "L": self._wrap = False self._insert_lines(self._params(body, 1)[0]) elif final == "M": self._wrap = False self._delete_lines(self._params(body, 1)[0]) elif final == "P": self._wrap = False self._delete_chars(self._params(body, 1)[0]) elif final == "@": self._wrap = False self._insert_chars(self._params(body, 1)[0]) elif final == "X": self._wrap = False self._erase_chars(self._params(body, 1)[0]) elif final == "S": self._wrap = False for _i in range(min(self._params(body, 1)[0], self.rows)): self.scroll_up(self.region_top, self.region_bottom) elif final == "T": self._wrap = False for _i in range(min(self._params(body, 1)[0], self.rows)): self.scroll_down(self.region_top, self.region_bottom) elif final == "r": top, bottom = self._params(body, 2) top = min(self.rows, max(1, top)) bottom = min(self.rows, max(1, bottom)) if top < bottom: self.region_top = top - 1 self.region_bottom = bottom - 1 self.cx = 0 self.cy = self.region_top elif final == "b": if self._last_char: for _i in range(min(self._params(body, 1)[0], self.cols * self.rows)): self._ground(self._last_char) elif final == "s" and self.lr_mode: left, right = self._params(body, 2) left = min(self.cols, max(1, left)) right = min(self.cols, max(1, right if body.count(";") else self.cols)) if left < right: self.margin_left = left - 1 self.margin_right = right - 1 self.cx = 0 self.cy = 0 elif final == "s": self._saved = (self.cx, self.cy, self.fg, self.bg, self.attr) elif final == "u": if self._saved is not None: self.cx, self.cy, self.fg, self.bg, self.attr = self._saved self._clamp_cursor() def _in_region(self): return self.region_top <= self.cy <= self.region_bottom def _mode(self, body, state): private = body[:1] == "?" for value in self._params(body, None, 0): if value == 25 and private: self.cursor_visible = state elif value == 1 and private: self.app_cursor = state elif value == 2004 and private: self.bracketed_paste = state elif value == 69 and private: self.lr_mode = state self.margin_left = 0 self.margin_right = self.cols - 1 elif value in (47, 1047, 1049) and private: self._alt_screen(state) def _alt_screen(self, state): if state and self._alt is None: self._alt = (self.grid, self.cx, self.cy, self.fg, self.bg, self.attr, self.region_top, self.region_bottom, self._saved, self.cursor_visible) self.grid = [[None] * self.cols for _row in range(self.rows)] self.cx = 0 self.cy = 0 self.fg = None self.bg = None self.attr = 0 self.region_top = 0 self.region_bottom = self.rows - 1 self._saved = None self._wrap = False elif not state and self._alt is not None: self.grid, self.cx, self.cy, self.fg, self.bg, self.attr, self.region_top, self.region_bottom, self._saved, self.cursor_visible = self._alt self._alt = None self._wrap = False def _sgr(self, body): values = self._params(body, None, 0) if body.strip("?") else [0] pos = 0 while pos < len(values): code = values[pos] if code == 0: self.fg = None self.bg = None self.attr = 0 elif code == 1: self.attr |= self.ATTR_BOLD elif code == 2: self.attr |= self.ATTR_DIM elif code == 3: self.attr |= self.ATTR_ITALIC elif code == 4: self.attr |= self.ATTR_UNDERLINE elif code == 5: self.attr |= self.ATTR_BLINK elif code == 7: self.attr |= self.ATTR_REVERSE elif code == 8: self.attr |= self.ATTR_HIDDEN elif code == 9: self.attr |= self.ATTR_STRIKE elif code == 22: self.attr &= ~(self.ATTR_BOLD | self.ATTR_DIM) elif code == 23: self.attr &= ~self.ATTR_ITALIC elif code == 24: self.attr &= ~self.ATTR_UNDERLINE elif code == 25: self.attr &= ~self.ATTR_BLINK elif code == 27: self.attr &= ~self.ATTR_REVERSE elif code == 28: self.attr &= ~self.ATTR_HIDDEN elif code == 29: self.attr &= ~self.ATTR_STRIKE elif 30 <= code <= 37 or 90 <= code <= 97: self.fg = str(code) elif code == 39: self.fg = None elif 40 <= code <= 47 or 100 <= code <= 107: self.bg = str(code) elif code == 49: self.bg = None elif code == 38 or code == 48: target = "fg" if code == 38 else "bg" if pos + 2 < len(values) and values[pos + 1] == 5: setattr(self, target, "%d;5;%d" % (code, values[pos + 2] & 0xFF)) pos += 2 elif pos + 4 < len(values) and values[pos + 1] == 2: setattr(self, target, "%d;2;%d;%d;%d" % (code, values[pos + 2] & 0xFF, values[pos + 3] & 0xFF, values[pos + 4] & 0xFF)) pos += 4 pos += 1 def line_feed(self): if self.cy == self.region_bottom: if self._inside_margins(): self.scroll_up(self.region_top, self.region_bottom) else: self.cy = min(self.rows - 1, self.cy + 1) def _margins(self): """Inclusive left and right column of the scrolling area: the whole width unless left/right margin mode (DECLRMM) is on.""" if self.lr_mode: return (self.margin_left, self.margin_right) return (0, self.cols - 1) def _inside_margins(self): left, right = self._margins() return left <= self.cx <= right def scroll_up(self, top, bottom, record=True): left, right = self._margins() if left > 0 or right < self.cols - 1: for row in range(top, bottom): self.grid[row] = self.grid[row][:left] + self.grid[row + 1][left:right + 1] + self.grid[row][right + 1:] self.grid[bottom] = self.grid[bottom][:left] + [self.blank()] * (right - left + 1) + self.grid[bottom][right + 1:] return if record and self.history is not None and top == 0 and bottom == self.rows - 1 and self._alt is None: self.history.append(self.grid[0]) self.history_pushed += 1 for row in range(top, bottom): self.grid[row] = self.grid[row + 1] self.grid[bottom] = [self.blank()] * self.cols def scroll_down(self, top, bottom): left, right = self._margins() if left > 0 or right < self.cols - 1: for row in range(bottom, top, -1): self.grid[row] = self.grid[row][:left] + self.grid[row - 1][left:right + 1] + self.grid[row][right + 1:] self.grid[top] = self.grid[top][:left] + [self.blank()] * (right - left + 1) + self.grid[top][right + 1:] return for row in range(bottom, top, -1): self.grid[row] = self.grid[row - 1] self.grid[top] = [self.blank()] * self.cols def _erase_display(self, mode): if mode == 2 or mode == 3: for row in range(self.rows): self.grid[row] = [self.blank()] * self.cols self.cx = 0 self.cy = 0 elif mode == 1: for row in range(0, self.cy): self.grid[row] = [self.blank()] * self.cols for col in range(0, self.cx + 1): self.grid[self.cy][col] = self.blank() else: for col in range(self.cx, self.cols): self.grid[self.cy][col] = self.blank() for row in range(self.cy + 1, self.rows): self.grid[row] = [self.blank()] * self.cols def _erase_line(self, mode): if mode == 1: for col in range(0, self.cx + 1): self.grid[self.cy][col] = self.blank() elif mode == 2: self.grid[self.cy] = [self.blank()] * self.cols else: for col in range(self.cx, self.cols): self.grid[self.cy][col] = self.blank() def _insert_lines(self, count): count = min(max(1, count), self.region_bottom - self.cy + 1) if self.cy < self.region_top or self.cy > self.region_bottom or not self._inside_margins(): return for _i in range(count): self.scroll_down(self.cy, self.region_bottom) def _delete_lines(self, count): count = min(max(1, count), self.region_bottom - self.cy + 1) if self.cy < self.region_top or self.cy > self.region_bottom or not self._inside_margins(): return for _i in range(count): self.scroll_up(self.cy, self.region_bottom, False) def _delete_chars(self, count): row = self.grid[self.cy] rest = row[self.cx + count:] self.grid[self.cy] = row[:self.cx] + rest + [self.blank()] * min(max(1, count), self.cols - self.cx) def _insert_chars(self, count): row = self.grid[self.cy] room = self.cols - self.cx count = min(max(1, count), room) self.grid[self.cy] = row[:self.cx] + ([self.blank()] * count + row[self.cx:])[:room] def _erase_chars(self, count): for col in range(self.cx, min(self.cols, self.cx + max(1, count))): self.grid[self.cy][col] = self.blank() def _clamp_cursor(self): self.cx = min(self.cols - 1, max(0, self.cx)) self.cy = min(self.rows - 1, max(0, self.cy)) def reset(self): for row in range(self.rows): self.grid[row] = [self.blank()] * self.cols self.cx = 0 self.cy = 0 self.fg = None self.bg = None self.attr = 0 self.cursor_visible = True self.region_top = 0 self.region_bottom = self.rows - 1 self.lr_mode = False self.margin_left = 0 self.margin_right = self.cols - 1 self._wrap = False self._saved = None self._alt = None self.app_cursor = False self.bracketed_paste = False self._graphics = False def resize(self, rows, cols): rows = max(1, int(rows)) cols = max(1, int(cols)) grid = [] for row in range(rows): if row < self.rows: old = self.grid[row] grid.append(old[:cols] if cols <= self.cols else old + [None] * (cols - self.cols)) else: grid.append([None] * cols) self.grid = grid self.rows = rows self.cols = cols self.region_top = 0 self.region_bottom = rows - 1 self.lr_mode = False self.margin_left = 0 self.margin_right = cols - 1 self._clamp_cursor() self._wrap = False def text(self, limit=None): rows = self.grid if limit is None else self.grid[-limit:] lines = [] for row in rows: lines.append("".join(cell[0] if cell else " " for cell in row).rstrip()) return "\n".join(lines) @staticmethod def style_for(cell, base): if cell is None: return base _char, fg, bg, attr = cell return TuiStyle(fg=TuiVtScreen._frag_to_color(fg, False) or base.fg, bg=TuiVtScreen._frag_to_color(bg, True) or base.bg, bold=bool(attr & TuiVtScreen.ATTR_BOLD), dim=bool(attr & TuiVtScreen.ATTR_DIM), underline=bool(attr & TuiVtScreen.ATTR_UNDERLINE), reverse=bool(attr & TuiVtScreen.ATTR_REVERSE), strike=bool(attr & TuiVtScreen.ATTR_STRIKE)) @staticmethod def _frag_to_color(frag, background): if frag is None: return None if ";" not in frag: try: code = int(frag) except ValueError: return None if 30 <= code <= 37: return ("black", "red", "green", "yellow", "blue", "magenta", "cyan", "white")[code - 30] if 90 <= code <= 97: return "bright_" + ("black", "red", "green", "yellow", "blue", "magenta", "cyan", "white")[code - 90] if 40 <= code <= 47: return ("black", "red", "green", "yellow", "blue", "magenta", "cyan", "white")[code - 40] if 100 <= code <= 107: return "bright_" + ("black", "red", "green", "yellow", "blue", "magenta", "cyan", "white")[code - 100] return None parts = frag.split(";") try: nums = [int(part) for part in parts] except ValueError: return None if len(nums) == 3 and nums[1] == 5: return nums[2] if len(nums) == 5 and nums[1] == 2: return (nums[2], nums[3], nums[4]) return None class TuiText(TuiPanel): """Wrapped static text with alignment.""" def __init__(self, text="", text_align="left", wrap=True, **kwargs): super().__init__(**kwargs) self.text = text self.text_align = text_align self.wrap = wrap def measure(self): lines = str(self.text).split("\n") width = 0 for line in lines: width = max(width, len(line)) return (width, max(1, len(lines))) def lines(self, width): out = [] for para in str(self.text).split("\n"): if not self.wrap or width <= 0: out.append(para) continue words = para.split(" ") line = "" for word in words: piece = word if not line else line + " " + word if len(piece) <= width or not line: line = piece else: out.append(line) line = word out.append(line) return out def content_rows(self): width = self._content_w() height = self._content_h() lines = self.lines(width) if self.valign in ("middle", "center"): top = max(0, (height - len(lines)) // 2) elif self.valign == "bottom": top = max(0, height - len(lines)) else: top = 0 rows = [] for pos in range(height): line = lines[pos - top] if top <= pos < top + len(lines) else "" if self.text_align == "center": line = line.center(width) elif self.text_align == "right": line = line.rjust(width) else: line = line.ljust(width) rows.append([TuiSpan(line[:width], self.style)]) return rows class TuiRich(TuiPanel): """Static multi-style rows: set() takes one span list or a list of span lists. Rows pad to the panel width and clip, so callers never measure. .text mirrors the plain content for tests and status reads.""" def __init__(self, rows=(), **kwargs): super().__init__(**kwargs) self.rows = [] self.text = "" self.set(rows) def set(self, rows): rows = list(rows) if rows and isinstance(rows[0], TuiSpan): rows = [rows] rows = [list(row) for row in rows] look = [tuple((span.text, span.style.key()) for span in row) for row in rows] if look == self.__dict__.get("_look"): return self._look = look self.rows = rows self.text = "\n".join("".join(span.text for span in row) for row in self.rows) if self.app is not None: self.touch() def measure(self): width = 0 for row in self.rows: width = max(width, sum(len(span.text) for span in row)) return (width, max(1, len(self.rows))) def content_rows(self): width = self._content_w() height = self._content_h() out = [] for pos in range(height): row = self.rows[pos] if pos < len(self.rows) else [] out.append(tui_slice_spans(row, 0, width, self.style)) return out class TuiButton(TuiPanel): """Clickable button: Enter/Space/click press it. on_press is the Press-event handler attribute and receives the event.""" def __init__(self, label="", on_press=None, brackets=True, **kwargs): kwargs.setdefault("focusable", True) kwargs.setdefault("border", True) super().__init__(**kwargs) self.label = label self.on_press = on_press self.brackets = brackets def measure(self): return (len(self.label) + 4, 1) def content_rows(self): width = self._content_w() height = self._content_h() label = (("[ %s ]" if self.brackets else " %s ") % self.label).center(width)[:width] style = self.style if not self.enabled: style = style.merged(TuiStyle(dim=True)) elif self.app is not None and self.app.focused is self: style = style.merged(TuiStyle(reverse=True, bold=True)) rows = [] for pos in range(height): if height == 1 or pos == height // 2: rows.append([TuiSpan(label, style)]) else: rows.append([TuiSpan(" " * width, self.style)]) return rows def on_key(self, event): if not self.enabled: return if event.key in ("enter", "space"): event.stop() self.press() def on_click(self, event): if not self.enabled or not event.pressed: return event.stop() self.focus() self.press() def press(self): self.emit(TuiPress(panel=self)) if self.app is not None: self.touch() class TuiOptions(TuiPanel): """Virtualized option list: arrows/mouse/wheel, Enter selects. on_select is the Select-event handler attribute and receives the event (index, option, value on it); the event also bubbles.""" def __init__(self, options=(), selected=0, multi=False, on_select=None, **kwargs): kwargs.setdefault("focusable", True) kwargs.setdefault("scrollable", True) super().__init__(**kwargs) self.options = [TuiOptions.norm_option(item) for item in options] self.selected = 0 self.multi = multi self.chosen = set() self.on_select = on_select if self.options: self.selected = max(0, min(int(selected), len(self.options) - 1)) @staticmethod def norm_option(item): if isinstance(item, (tuple, list)) and len(item) >= 2: return (str(item[0]), item[1]) if isinstance(item, dict): return (str(item.get("label", "")), item.get("value")) return (str(item), item) def measure(self): width = 4 for label, _value in self.options: width = max(width, len(label) + 4) return (width, max(1, len(self.options))) def content_rows(self): width = self._content_w() height = self._content_h() self._follow_selection(height) rows = [] focused = self.app is not None and self.app.focused is self for pos in range(height): index = self.scroll_y + pos if 0 <= index < len(self.options): label, _value = self.options[index] marker = ">" if self.multi: marker = "[x]" if index in self.chosen else "[ ]" elif index != self.selected: marker = " " text = (" %s %s" % (marker, label))[:width].ljust(width) if index == self.selected and self.enabled: style = self.style.merged(TuiStyle(reverse=True, bold=focused)) else: style = self.style rows.append([TuiSpan(text, style)]) else: rows.append([TuiSpan(" " * width, self.style)]) return rows def _follow_selection(self, height): if self.selected < self.scroll_y: self.scroll_y = self.selected elif self.selected >= self.scroll_y + max(1, height): self.scroll_y = self.selected - max(1, height) + 1 def move(self, delta): if not self.options or not self.enabled: return self.selected = max(0, min(len(self.options) - 1, self.selected + delta)) if self.app is not None: self.touch() def choose(self, index=None): if not self.options or not self.enabled: return if index is not None: self.selected = max(0, min(len(self.options) - 1, index)) if self.multi: if self.selected in self.chosen: self.chosen.discard(self.selected) else: self.chosen.add(self.selected) label, value = self.options[self.selected] self.emit(TuiSelect(panel=self, index=self.selected, option=label, value=value)) if self.app is not None: self.touch() def on_key(self, event): if not self.enabled: return if event.key == "up": event.stop() self.move(-1) elif event.key == "down": event.stop() self.move(1) elif event.key == "home": event.stop() self.move(-len(self.options)) elif event.key == "end": event.stop() self.move(len(self.options)) elif event.key == "pageup": event.stop() self.move(-max(1, self._content_h())) elif event.key == "pagedown": event.stop() self.move(max(1, self._content_h())) elif event.key in ("enter", "space"): event.stop() self.choose() def on_click(self, event): if not self.enabled or not event.pressed: return event.stop() self.focus() pad_top = self.padding[0] + (1 if self.border else 0) index = self.scroll_y + (event.y - self.ay - pad_top) if 0 <= index < len(self.options): self.choose(index) def on_scroll(self, event): if not self.enabled: return event.stop() if event.direction == "up": self.move(-1) elif event.direction == "down": self.move(1) COMPLETE_WORD_RE = re.compile(r"(?:^|\s)([/@])([A-Za-z0-9_./-]*)$") TUI_AVATAR_ROLES = ("accent", "accent2", "accent3", "success", "warning", "info") def tui_avatar_role(name): """Stable accent role per name so a bot keeps its color everywhere.""" return TUI_AVATAR_ROLES[sum(ord(char) for char in str(name)) % len(TUI_AVATAR_ROLES)] class TuiChatList(TuiOptions): """Two-line chat rows: avatar chip + name / snippet. Options carry (label, value) as usual; row_meta maps value -> {"snippet": str, "draft": str, "busy": bool or a spinner glyph, "unread": int}. Selection moves per chat while each chat paints two terminal rows; the selected chat gets an accent bar.""" def __init__(self, options=(), **kwargs): super().__init__(options, **kwargs) self.row_meta = {} def measure(self): width, _height = super().measure() return (width, max(1, 2 * len(self.options))) def _follow_selection(self, height): top = self.selected * 2 if top < self.scroll_y: self.scroll_y = top elif top + 1 >= self.scroll_y + max(1, height): self.scroll_y = top + 2 - max(1, height) def content_rows(self): width = self._content_w() height = self._content_h() self._follow_selection(height) focused = self.app is not None and self.app.focused is self rows = [] for pos in range(height): index = self.scroll_y + pos chat, sub = divmod(index, 2) if 0 <= chat < len(self.options): label, value = self.options[chat] meta = self.row_meta.get(value) or {} active = chat == self.selected and self.enabled base = self.style.merged(TuiStyle(bg="role:selection")) if active else self.style tint = "role:" + tui_avatar_role(label) bar = TuiSpan("▌" if active else " ", base.merged(TuiStyle(fg="role:accent"))) if sub == 0: chip = TuiSpan(" %s " % (label[:1].upper() or "?"), TuiStyle(fg="role:on_accent", bg=tint, bold=True)) name = TuiSpan(" " + label[:max(0, width - 6)], base.merged(TuiStyle(bold=active or focused))) busy = meta.get("busy") badge = [TuiSpan(" %s" % (busy if isinstance(busy, str) else "●"), base.merged(TuiStyle(fg="role:warning", bold=True)))] if busy else [] unread = int(meta.get("unread") or 0) if unread: badge.append(TuiSpan(" %d " % min(unread, 99), TuiStyle(fg="role:on_accent", bg="role:accent", bold=True))) rows.append(tui_pad_spans([bar, chip, name] + badge, width, base)) else: snippet = str(meta.get("snippet") or "") draft = str(meta.get("draft") or "") lead = TuiSpan(" ", base) if draft: head = TuiSpan("Draft: ", base.merged(TuiStyle(fg="role:error"))) tail = TuiSpan(draft[:max(0, width - 12)], base.merged(TuiStyle(fg="role:muted"))) rows.append(tui_pad_spans([bar, lead, head, tail], width, base)) else: tail = TuiSpan(snippet[:max(0, width - 6)], base.merged(TuiStyle(fg="role:muted"))) rows.append(tui_pad_spans([bar, lead, tail], width, base)) else: rows.append([TuiSpan(" " * width, self.style)]) return rows def on_click(self, event): if not self.enabled or not event.pressed: return event.stop() self.focus() pad_top = self.padding[0] + (1 if self.border else 0) index = self.scroll_y + (event.y - self.ay - pad_top) chat = index // 2 if 0 <= chat < len(self.options): self.choose(chat) class TuiAutocomplete(TuiOptions): """Non-focusable completion popup owned by a TuiTextBox. The box keeps focus and drives the list: typing filters, arrows move, Enter accepts via the Select event, Esc dismisses. Tab is never used: it always moves focus by framework rule.""" def __init__(self, **kwargs): kwargs.setdefault("focusable", False) kwargs.setdefault("border", True) kwargs.setdefault("layer", "dialog") kwargs.setdefault("z", 5) super().__init__((), **kwargs) def set_items(self, items): self.options = [TuiOptions.norm_option(item) for item in items] self.selected = 0 self.scroll_y = 0 if self.app is not None: self.touch() TUI_COMMAND_HELP = {} def tui_command_help(): """Slash-command descriptions parsed once from HELP_TEXT.""" if not TUI_COMMAND_HELP: for line in HELP_TEXT.splitlines(): match = re.match(r"\s*/(\S+)\s+(.*)", line) if not match: continue rest = match.group(2) desc = re.split(r"\s{2,}", rest.strip())[-1].strip() TUI_COMMAND_HELP.setdefault(match.group(1), desc) return TUI_COMMAND_HELP def tui_complete_mention(prefix, directory=None, bots=(), models=()): """Completion source for @mentions: [(label, value)] filtered by prefix across bots, providers, known models, and files.""" found = [] low = (prefix or "").lower() for name in bots: if name.lower().startswith(low): found.append(("@%s [bot]" % name, name)) for label in PROVIDER_LABELS: if label.lower().startswith(low): found.append(("@%s [provider]" % label, label)) if MENTION_AUTO.startswith(low): found.append(("@%s [reset pins]" % MENTION_AUTO, MENTION_AUTO)) if not models: models = sorted(set(ZEN_FREE_MODELS) | set(POLLINATIONS_MODELS) | set(DEVPLACE_FREE_ROUTES) | {OPENROUTER_MODEL}) for mid in models: if mid.lower().startswith(low): found.append(("@%s [model]" % mid, mid)) for rel in complete_files(prefix or "", directory or dir_key()): found.append(("@%s [file]" % rel, rel)) return found[:100] def tui_complete_command(prefix): """Completion source for slash commands: [(label, name)] filtered by prefix. Labels carry the HELP_TEXT description when one exists.""" help_map = tui_command_help() found = [] for name in COMMANDS: if not name.startswith(prefix or ""): continue desc = help_map.get(name, "") label = "/%s - %s" % (name, desc) if desc else "/" + name found.append((label, name)) return found class VaultHistory: """History backend over the vault's input_history table: load() gives the profile's entries oldest to newest, append(text) records one. The REPL and the chat scene both use it, so they share one sealed list.""" def __init__(self, store, profile, bot=lambda: "main"): self.store = store self.profile = profile self.bot = bot @staticmethod def for_agent(agent): store = getattr(agent, "store", None) if store is None or not hasattr(store, "history_add"): return None return VaultHistory(store, agent.profile, lambda: agent.bot) def load(self): return self.store.history_load(self.profile) def append(self, text): return self.store.history_add(self.profile, self.bot(), text) class TuiHistory: """Input history with readline semantics: up recalls older entries, down walks back toward the unfinished draft you started from. Persistence is a pluggable backend with load() and append(text) (VaultHistory); without one it lives in memory only. Capped at INPUT_HISTORY_CAP entries.""" def __init__(self, backend=None, cap=INPUT_HISTORY_CAP): self.backend = backend self.cap = max(1, int(cap)) self.entries = [] self.pos = None self.draft = "" self.write_errors = 0 if backend is not None: try: self.entries = [str(item) for item in backend.load()][-self.cap:] except Exception: self.entries = [] def add(self, text): text = str(text or "").strip() self.reset() if not text or (self.entries and self.entries[-1] == text): return self.entries.append(text) del self.entries[:-self.cap] if self.backend is not None: try: self.backend.append(text) except Exception: self.write_errors += 1 def reset(self): self.pos = None self.draft = "" def older(self, current): """Previous entry, or None at the oldest. The first step remembers current as the draft.""" if not self.entries: return None if self.pos is None: self.draft = str(current or "") self.pos = len(self.entries) if self.pos == 0: return None self.pos -= 1 return self.entries[self.pos] def newer(self): """Next entry; past the newest it returns the draft and leaves browsing. None when not browsing.""" if self.pos is None: return None self.pos += 1 if self.pos >= len(self.entries): draft = self.draft self.reset() return draft return self.entries[self.pos] class TuiTextBox(TuiPanel): """Multiline text editor. Arrows/home/end/pgup/pgdn move, Enter splits lines, Backspace/Delete join them, clicks position the cursor. Escape is left alone so dialogs can close on it, unless a completion popup is open: then Esc dismisses the popup first. complete= wires a prefix-to-items callable for /words and mentions= the same for @words, both driving a TuiAutocomplete popup.""" def __init__(self, text="", complete=None, mentions=None, send_on_enter=False, on_send=None, max_chars=0, placeholder="", history=None, **kwargs): kwargs.setdefault("focusable", True) kwargs.setdefault("border", True) super().__init__(**kwargs) self.lines = str(text).split("\n") or [""] self.cx = len(self.lines[-1]) self.cy = len(self.lines) - 1 self.top = 0 self.complete_fn = complete self.mention_fn = mentions self.complete_popup = None self.send_on_enter = send_on_enter self.on_send = on_send self.max_chars = max(0, int(max_chars or 0)) self.placeholder = placeholder or "" self.history = history self._rows_seen = len(self.lines) def touch(self): """Besides repainting, tell the owner when the number of lines changed (on_change, on the loop), so a composer can resize without a polling timer.""" super().touch() rows = len(self.lines) if rows != self._rows_seen and self.app is not None: self._rows_seen = rows self.app.run_on_loop(lambda: self.emit(TuiChange(panel=self, rows=rows))) @property def value(self): return "\n".join(self.lines) def measure(self): width = 0 for line in self.lines: width = max(width, len(line)) return (max(1, width), max(1, len(self.lines))) def _clamp(self): self.cy = max(0, min(len(self.lines) - 1, self.cy)) self.cx = max(0, min(len(self.lines[self.cy]), self.cx)) def _touch(self): if self.app is not None: self.touch() def _origin(self): edge = 1 if self.border else 0 return (self.ax + edge + self.padding[3], self.ay + edge + self.padding[0]) def content_rows(self): width = self._content_w() height = self._content_h() self._clamp() if self.cy < self.top: self.top = self.cy if self.cy >= self.top + height: self.top = self.cy - height + 1 left = max(0, self.cx - width + 1) focused = self.app is not None and self.app.focused is self and self.enabled hint = self.placeholder if (self.placeholder and not self.value.strip()) else "" hint_style = self.style.merged(TuiStyle(fg="role:muted", dim=True)) if hint else self.style rows = [] for pos in range(height): index = self.top + pos line = self.lines[index][left:left + width] if 0 <= index < len(self.lines) else "" style = self.style if hint and index == 0: line, style = hint, hint_style line = line.ljust(width) if focused and index == self.cy: col = self.cx - left before, at, after = line[:col], line[col:col + 1], line[col + 1:] rows.append([TuiSpan(before, style), TuiSpan(at or " ", style.merged(TuiStyle(reverse=True))), TuiSpan(after, style)]) else: rows.append([TuiSpan(line[:width], style)]) return rows def insert(self, text): text = str(text) if self.max_chars: room = self.max_chars - len(self.value) if room <= 0: return text = text[:room] parts = text.split("\n") line = self.lines[self.cy] head, tail = line[:self.cx], line[self.cx:] self.lines[self.cy] = head + parts[0] self.cx = len(head) + len(parts[0]) for part in parts[1:-1]: self.cy += 1 self.lines.insert(self.cy, part) if len(parts) > 1: self.cy += 1 self.lines.insert(self.cy, parts[-1] + tail) self.cx = len(parts[-1]) else: self.lines[self.cy] += tail self._clamp() self._touch() def _complete_refresh(self): popup = self.complete_popup if self.app is None: if popup is not None: self._complete_hide() return match = COMPLETE_WORD_RE.search(self.lines[self.cy][:self.cx]) fn = None if match: fn = self.complete_fn if match.group(1) == "/" else self.mention_fn try: items = fn(match.group(2)) if (match and callable(fn)) else [] except Exception: items = [] if not items: if popup is not None: self._complete_hide() return if popup is None: popup = TuiAutocomplete() popup.on_select = self._complete_picked self.complete_popup = popup self.root().add(popup) popup.set_items(items) self._complete_place(popup) def _complete_place(self, popup): root = self.root() edge = 1 if root.border else 0 ox = root.ax + edge + root.padding[3] oy = root.ay + edge + root.padding[0] room_w = max(0, root._content_w()) room_h = max(0, root._content_h()) chrome_w = popup.chrome_w() chrome_h = popup.chrome_h() width = 4 for label, _value in popup.options: width = max(width, len(label) + 4) width = max(4, min(width, max(4, room_w - chrome_w))) height = max(1, min(len(popup.options), TUI_COMPLETE_MAXH)) view_h = self._content_h() if self.cy < self.top: self.top = self.cy elif self.cy >= self.top + view_h: self.top = self.cy - view_h + 1 box_ox, box_oy = self._origin() left = max(0, self.cx - self._content_w() + 1) cur_x = box_ox + (self.cx - left) cur_y = box_oy + (self.cy - self.top) popup.x = min(max(0, cur_x - ox), max(0, room_w - width - chrome_w)) below = cur_y - oy + 1 if below + height + chrome_h <= room_h: popup.y = below else: popup.y = max(0, cur_y - oy - height - chrome_h) popup.w = width popup.h = height def _complete_move(self, key): popup = self.complete_popup if popup is None: return if key == "up": popup.move(-1) elif key == "down": popup.move(1) elif key == "home": popup.move(-len(popup.options)) elif key == "end": popup.move(len(popup.options)) elif key == "pageup": popup.move(-max(1, popup._content_h())) elif key == "pagedown": popup.move(max(1, popup._content_h())) def _complete_hide(self): popup = self.complete_popup self.complete_popup = None if popup is not None and popup.parent is not None: popup.parent.remove(popup) def _complete_picked(self, event): if event.panel is not self.complete_popup: return event.stop() self._complete_accept(event.value) def _complete_accept(self, value): line = self.lines[self.cy] match = COMPLETE_WORD_RE.search(line[:self.cx]) if not match: self._complete_hide() return trigger = match.group(1) word_start = match.start(1) chars = "_-" if trigger == "/" else "_-./" end = self.cx while end < len(line) and (line[end].isalnum() or line[end] in chars): end += 1 replacement = trigger + str(value) self.lines[self.cy] = line[:word_start] + replacement + line[end:] self.cx = word_start + len(replacement) if self.cx >= len(self.lines[self.cy]): self.lines[self.cy] += " " self.cx += 1 self._clamp() self._touch() self._complete_hide() def on_paste(self, event): if not self.enabled: return event.stop() text = (event.text or "").replace("\r\n", "\n").replace("\r", "\n") if text: self.insert(text) self._complete_refresh() def on_key(self, event): if not self.enabled: return key = event.key or "" if key == "escape" and self.complete_popup is not None: event.stop() self._complete_hide() return if key in ("escape", "tab", "backtab"): return if self.complete_popup is not None: if key in ("up", "down", "home", "end", "pageup", "pagedown"): event.stop() self._complete_move(key) return if key == "enter": event.stop() self.complete_popup.choose() return if key == "left": event.stop() self.cx -= 1 elif key == "right": event.stop() self.cx += 1 elif key == "up": event.stop() if self.history is not None and self.cy == 0: self._recall(self.history.older(self.value)) return self.cy -= 1 elif key == "down": event.stop() if self.history is not None and self.cy == len(self.lines) - 1 and self.history.pos is not None: self._recall(self.history.newer()) return self.cy += 1 elif key == "home": event.stop() self.cx = 0 elif key == "end": event.stop() self.cx = len(self.lines[self.cy]) elif key in ("pageup", "pagedown"): event.stop() step = max(1, self._content_h()) self.cy += -step if key == "pageup" else step elif key == "enter": event.stop() if self.send_on_enter and callable(self.on_send): self._complete_hide() self.on_send(self.value) return self.insert("\n") self._complete_refresh() return elif key in ("shift+enter", "alt+enter"): event.stop() self.insert("\n") self._complete_refresh() return elif key == "backspace": event.stop() if self.cx > 0: line = self.lines[self.cy] self.lines[self.cy] = line[:self.cx - 1] + line[self.cx:] self.cx -= 1 elif self.cy > 0: prev = self.lines[self.cy - 1] self.cx = len(prev) self.lines[self.cy - 1] = prev + self.lines[self.cy] del self.lines[self.cy] self.cy -= 1 elif key == "delete": event.stop() line = self.lines[self.cy] if self.cx < len(line): self.lines[self.cy] = line[:self.cx] + line[self.cx + 1:] elif self.cy < len(self.lines) - 1: self.lines[self.cy] = line + self.lines[self.cy + 1] del self.lines[self.cy + 1] else: char = event.char or "" if char and "+" not in key and not key.startswith(("ctrl+", "alt+")): event.stop() self.insert(char) self._complete_refresh() return return self._clamp() self._touch() self._complete_refresh() def _recall(self, text): """Replace the buffer with a history entry, cursor at the end like readline. None (nothing older/newer) leaves the buffer alone.""" if text is None: return self.lines = str(text).split("\n") or [""] self.cy = len(self.lines) - 1 self.cx = len(self.lines[-1]) self.top = 0 self._complete_hide() self._clamp() self._touch() def on_click(self, event): if not self.enabled or not event.pressed: return event.stop() self.focus() ox, oy = self._origin() left = max(0, self.cx - self._content_w() + 1) self.cy = self.top + (event.y - oy) self.cx = left + (event.x - ox) self._clamp() self._touch() self._complete_refresh() def on_blur(self, event): self._complete_hide() def on_unmount(self, event): self._complete_hide() class TuiDialog(TuiPanel): """Centered modal panel with a button row. Esc closes.""" def __init__(self, *children, **kwargs): kwargs.setdefault("layer", "dialog") kwargs.setdefault("modal", True) kwargs.setdefault("border", True) kwargs.setdefault("x", "center") kwargs.setdefault("y", "center") self.on_close = kwargs.pop("on_close", None) self.closable = kwargs.pop("closable", True) super().__init__(**kwargs) if children: self.add(*children) def buttons(self, *specs): row = TuiCols(gap=2) for label, callback in specs: row.add(TuiButton(label, on_press=callback)) self.add(row) return row def on_key(self, event): if event.key == "escape": event.stop() self.close() def on_mount(self, event): if self.app is not None: self.app.remember_focus() self.app.focus_first(self) class TuiQuestion(TuiDialog): """Prompt plus clickable/keyboard options. Answers via on_done(value).""" def __init__(self, prompt="", options=(), on_done=None, multi=False, **kwargs): super().__init__(**kwargs) self.on_done = on_done self.prompt = self.add(TuiText(prompt, wrap=True)) self.picker = TuiOptions(options, multi=multi) self.add(self.picker) if multi: self.buttons(("OK", lambda _event: self._finish()), ("Cancel", lambda _event: self.close())) else: self.buttons(("Cancel", lambda _event: self.close())) def on_select(self, event): if event.panel is not self.picker or self.picker.multi: return event.stop() if callable(self.on_done): self.on_done(event.value) self.close() def _finish(self): values = [self.picker.options[pos][1] for pos in sorted(self.picker.chosen)] if callable(self.on_done): self.on_done(values) self.close() class TuiPromptDialog(TuiDialog): """Pretty prompt box: label, multiline editor, OK/Cancel. OK runs on_execute(value) then closes; Cancel and Esc just close.""" def __init__(self, label="Enter prompt:", box_h=3, on_execute=None, complete=None, mentions=None, **kwargs): kwargs.setdefault("title", "Prompt") kwargs.setdefault("w", 56) super().__init__(**kwargs) self.on_execute = on_execute self.add(TuiText(label, style=TuiStyle(dim=True))) self.box = TuiTextBox(h=box_h, style=TuiStyle(bg="black", fg="white"), complete=complete, mentions=mentions) self.add(self.box) self.buttons(("OK", lambda _event: self._run()), ("Cancel", lambda _event: self.close())) def _run(self): if callable(self.on_execute): self.on_execute(self.box.value) self.close() class TuiProgress(TuiPanel): """Fraction bar with percent label.""" def __init__(self, value=0.0, **kwargs): super().__init__(**kwargs) self.value = 0.0 self.set(value) def measure(self): return (20, 1) def set(self, value): self.value = max(0.0, min(1.0, float(value))) if self.app is not None: self.touch() def content_rows(self): width = self._content_w() filled = int(width * self.value) bar = "█" * filled + "░" * max(0, width - filled - 7) + " %3d%%" % int(self.value * 100) return [[TuiSpan(bar[:width].ljust(width), self.style)]] class TuiLog(TuiPanel): """Virtualized scrollback view. Wheel scrolls; appending follows the tail only while already pinned there. Lines are (text, spans) pairs: append() takes plain text plus one style, append_spans() takes pre-built multi-style runs (chat bubbles).""" shifts = True def __init__(self, **kwargs): kwargs.setdefault("scrollable", True) super().__init__(**kwargs) self.lines = [] def measure(self): width = 0 for text, _spans in self.lines[-500:]: width = max(width, len(text)) return (width, max(1, len(self.lines))) def _push(self, text, spans): pinned = self.scroll_y >= max(0, len(self.lines) - self._content_h()) self.lines.append((text, spans)) if pinned: self.scroll_y = max(0, len(self.lines) - self._content_h()) if self.app is not None: self.touch() def append(self, text, style=None): text = str(text) self._push(text, [TuiSpan(text, style or self.style)]) def append_spans(self, spans): spans = list(spans) or [TuiSpan("", self.style)] self._push("".join(span.text for span in spans), spans) def content_rows(self): """Every line, unscrolled: TuiPanel.paint applies scroll_x/scroll_y itself, so scrolling here too would shift the view twice.""" return [spans for _text, spans in self.lines] def paint_cells(self, compositor): """paint() in cell space. A line's converted row is kept between paints (looked up by the identity of its span list), so scrolling or repainting after a neighbor changed converts only lines it has not seen. Lines are append-only span lists, which is what makes that safe. A subclass that draws its own rows or content gets the plain path.""" kind = type(self) if kind.paint is not TuiPanel.paint or kind.content_rows is not TuiLog.content_rows or self.aw < self.chrome_w() + 2: return TuiPanel.paint_cells(self, compositor) return self._log_cells(compositor) def _log_cells(self, compositor): width = self._content_w() height = self._content_h() pad_top, pad_right, pad_bottom, pad_left = self.padding style = self.style key = style.key() blank = compositor.cell(" ", key) inner_w = width + pad_left + pad_right edge_left = [] edge_right = [] top = bottom = None if self.border: framed = self._add_border([[]], inner_w) top, bottom = compositor.cells_from([framed[0], framed[-1]], self.aw) bar_left, bar_right = compositor.cells_from([framed[1]], 2)[0] edge_left = [bar_left] edge_right = [bar_right] head = edge_left + [blank] * pad_left tail = [blank] * pad_right + edge_right pad_row = edge_left + [blank] * inner_w + edge_right empty = [blank] * width rows = [top] if top is not None else [] rows.extend([pad_row] * pad_top) memo = self.__dict__.get("_row_memo") if memo is None: memo = self._row_memo = {} lines = self.lines total = len(lines) scroll_x = self.scroll_x sig = (scroll_x, width, key) first = self.scroll_y found = [] missing = [] for index in range(first, min(first + height, total)): spans = lines[index][1] entry = memo.get(id(spans)) if entry is not None and entry[0] is spans and entry[1] == sig: found.append(entry[2]) else: found.append(None) missing.append(spans) if missing: if len(memo) + len(missing) > TUI_ROW_MEMO: memo.clear() converted = iter(compositor.cells_from([tui_slice_spans(spans, scroll_x, width, style) for spans in missing], width)) for pos, cells in enumerate(found): if cells is None: spans = lines[first + pos][1] cells = found[pos] = next(converted) memo[id(spans)] = (spans, sig, cells) for cells in found: rows.append(head + cells + tail) for _index in range(height - len(found)): rows.append(head + empty + tail) rows.extend([pad_row] * pad_bottom) if bottom is not None: rows.append(bottom) full = [blank] * self.aw while len(rows) < self.ah: rows.append(full) return rows[:self.ah] def on_scroll(self, event): event.stop() if event.direction == "up": self.scroll_by(0, -3) elif event.direction == "down": self.scroll_by(0, 3) def on_key(self, event): if event.key == "pageup": event.stop() self.scroll_by(0, -max(1, self._content_h())) elif event.key == "pagedown": event.stop() self.scroll_by(0, max(1, self._content_h())) elif event.key == "home": event.stop() self.scroll_y = 0 if self.app is not None: self.touch() elif event.key == "end": event.stop() self.scroll_y = max(0, len(self.lines) - self._content_h()) if self.app is not None: self.touch() TUI_TERM_KEYS = {"enter": b"\r", "tab": b"\t", "backtab": b"\x1b[Z", "backspace": b"\x7f", "escape": b"\x1b", "up": b"\x1b[A", "down": b"\x1b[B", "right": b"\x1b[C", "left": b"\x1b[D", "home": b"\x1b[H", "end": b"\x1b[F", "pageup": b"\x1b[5~", "pagedown": b"\x1b[6~", "delete": b"\x1b[3~", "insert": b"\x1b[2~", "f1": b"\x1bOP", "f2": b"\x1bOQ", "f3": b"\x1bOR", "f4": b"\x1bOS", "f5": b"\x1b[15~", "f6": b"\x1b[17~", "f7": b"\x1b[18~", "f8": b"\x1b[19~", "f9": b"\x1b[20~", "f10": b"\x1b[21~", "f11": b"\x1b[23~", "f12": b"\x1b[24~"} class TuiTerminal(TuiPanel): """A live sub-terminal: real pty child streamed through a VT screen.""" memoize_measure = False def __init__(self, cmd=None, interactive=True, **kwargs): kwargs.setdefault("focusable", interactive) kwargs.setdefault("border", True) super().__init__(**kwargs) self.cmd = cmd or [os.environ.get("SHELL") or "/bin/sh"] self.interactive = interactive self.screen = TuiVtScreen(24, 80) self.pid = None self.fd = None self.exit_status = None self.error = "" self._row_memo = {} self._pending = bytearray() def measure(self): return (self.screen.cols, self.screen.rows) def arrange(self, x, y, w, h, clip): super().arrange(x, y, w, h, clip) width = self._content_w() height = self._content_h() if width != self.screen.cols or height != self.screen.rows: self.screen.resize(max(1, height), max(1, width)) self._apply_winsize() def spawn(self): if self.pid is not None or pty is None: if pty is None: self.error = "pty not available on this platform" return False pid, fd = pty.fork() if pid == 0: env = dict(os.environ) env.setdefault("TERM", "xterm-256color") try: os.execvpe(self.cmd[0], self.cmd, env) except OSError as exc: sys.stderr.write("tui terminal: cannot exec %s: %s\n" % (self.cmd[0], exc)) os._exit(127) self.pid = pid self.fd = fd self._apply_winsize() return True def _apply_winsize(self): if self.fd is None or fcntl is None or termios is None: return try: fcntl.ioctl(self.fd, termios.TIOCSWINSZ, struct.pack("HHHH", self.screen.rows, self.screen.cols, 0, 0)) except OSError: pass def fds(self): if self.fd is not None and self.exit_status is None: return [self.fd] return [] def feed_output(self): if self.fd is None: return False try: data = os.read(self.fd, 65536) except OSError: data = b"" if not data: self.poll_exit() return False self.screen.feed(data) if self.app is not None: self.touch() return True def drain_pending(self): while self._pending and self.fd is not None and self.exit_status is None: try: written = os.write(self.fd, bytes(self._pending[:4096])) except OSError: break if written <= 0: break del self._pending[:written] def poll_exit(self): if self.pid is None or self.exit_status is not None: return self.exit_status try: done, status = os.waitpid(self.pid, os.WNOHANG) except OSError: done, status = 1, 0 if done: self.exit_status = status try: os.close(self.fd) except OSError: pass self.fd = None if self.app is not None: self.touch() return self.exit_status def terminate(self): if self.pid is None or self.exit_status is not None: return try: os.killpg(self.pid, signal.SIGKILL) except OSError: try: os.kill(self.pid, signal.SIGKILL) except OSError: pass try: _done, status = os.waitpid(self.pid, 0) self.exit_status = status except OSError: self.exit_status = 0 if self.fd is not None: try: os.close(self.fd) except OSError: pass self.fd = None def content_rows(self): width = self._content_w() height = self._content_h() if self.error: rows = [[TuiSpan(self.error[:width].ljust(width), self.style.merged(TuiStyle(fg="red")))]] while len(rows) < height: rows.append([TuiSpan(" " * width, self.style)]) return rows show_cursor = self.app is not None and self.app.focused is self and self.screen.cursor_visible and self.exit_status is None rows = [] memo = self._row_memo if len(memo) > TUI_ROW_MEMO: memo.clear() base_key = self.style.key() for row in range(min(height, self.screen.rows)): grid_row = self.screen.grid[row] cursor_x = self.screen.cx if show_cursor and row == self.screen.cy else -1 key = (tuple(grid_row[:width]), width, base_key, cursor_x) spans = memo.get(key) if spans is None: cells = [] for col in range(min(width, self.screen.cols)): cell = grid_row[col] char = cell[0] if cell else " " style = TuiVtScreen.style_for(cell, self.style) if col == cursor_x: style = style.merged(TuiStyle(reverse=True)) cells.append((char, style)) while len(cells) < width: cells.append((" ", self.style)) spans = memo[key] = tui_runs(cells) rows.append(spans) while len(rows) < height: rows.append([TuiSpan(" " * width, self.style)]) return rows def on_click(self, event): if not event.pressed or not self.interactive: return event.stop() self.focus() def on_key(self, event): if not self.interactive or self.fd is None or self.exit_status is not None: return data = TuiTerminal.key_bytes(event) if data: event.stop() self._pending += data def send_text(self, text): self._pending += text.encode("utf-8", "replace") def on_paste(self, event): if not self.interactive or self.exit_status is not None: return event.stop() self._pending += (event.text or "").encode("utf-8", "replace") @staticmethod def key_bytes(event): key = event.key or "" if key in TUI_TERM_KEYS and not getattr(event, "modifiers", None): return TUI_TERM_KEYS[key] if key.startswith("ctrl+") and len(key) == 6: return bytes([ord(key[5:].upper()) - 0x40]) if key.startswith("alt+") and len(event.char or "") == 1: return b"\x1b" + event.char.encode("utf-8", "replace") if "+" in key: mods, _, base = key.rpartition("+") finals = {"up": "A", "down": "B", "right": "C", "left": "D", "home": "H", "end": "F"} if base in finals: code = 1 + ("shift" in mods) + ("alt" in mods) * 2 + ("ctrl" in mods) * 4 return ("\x1b[1;%d%s" % (code, finals[base])).encode("utf-8") return b"" if event.char: return event.char.encode("utf-8", "replace") return b"" class TuiError(Exception): pass class TuiDoubleTap: """Two same-key presses inside a window. Clock is injected (now) so tests stay deterministic; the app passes time.time().""" def __init__(self, timeout=0.4): self.timeout = timeout self.last_key = None self.last_at = 0.0 def hit(self, key, now): if key == self.last_key and (now - self.last_at) <= self.timeout: self.last_key = None self.last_at = 0.0 return True self.last_key = key self.last_at = now return False class TuiApp: """Owns the screen: terminal setup, tick loop, focus, timers, routing. One asyncio loop: stdin and sub-terminal ptys are loop readers/writers that dispatch as bytes arrive, a frame clock (1/fps) advances timers and animations, relayouts on demand, composites and writes one frame. Dirty flags skip idle frames entirely. Blocking work goes through offload(): it runs on its own thread and calls back on the loop, so the UI never freezes. Ctrl+C always quits (terminal state restores in a finally).""" def __init__(self, root=None, fps=TUI_FPS): self.root = root or TuiPanel(flow=None, style=TuiStyle(bg="black")) self.fps = max(1, tui_int(fps, TUI_FPS)) self.rows = 24 self.cols = 80 self.focused = None self._focus_memory = [] self.double_tap = TuiDoubleTap() self.on_double_tap = None self.compositor = TuiCompositor() self.hitmap = TuiHitMap() self.input = TuiInput() self.animations = [] self.timers = [] self._timer_seq = 0 self._need_layout = True self._need_paint = True self._lock = threading.Lock() self._drop_all = True self._damage = set() self._stack = [] self._layout_full = True self._layout_starts = [] self._watch = set() self._shifts = [] self._restack = False self._geom_rects = [] self._geom_full = True self.layouts = {"full": 0, "partial": 0, "arranged": 0} self._tree_version = 0 self._last_cursor = None self.verify = bool(os.environ.get("TAI_TUI_VERIFY")) self.verified = 0 self._running = False self._resize_pending = False self._saved_termios = None self._old_winch = None self._old_chld = None self._chld_set = False self._loop = None self._failure = None self._input_seen = False self._last_frame = 0.0 self._stdout_fd = None self._stdin_fd = None self._wake = None self._loop_thread = None self._tickers = [] self._tickers_version = -1 self._terms = [] self._terms_version = -1 self.iterations = 0 self._readers = {} self._writers = {} self._eof = set() self._workers = [] # -- structure --------------------------------------------------- # def mount(self, root=None): if root is not None: self.root = root self.root._bind_app(self) self.focused = None self._layout_full = True self._stack = [] self.layout() self.auto_focus() self.dirty_paint() return self.root def dirty_layout(self, paint=True): """Anonymous "geometry or structure changed somewhere": the next layout arranges the whole tree. paint=True (the default, for callers that also edited state without saying so) also drops every cached panel; paint=False trusts the panels' own touch() and lets the layout pass diff rects. Precise callers use relayout().""" with self._lock: self._need_layout = True self._need_paint = True self._layout_full = True if paint: self._drop_all = True self.wake() LAYOUT_STARTS = 64 def relayout(self, panel, inner=False, restack=False, only=False): """Thread-safe and precise: only this panel's subtree is arranged again. inner=False means the panel's own size, position or chrome changed, so its parent lays it out again; inner=True means its children (or content) changed, so it lays them out again. Either way the work climbs only while the panel's size follows its content (content_sized), and stops at the first fixed-size panel. restack marks a panel added, removed, shown or hidden. only=True arranges just this panel again, in the box it already has (its scroll offsets changed, so they need clamping).""" with self._lock: self._need_layout = True self._need_paint = True if len(self._layout_starts) >= TuiApp.LAYOUT_STARTS: self._layout_full = True else: self._layout_starts.append((panel, "self" if only else "inner" if inner else "own")) panel.mark_pending() if restack: self._restack = True self._tree_version += 1 self.wake() def run_on_loop(self, call): """Run call() on the loop thread: now when already there (or when no loop runs, as in headless tests), else queued thread-safely.""" loop = self._loop if loop is None or threading.get_ident() == self._loop_thread: call() return try: loop.call_soon_threadsafe(call) except RuntimeError: pass def tree_changed(self): """A panel gained or lost an on_tick handler: the cached ticker and terminal lists are stale.""" with self._lock: self._tree_version += 1 def restack(self): """Paint order changed (z or layer) without any geometry change.""" with self._lock: self._need_layout = True self._need_paint = True self._restack = True self.wake() def dirty_paint(self): """Anonymous "something changed": repaint every panel. Precise callers use panel.touch() and only that panel's rect repaints.""" with self._lock: self._need_paint = True self._drop_all = True self.wake() def damage(self, panel): """Thread-safe: turn threads touch panels while the loop composes.""" with self._lock: self._damage.add(panel) self._need_paint = True if panel.w == "auto" or panel.h == "auto": self._watch.add(panel) self._need_layout = True self.wake() def wake(self): """Thread-safe: end the main loop's sleep early. The loop sleeps until its next timer, animation frame or event, so anything that makes a frame due (a touch, a timer, a thread's result) calls this.""" loop = self._loop if loop is None or self._wake is None: return if threading.get_ident() == self._loop_thread: self._wake.set() else: try: loop.call_soon_threadsafe(self._wake.set) except RuntimeError: pass def set_theme(self, name): """Switch the global theme and repaint every cell. Cells are diffed by style key (role names), which does not change with the theme, so the compositor must forget what the terminal shows. Raises ValueError naming the available themes when unknown.""" tui_set_theme(name) self.compositor.invalidate() self.dirty_paint() return tui_theme() def after(self, delay, callback): return self._schedule(float(delay), None, callback) def every(self, interval, callback): return self._schedule(float(interval), float(interval), callback) def cancel_timer(self, handle): self.timers = [entry for entry in self.timers if entry is not handle] def _schedule(self, delay, interval, callback): self._timer_seq += 1 handle = [time.time() + max(0.0, delay), interval, callback, self._timer_seq] self.timers.append(handle) self.wake() return handle def _fire_timers(self, now): for handle in list(self.timers): deadline, interval, callback, _seq = handle if now < deadline: continue try: callback() except Exception: pass if interval is None: if handle in self.timers: self.timers.remove(handle) else: handle[0] = now + interval # -- focus ------------------------------------------------------- # def top_modal(self): best = None for node in self.root.walk(): if node.modal and node.is_shown() and node.enabled: if best is None or (node.effective_z(), node.seq) > (best.effective_z(), best.seq): best = node return best def focusables(self, scope=None): modal = self.top_modal() base = modal if modal is not None else (scope or self.root) found = [node for node in base.walk() if node.focusable and node.enabled and node.is_shown()] found.sort(key=lambda node: (node.ay, node.ax, node.seq)) return found def set_focus(self, panel): if panel is not None and (not panel.focusable or not panel.enabled or not panel.is_shown()): return modal = self.top_modal() if modal is not None and panel is not None: node = panel inside = False while node is not None: if node is modal: inside = True break node = node.parent if not inside: return if self.focused is panel: return old = self.focused self.focused = panel if old is not None: old.emit(TuiBlur(panel=old)) old.touch() if panel is not None: panel.emit(TuiFocus(panel=panel)) panel.touch() self._need_paint = True def focus_first(self, scope=None): found = self.focusables(scope) if found: self.set_focus(found[0]) def auto_focus(self): found = self.focusables() for node in found: if not isinstance(node, TuiTerminal): self.set_focus(node) return if found: self.set_focus(found[0]) def remember_focus(self): self._focus_memory.append(self.focused) def _restore_focus(self): while self._focus_memory: prev = self._focus_memory.pop() if (prev is not None and prev.focusable and prev.enabled and prev.is_shown() and prev.root() is self.root): self.set_focus(prev) if self.focused is prev: return self.auto_focus() def focus_next(self): found = self.focusables() if not found: return if self.focused in found: self.set_focus(found[(found.index(self.focused) + 1) % len(found)]) else: self.set_focus(found[0]) def focus_prev(self): found = self.focusables() if not found: return if self.focused in found: self.set_focus(found[(found.index(self.focused) - 1) % len(found)]) else: self.set_focus(found[-1]) def ensure_focus(self): node = self.focused if (node is None or node.root() is not self.root or not node.is_shown() or not node.enabled or not node.focusable): self.focused = None self.auto_focus() def close_panel(self, panel): panel.emit(TuiClose(panel=panel)) if panel.parent is not None: panel.parent.remove(panel) node = self.focused if node is not None and (node is panel or node.root() is not self.root or not node.is_shown()): self.focused = None self._restore_focus() # -- layout + paint ---------------------------------------------- # def layout(self): """Arrange, restack, and work out what the pass changed. Only the subtrees that asked (relayout) are arranged again, each from the nearest ancestor whose size does not follow its content; an anonymous dirty_layout, a resize or the first pass arranges everything. Moved or resized panels damage only their old and new rects, and a stack that gained or lost panels damages those panels' rects; a stack whose remaining panels changed order is the whole screen.""" with self._lock: self._need_layout = False full, self._layout_full = self._layout_full, False restack, self._restack = self._restack, False starts, self._layout_starts = self._layout_starts, [] watch, self._watch = self._watch, set() touched = list(self._damage) for panel in watch: seen = panel._measure_seen size = panel.measure() if size != seen and panel.root() is self.root: outer = seen is None or (panel.w == "auto" and size[0] != seen[0]) or (panel.h == "auto" and size[1] != seen[1]) starts.append((panel, "inner" if outer else "self")) everything = full or not self._stack roots = [self.root] if everything else self._layout_roots(starts) if roots is None: everything, roots = True, [self.root] TuiPanel._measure_memo = {} arranged = [] if not everything: for panel in [item[0] for item in starts] + touched: panel.mark_pending() TuiPanel._stale = True TuiPanel._arranged = arranged try: for node in roots: if everything: node.arrange(0, 0, self.cols, self.rows, (0, 0, self.cols, self.rows)) else: node.arrange(*node._arrange) finally: TuiPanel._measure_memo = None TuiPanel._stale = None TuiPanel._arranged = None self.layouts["full" if everything else "partial"] += 1 if self.verify and not everything: self._verify_layout() if everything or restack or self._geometry_changed_membership(arranged): self._restack_diff() else: self._geometry_diff(arranged) self.hitmap.rebuild(self._stack) def _layout_roots(self, starts): """The panels to arrange again for these requests, outermost only, or None when the request cannot be narrowed (arrange everything).""" found = [] for panel, mode in starts: if panel.root() is not self.root: continue node = panel.parent if mode == "own" else panel if node is None: return None while mode != "self" and node.content_sized(): node = node.parent while node._arrange is None and node.parent is not None: node = node.parent if node._arrange is None: return None if node.is_shown(): found.append(node) keep = set(found) roots = [] for node in found: above = node.parent while above is not None and above not in keep: above = above.parent if above is None and node not in roots: roots.append(node) return roots def _geometry_changed_membership(self, arranged): """True when a re-arranged panel entered or left the stack (it became zero-sized, or was zero-sized and grew).""" for node in arranged: listed = node._geom is not None if listed != (node.aw > 0 and node.ah > 0): return True return False def _geometry_diff(self, arranged): """Same stack: damage the old and new rects of the moved or resized panels among the panels this pass arranged.""" rects = self._geom_rects for node in arranged: geom = (node.ax, node.ay, node.aw, node.ah, node.clip) was = node._geom self.layouts["arranged"] += 1 if was is None or was == geom: continue rects.append(was[4]) rects.append(geom[4]) if was[2:4] != geom[2:4]: node._cells = None node._geom = geom def _restack_diff(self): """The stack may have changed: rebuild it and damage what differs.""" old = self._stack stack = self.compositor.flatten(self.root) self._tree_version += 1 rects = self._geom_rects if not old: self._geom_full = True elif stack != old: now = set(stack) before = set(old) if [node for node in old if node in now] != [node for node in stack if node in before]: self._geom_full = True else: for node in old: if node not in now: rects.append(node._geom[4]) node._geom = None for node in stack: geom = (node.ax, node.ay, node.aw, node.ah, node.clip) was = node._geom if was is None: rects.append(geom[4]) elif was != geom: rects.append(was[4]) rects.append(geom[4]) if was[2:4] != geom[2:4]: node._cells = None node._geom = geom self.layouts["arranged"] += len(stack) self._stack = stack def _verify_layout(self): """Oracle check: arranging the whole tree again must not move any panel the partial pass left alone. Raises TuiError naming the first.""" def snapshot(): return [(node, node.ax, node.ay, node.aw, node.ah, node.clip, node.scroll_x, node.scroll_y) for node in self.root.walk() if node._arrange is not None] before = snapshot() TuiPanel._measure_memo = {} try: self.root.arrange(0, 0, self.cols, self.rows, (0, 0, self.cols, self.rows)) finally: TuiPanel._measure_memo = None after = {id(row[0]): row for row in snapshot()} for row in before: want = after.get(id(row[0])) if want is not None and want[1:] != row[1:]: raise TuiError("partial layout diverged from full layout for %s id=%r: partial %r full %r" % ( type(row[0]).__name__, row[0].id, row[1:], want[1:])) def _damage_rects(self): """Rects to recompose for this frame, or None for the whole screen. Consumes the pending damage.""" with self._lock: self._need_paint = False drop = self._drop_all touched, self._damage = self._damage, set() self._drop_all = False rects, self._geom_rects = self._geom_rects, [] full = drop or self._geom_full self._geom_full = False self._shifts = [] if drop: for node in self.root.walk(): node._cells = None node._shift = 0 if full: for panel in touched: panel._shift = 0 return None shown = set(self._stack) for panel in touched: if panel in shown: rects.append(panel.clip) if panel._shift: self._shifts.append(panel.content_rect() + (panel._shift,)) panel._shift = 0 if len(rects) > TUI_DAMAGE_RECTS: self._shifts = [] return None return rects def frame_bytes(self): """The bytes that bring the terminal up to date; "" when nothing on screen changed (no cell differs and the cursor stayed).""" if self._need_layout: self.layout() compositor = self.compositor fresh = compositor.last is None rects = self._damage_rects() body = compositor.refresh(self._stack, self.rows, self.cols, rects, self._shifts if rects is not None else ()) cursor = self._cursor_bytes() if self.verify: self._verify_frame() if not body and not fresh and cursor == self._last_cursor: return "" self._last_cursor = cursor return TUI_SYNC_START + "\x1b[?25l" + body + "\x1b[0m" + cursor + TUI_SYNC_END def _verify_frame(self): """Oracle check: the lazy screen must equal a from-scratch recompose by the reference algorithm. Raises TuiError naming the first cell.""" want = self.compositor.compose_reference(self.root, self.rows, self.cols) have = self.compositor.last self.verified += 1 for y, (row_want, row_have) in enumerate(zip(want, have)): if row_want != row_have: x = next(pos for pos, (a, b) in enumerate(zip(row_want, row_have)) if a != b) owner = [node for node in self._stack if node.ax <= x < node.ax + node.aw and node.ay <= y < node.ay + node.ah] raise TuiError("lazy frame diverged from reference at x=%d y=%d: want %r have %r (top panel %s)" % ( x, y, row_want[x], row_have[x], type(owner[-1]).__name__ if owner else "none")) def _cursor_bytes(self): node = self.focused if isinstance(node, TuiTerminal) and node.exit_status is None and node.screen.cursor_visible and node.is_shown(): edge = 1 if node.border else 0 pad_top, _right, _bottom, pad_left = node.padding x = node.ax + edge + pad_left + node.screen.cx y = node.ay + edge + pad_top + node.screen.cy x0, y0, x1, y1 = node.clip if x0 <= x < x1 and y0 <= y < y1: return TuiCompositor.place_cursor(x, y, True) return TuiCompositor.place_cursor(0, 0, False) def render_text(self): if self._need_layout: self.layout() return TuiCompositor.render_text(self.compositor.compose(self.root, self.rows, self.cols)) # -- events ------------------------------------------------------ # def dispatch(self, event): if isinstance(event, TuiKey): if event.key == "ctrl+c": self.quit() return if self.on_double_tap is not None and self.double_tap.hit(event.key, time.time()): if self.on_double_tap(event): return if event.key == "tab": self.focus_next() return if event.key == "backtab": self.focus_prev() return if self.focused is not None: self.focused.emit(event) else: self.root.emit(event) return if isinstance(event, TuiModeReply): if event.mode == TUI_DECLRMM and event.state in (1, 2): self.compositor.margins = True return if isinstance(event, TuiClick): self._dispatch_click(event) return if isinstance(event, TuiScroll): self._dispatch_scroll(event) return if isinstance(event, TuiPaste): if self.focused is not None: self.focused.emit(event) else: self.root.emit(event) return self.root.emit(event) def _modal_blocks(self, panel): modal = self.top_modal() if modal is None or panel is None: return modal is not None and panel is None node = panel while node is not None: if node is modal: return False node = node.parent return True def _dispatch_click(self, event): hit = self.hitmap.at(event.x, event.y) if hit is None or self._modal_blocks(hit): return if event.pressed: node = hit while node is not None and not (node.focusable and node.enabled and node.is_shown()): node = node.parent if node is not None: self.set_focus(node) hit.emit(event) def _dispatch_scroll(self, event): hit = self.hitmap.at(event.x, event.y) if hit is None or self._modal_blocks(hit): return hit.emit(event) # -- animations -------------------------------------------------- # def tick_animations(self, now): if not self.animations: return for anim in list(self.animations): panel = anim["panel"] if anim["started"] is None: anim["started"] = now anim["from"] = panel.anim_start(anim["prop"]) elapsed = now - anim["started"] - anim["delay"] if elapsed < 0: continue progress = min(1.0, elapsed / anim["duration"]) value = anim["from"] + (anim["to"] - anim["from"]) * tui_ease(anim["easing"], progress) if anim["prop"] in TUI_GEOMETRY: value = int(round(value)) try: setattr(panel, anim["prop"], value) except (AttributeError, TypeError, ValueError): pass if progress >= 1.0: self.animations.remove(anim) if callable(anim["on_done"]): try: anim["on_done"](panel) except Exception: pass def tickers(self): """Panels with an on_tick handler, found once per tree change.""" if self._tickers_version != self._tree_version: self._tickers = [node for node in self.root.walk() if callable(getattr(node, "on_tick", None))] self._tickers_version = self._tree_version return self._tickers def tick_widgets(self, dt, now): for node in self.tickers(): handler = getattr(node, "on_tick", None) if callable(handler): try: handler(TuiTick(panel=node, dt=dt, now=now)) except Exception: pass # -- terminal ---------------------------------------------------- # def _winsize(self): rows, cols = 24, 80 if fcntl is not None and termios is not None: try: packed = fcntl.ioctl(self._stdout_fd if self._stdout_fd is not None else sys.stdout.fileno(), termios.TIOCGWINSZ, b"\0" * 8) rows, cols = struct.unpack("HH", packed[:4]) except OSError: pass return (max(TUI_MIN_ROWS, rows or 0), max(TUI_MIN_COLS, cols or 0)) def _terminals(self): """The live terminal panels, found once per tree change.""" if self._terms_version != self._tree_version: self._terms = [node for node in self.root.walk() if isinstance(node, TuiTerminal)] self._terms_version = self._tree_version return self._terms def _on_winch(self, _signum, _frame): self._resize_pending = True self._signal_wake() def _on_chld(self, signum, frame): """A child exited: wake the loop so a terminal notices its exit status even when a grandchild still holds the pty open.""" self._signal_wake() previous = self._old_chld if callable(previous): previous(signum, frame) def _signal_wake(self): loop, wake = self._loop, self._wake if loop is not None and wake is not None: try: loop.call_soon_threadsafe(wake.set) except RuntimeError: pass def _setup(self): if select is None or termios is None or tty is None: raise TuiError("tui needs select/termios/tty (POSIX terminal)") if not sys.stdin.isatty() or not sys.stdout.isatty(): raise TuiError("tui needs an interactive terminal") self.rows, self.cols = self._winsize() self._stdout_fd = sys.stdout.fileno() fd = sys.stdin.fileno() self._saved_termios = termios.tcgetattr(fd) tty.setraw(fd) if hasattr(signal, "SIGWINCH"): try: self._old_winch = signal.signal(signal.SIGWINCH, self._on_winch) except (ValueError, OSError): self._old_winch = None if hasattr(signal, "SIGCHLD"): try: self._old_chld = signal.signal(signal.SIGCHLD, self._on_chld) self._chld_set = True except (ValueError, OSError): self._old_chld = None out = sys.stdout.fileno() os.write(out, (TUI_ALT_ENTER + TUI_MOUSE_ENABLE + TUI_KITTY_ENABLE + TUI_PASTE_ENABLE + "\x1b[?25l" + "\x1b[?%d$p" % TUI_DECLRMM).encode("utf-8")) def _teardown(self): try: out = self._stdout_fd if self._stdout_fd is not None else sys.stdout.fileno() os.write(out, (TUI_PASTE_DISABLE + TUI_KITTY_DISABLE + TUI_MOUSE_DISABLE + TUI_ALT_LEAVE + "\x1b[?25h").encode("utf-8")) except OSError: pass if self._old_winch is not None: try: signal.signal(signal.SIGWINCH, self._old_winch) except (ValueError, OSError): pass if self._chld_set: self._chld_set = False try: signal.signal(signal.SIGCHLD, self._old_chld if self._old_chld is not None else signal.SIG_DFL) except (ValueError, OSError): pass if self._saved_termios is not None and termios is not None: try: termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, self._saved_termios) except (termios.error, OSError): pass for term in self._terminals(): try: term.terminate() except Exception: pass def quit(self): self._running = False self.wake() @staticmethod def write_all(fd, data): view = memoryview(data) while view: written = os.write(fd, view) view = view[written:] def run(self): self._setup() try: asyncio.run(self._main()) finally: self._teardown() if self._failure is not None: failure, self._failure = self._failure, None raise failure return 0 def offload(self, work, done): """Run work() on its own thread and call done(result, error) back on the loop thread. Without a running loop (headless tests) both run inline, so callers never branch.""" loop = self._loop if loop is None: try: result, error = work(), None except Exception as exc: result, error = None, exc done(result, error) return def finish(result, error): try: done(result, error) finally: self.dirty_paint() def body(): try: result, error = work(), None except BaseException as exc: result, error = None, exc try: loop.call_soon_threadsafe(finish, result, error) except RuntimeError: pass worker = threading.Thread(target=body, name="tui-offload", daemon=True) self._workers = [item for item in self._workers if item.is_alive()] + [worker] worker.start() def _on_stdin(self, fd): try: data = os.read(fd, 65536) except BlockingIOError: return except OSError: data = b"" if not data: self._loop.remove_reader(fd) self._running = False self.wake() return self._input_seen = True for event in self.input.feed(data): self.dispatch(event) if time.time() - self._last_frame >= TUI_INPUT_PAINT_GAP: self._paint_frame() if self.input.buf or self._need_paint or self._need_layout: self.wake() def _paint_frame(self): """Write one frame if anything is dirty. False when stdout is gone.""" if not (self._need_paint or self._need_layout): return True try: data = self.frame_bytes().encode("utf-8") if data: TuiApp.write_all(self._stdout_fd, data) except OSError: self._running = False return False self._last_frame = time.time() return True def _on_term_readable(self, fd, term): if not term.feed_output(): self._eof.add(fd) if self._readers.pop(fd, None) is not None: self._loop.remove_reader(fd) self.wake() def _on_term_writable(self, term): term.drain_pending() self.wake() def _sync_terminals(self): """Make loop readers/writers match the live terminals: add fds of new ptys, drop closed ones, hold a writer only while input is queued.""" loop = self._loop want_r = {} want_w = {} for term in self._terminals(): term.poll_exit() for fd in term.fds(): if fd not in self._eof: want_r[fd] = term if term.fd is not None and term._pending and term.exit_status is None: want_w[term.fd] = term self._eof &= {fd for term in self._terminals() for fd in term.fds()} for fd in [fd for fd in self._readers if fd not in want_r]: loop.remove_reader(fd) del self._readers[fd] for fd, term in want_r.items(): if fd not in self._readers: loop.add_reader(fd, self._on_term_readable, fd, term) self._readers[fd] = term for fd in [fd for fd in self._writers if fd not in want_w]: loop.remove_writer(fd) del self._writers[fd] for fd, term in want_w.items(): if fd not in self._writers: loop.add_writer(fd, self._on_term_writable, term) self._writers[fd] = term def _loop_failed(self, _loop, context): """An exception in a loop callback must not print over the screen: keep it, stop the loop, and re-raise it after the terminal restores.""" self._failure = context.get("exception") or RuntimeError(context.get("message", "tui loop error")) self._running = False self.wake() async def _main(self): """The event loop. It sleeps until something is due: the next timer, the next animation or ticker frame, a pending partial key sequence, or a wake() (a touch from any thread, a pty, a signal). A quiet screen costs no wakeups at all, and paints are spaced at least one frame apart.""" loop = asyncio.get_running_loop() self._loop = loop self._wake = asyncio.Event() self._loop_thread = threading.get_ident() loop.set_exception_handler(self._loop_failed) stdin_fd = self._stdin_fd if self._stdin_fd is not None else sys.stdin.fileno() self.dirty_layout() self._running = True loop.add_reader(stdin_fd, self._on_stdin, stdin_fd) frame = 1.0 / self.fps last = time.time() try: while self._running: self._wake.clear() self.iterations += 1 started = time.time() self._fire_timers(started) self._sync_terminals() if self._resize_pending: self._resize_pending = False self.rows, self.cols = self._winsize() self.compositor.invalidate() self.dirty_layout() self.root.emit(TuiResize(panel=self.root, rows=self.rows, cols=self.cols)) if not self._input_seen and self.input.buf: for event in self.input.flush(): self.dispatch(event) self._input_seen = False tick_now = time.time() self.tick_animations(tick_now) if self.tickers(): self.tick_widgets(tick_now - last, tick_now) last = tick_now self.ensure_focus() wait = self._last_frame + frame - time.time() if (self._need_paint or self._need_layout) and wait > 0: await asyncio.sleep(wait) if not self._paint_frame(): break await self._sleep(self._idle_delay(frame, started)) finally: for fd in list(self._readers): loop.remove_reader(fd) for fd in list(self._writers): loop.remove_writer(fd) self._readers.clear() self._writers.clear() try: loop.remove_reader(stdin_fd) except Exception: pass self._loop = None self._wake = None def _idle_delay(self, frame, started): """Seconds the loop may sleep: zero when a frame is already due, one frame while something animates or ticks or a key sequence is half read, else until the next timer, or None (until a wake) with no timer.""" now = time.time() if self._need_paint or self._need_layout or self._resize_pending: return max(0.0, self._last_frame + frame - now) if self.animations or self.tickers() or self.input.buf: return max(0.0, frame - (now - started)) if not self.timers: return None return max(0.0, min(deadline for deadline, _i, _c, _s in self.timers) - now) async def _sleep(self, delay): if delay is not None and delay <= 0: await asyncio.sleep(0) return try: await asyncio.wait_for(self._wake.wait(), delay) except asyncio.TimeoutError: pass def tui_build_demo(app, live=True): """The /demo scene: every widget, overlapping layers, timers, modals. live=False builds the identical tree with canned terminal bytes for headless --demo-shot instead of spawning real ptys. Returns refs.""" refs = {"live": live, "banner_on": True, "term_count": 0, "log_count": 0} root = TuiPanel(flow=None, style=TuiStyle(bg="black"), id="root") header = TuiText(" tai TUI demo -- layers, mouse, focus, animation", dock="top", h=1, style=TuiStyle(bg="blue", fg="white", bold=True), id="header") footer = TuiText(" tab/shift+tab focus - click options - q quit - n terminal - c close - F1 ask - 2xshift prompt - ctrl+c quit", dock="bottom", h=1, style=TuiStyle(dim=True), id="footer") root.add(header, footer) sidebar = TuiPanel(dock="left", w=28, border=True, title="Actions", id="sidebar", gap=1, padding=1) actions = TuiOptions(["Open question dialog", "Spawn floating terminal", "Toggle banner", "Close top overlay", "Quit demo"], id="actions") sidebar.add(TuiText("Pick an action,", style=TuiStyle(dim=True)), actions) root.add(sidebar) stage = TuiPanel(x=0, y=0, w="100%", h="100%", flow=None, id="stage") root.add(stage) shell = TuiTerminal(title="shell (interactive, click to type)", x=0, y=0, w="100%", h="100%", id="shell") stage.add(shell) refs["shell"] = shell stream = TuiTerminal(cmd=["sh", "-c", "i=0; while true; do i=$((i+1)); echo \"stream tick $i\"; sleep 1; done"], title="stream (read-only)", x="52%", y="6%", w="46%", h="42%", z=5, interactive=False, id="stream") stream.closable = True stage.add(stream) refs["stream"] = stream banner = TuiText(" ** overlapping layers - dialogs - terminals - options - animation ** ", x=10, y=1, z=6, style=TuiStyle(bg="magenta", fg="white", bold=True), id="banner") stage.add(banner) refs["banner"] = banner prog_box = TuiPanel(title="Progress", x=2, y="54%", w=36, h=8, z=5, border=True, id="progress", gap=0, padding=(0, 1)) prog_a = TuiProgress(0.35, id="prog_a") prog_b = TuiProgress(0.7, id="prog_b") prog_box.add(TuiText("download", style=TuiStyle(dim=True)), prog_a, TuiText("upload", style=TuiStyle(dim=True)), prog_b) prog_box.closable = True stage.add(prog_box) refs["prog_a"] = prog_a refs["prog_b"] = prog_b log = TuiLog(title="Events (wheel to scroll)", x=40, y="54%", w="42%", h=7, z=5, border=True, id="log") log.closable = True log.append("demo started", TuiStyle(fg="green")) log.append("click the sidebar, or press F1", TuiStyle(dim=True)) log.append("tab cycles focus, shift+tab goes back", TuiStyle(dim=True)) stage.add(log) refs["log"] = log def toast(text): note = TuiText(" " + text + " ", layer="toast", x=-2, y=1, style=TuiStyle(bg="green", fg="black", bold=True)) note.closable = True root.add(note) app.after(3.0, lambda: root.remove(note) if note.parent is not None else None) log.append(text) def ask_question(): def answered(value): toast("you picked: %s" % value) root.add(TuiQuestion("Pick a theme for the demo:", ["Ocean", "Forest", "Sunset", "Mono"], on_done=answered, title="Question", w=44, id="welcome" if refs.get("first_ask", True) else None)) refs["first_ask"] = False def open_prompt(): if root.find("prompt") is not None: return True def execute(text): shell.send_text(text + "\n") first = text.split("\n")[0][:40] or "(empty)" toast("executing: %s" % first) log.append("prompt: %s" % text.split("\n")[0][:60]) root.add(TuiPromptDialog(on_execute=execute, id="prompt", complete=tui_complete_command, mentions=tui_complete_mention, label="Enter prompt (/ commands, @ mentions):")) return True def on_double_tap(event): if event.key == "shift": return open_prompt() return False app.on_double_tap = on_double_tap def spawn_terminal(): refs["term_count"] += 1 nth = refs["term_count"] term = TuiTerminal(title="shell #%d" % (nth + 1), x=6 + nth * 4, y=3 + nth * 2, w=54, h=14, z=5, id="term%d" % nth) term.closable = True stage.add(term) if live: term.spawn() else: term.screen.feed(b"$ \r\n") term.focus() log.append("spawned terminal #%d" % (nth + 1)) return term def close_top(): best = None for node in root.walk(): if getattr(node, "closable", False) and node.is_shown() and node.enabled: key = (node.effective_z(), node.seq) if best is None or key > (best.effective_z(), best.seq): best = node if best is not None: log.append("closed %s" % (best.title or best.id or "panel")) app.close_panel(best) def on_action(event): if event.index == 0: ask_question() elif event.index == 1: spawn_terminal() elif event.index == 2: refs["banner_on"] = not refs["banner_on"] banner.visible = refs["banner_on"] elif event.index == 3: close_top() elif event.index == 4: app.quit() actions.on_select = on_action def on_root_key(event): if event.key == "q": event.stop() app.quit() elif event.key == "n": event.stop() spawn_terminal() elif event.key == "c": event.stop() close_top() elif event.key == "f1": event.stop() ask_question() root.on_key = on_root_key refs["ask"] = ask_question refs["spawn"] = spawn_terminal refs["close_top"] = close_top if live: def tick_clock(): header.text = " tai TUI demo -- %s" % now_iso() def tick_progress(): prog_a.set((prog_a.value + 0.02) % 1.0) prog_b.set((prog_b.value + 0.013) % 1.0) def tick_banner(): if not refs["banner_on"]: return room = max(1, stage.aw - 68) banner.x = 1 + int(time.time() * 14) % room def heartbeat(): refs["log_count"] += 1 log.append("heartbeat %d" % refs["log_count"], TuiStyle(dim=True)) app.every(1.0, tick_clock) app.every(0.12, tick_progress) app.every(0.06, tick_banner) app.every(5.0, heartbeat) ask_question() return root, refs def tui_demo(): app = TuiApp() try: root, _refs = tui_build_demo(app, live=True) app.mount(root) for term in app._terminals(): term.spawn() return app.run() except TuiError as exc: print("demo unavailable: %s" % exc, file=sys.stderr) return 2 def tui_demo_shot(rows=30, cols=100): app = TuiApp() app.rows = rows app.cols = cols root, refs = tui_build_demo(app, live=False) app.mount(root) app.close_panel(root.find("welcome")) refs["shell"].screen.feed(b"$ echo hello\r\nhello\r\n$ ") refs["stream"].screen.feed(b"stream tick 1\r\nstream tick 2\r\nstream tick 3\r\n") text = app.render_text() print(text) return 0 TUI_CHAT_SIDEBAR_MIN = 24 TUI_CHAT_SIDEBAR_MAX = 36 TUI_CHAT_NARROW_COLS = 80 TUI_CHAT_INPUT_CAP = 2000 TUI_CHAT_HISTORY = 40 TUI_CHAT_LINE_CAP = 500 TUI_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") def tui_strip_ansi(text): return TUI_ANSI_RE.sub("", text or "") def tui_chat_sidebar_w(cols): return max(TUI_CHAT_SIDEBAR_MIN, min(TUI_CHAT_SIDEBAR_MAX, int(cols * 0.3))) TUI_CHAT_BUBBLE_MAX = 0.7 TUI_CHAT_SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" TUI_CHAT_ONLINE_GLYPH = "●" TUI_CHAT_TYPE_ANSWER = object() TUI_CHAT_PROMPT_TIMEOUT = 600 TUI_CHAT_PROMPT_CHARS = 600 TUI_CHAT_REVEAL_TICK = 0.04 TUI_CHAT_REVEAL_STEPS = 40 TUI_CHAT_TICK_SEND = "◷" TUI_CHAT_TICK_SENT = "✓" TUI_CHAT_TICK_READ = "✓✓" TUI_MD_INLINE_RE = re.compile(r"(\*\*[^*\n]+\*\*|__[^_\n]+__|`[^`\n]+`|(?<![\w*])\*[^*\s][^*\n]*?\*(?![\w*]))") TUI_MD_LIST_RE = re.compile(r"^(\s*)([-*+]|\d+[.)])\s+(.*)$") def tui_md_inline(text): """Split one line into (text, tag) segments: b bold, c code, i italic.""" segs = [] pos = 0 for match in TUI_MD_INLINE_RE.finditer(text): if match.start() > pos: segs.append((text[pos:match.start()], "")) token = match.group(0) if token.startswith(("**", "__")): segs.append((token[2:-2], "b")) elif token.startswith("`"): segs.append((token[1:-1], "c")) else: segs.append((token[1:-1], "i")) pos = match.end() if pos < len(text): segs.append((text[pos:], "")) return segs def tui_md_blocks(text): """Line-level markdown: returns (kind, segs, lead) rows. kinds: p, h, li (lead is the marker), quote, code, rule, blank. Fences, headings, bullets, numbered lists, quotes and rules; everything else is text.""" out = [] fence = False for raw in str(text or "").replace("\t", " ").split("\n"): line = raw.rstrip() if line.strip().startswith("```"): fence = not fence continue if fence: out.append(("code", [(line, "code")], "")) continue if not line.strip(): if out and out[-1][0] != "blank": out.append(("blank", [], "")) continue head = re.match(r"^#{1,6}\s+(.*)$", line) item = TUI_MD_LIST_RE.match(line) quote = re.match(r"^>\s?(.*)$", line) if head: out.append(("h", [(seg, "h") for seg, _tag in tui_md_inline(head.group(1))], "")) elif re.match(r"^\s*(-{3,}|\*{3,}|_{3,})\s*$", line): out.append(("rule", [], "")) elif item: marker = "•" if item.group(2) in "-*+" else item.group(2) out.append(("li", tui_md_inline(item.group(3)), " " * (len(item.group(1)) // 2) + marker)) elif quote: out.append(("quote", tui_md_inline(quote.group(1)), "")) else: out.append(("p", tui_md_inline(line), "")) while out and out[-1][0] == "blank": out.pop() return out def tui_wrap_segs(segs, width): """Greedy word wrap over (text, tag) segments; words longer than the width are chopped. Returns a list of segment lists, never empty.""" width = max(1, width) lines = [] cur = [] used = 0 def flush(): nonlocal cur, used while cur and cur[-1][0].isspace(): cur.pop() lines.append(cur) cur, used = [], 0 for text, tag in segs: for token in re.findall(r"\s+|\S+", text): if token.isspace(): if used and used + len(token) <= width: cur.append((token, tag)) used += len(token) continue while len(token) > width: if used: flush() cur.append((token[:width], tag)) used = width flush() token = token[width:] if used + len(token) > width: flush() cur.append((token, tag)) used += len(token) flush() return lines def tui_chat_styles(side): """Style table for one bubble kind. side: out, in, note, error.""" if side == "out": bg, fg, meta = "role:bubble_out", "role:bubble_fg", "role:meta_out" elif side == "error": bg, fg, meta = "role:tool_bg", "role:error", "role:meta_in" elif side == "note": bg, fg, meta = "role:tool_bg", "role:muted", "role:meta_in" else: bg, fg, meta = "role:bubble_in", "role:bubble_fg", "role:meta_in" base = TuiStyle(bg="role:bg", fg="role:fg") return { "base": base, "bubble": TuiStyle(bg=bg, fg=fg), "meta": TuiStyle(bg=bg, fg=meta), "tick": TuiStyle(bg=bg, fg="role:tick_read" if side == "out" else meta, bold=True), "name": TuiStyle(bg=bg, fg="role:accent2" if side != "out" else "role:meta_out", bold=True), "b": TuiStyle(bg=bg, fg=fg, bold=True), "i": TuiStyle(bg=bg, fg="role:info"), "c": TuiStyle(bg="role:code_bg", fg="role:accent3"), "code": TuiStyle(bg="role:code_bg", fg="role:accent2"), "h": TuiStyle(bg=bg, fg="role:accent3" if side == "out" else "role:accent", bold=True), "bullet": TuiStyle(bg=bg, fg="role:accent2" if side != "out" else "role:meta_out", bold=True), "quote": TuiStyle(bg=bg, fg="role:muted"), "bar": TuiStyle(bg=bg, fg=("role:error" if side == "error" else "role:accent")), } TUI_CHAT_PAD = 2 def tui_chat_body_rows(text, inner, styles, markdown=True): """Bubble body as (segs, fill_tag) rows wrapped to inner columns. fill_tag code pads the row with the code-block background.""" rows = [] if not markdown: for para in str(text or "").split("\n"): for part in textwrap.wrap(para, inner, expand_tabs=True, replace_whitespace=False, drop_whitespace=False, break_long_words=True, break_on_hyphens=False) or [""]: rows.append(([(part.rstrip(), "")], "")) return rows for kind, segs, lead in tui_md_blocks(text): if kind == "blank": rows.append(([], "")) elif kind == "rule": rows.append(([("─" * inner, "quote")], "")) elif kind == "code": line = segs[0][0] for start in range(0, max(1, len(line)), inner): rows.append(([(" " + line[start:start + inner - 1], "code")], "code")) elif kind == "li": indent = len(lead) + 1 wrapped = tui_wrap_segs(segs, max(4, inner - indent)) for pos, parts in enumerate(wrapped): head = [(lead[:-1], ""), (lead[-1:], "bullet"), (" ", "")] if pos == 0 else [(" " * indent, "")] rows.append((head + parts, "")) elif kind == "quote": for parts in tui_wrap_segs([(seg, "quote") for seg, _tag in segs], max(4, inner - 2)): rows.append(([("▎ ", "bar")] + parts, "")) else: for parts in tui_wrap_segs(segs, inner): rows.append((parts, "")) return rows def tui_chat_bubble_rows(text, width, side, styles, stamp="", ticks="", ticks_read=False, sender="", markdown=True, boxed=True): """One aligned bubble as span-rows. side out (right) or in (left); boxed bubbles carry a sender row on top and the stamp+ticks row at the bottom, both inside the fill. Markdown renders to styled runs. Below usable width bubbles degrade to full-width left rows. styles needs the base, bubble, meta, tick entries; the rest fall back to bubble.""" width = max(10, int(width)) base = styles["base"] bubble = styles["bubble"] def style_of(tag): return styles.get(tag) or bubble meta = " ".join(part for part in (stamp, ticks) if part).strip() if width < 34: rows = [] if sender: rows.append([TuiSpan(sender[:width].ljust(width), style_of("name"))]) for segs, _fill in tui_chat_body_rows(text, width, styles, markdown): rows.append(tui_pad_spans([TuiSpan(seg, style_of(tag)) for seg, tag in segs], width, base)) if meta: rows.append([TuiSpan(meta.rjust(width), styles["meta"])]) return rows cap = max(14, int(width * TUI_CHAT_BUBBLE_MAX)) inner_cap = cap - 2 * TUI_CHAT_PAD body = tui_chat_body_rows(text, inner_cap, styles, markdown) need = max([sum(len(seg) for seg, _tag in segs) for segs, _fill in body] + [len(sender) + 1, len(meta), 1]) bubble_w = min(cap, need + 2 * TUI_CHAT_PAD) inner = bubble_w - 2 * TUI_CHAT_PAD pad = TuiSpan(" " * TUI_CHAT_PAD, bubble) rows = [] if boxed: rows.append([TuiSpan(" " * bubble_w, bubble)] if not sender else [pad, TuiSpan(sender[:inner].ljust(inner), style_of("name")), pad]) for segs, fill in body: spans = [TuiSpan(seg, style_of(tag)) for seg, tag in segs] used = sum(len(seg) for seg, _tag in segs) fill_style = style_of(fill) if fill else bubble rows.append([pad] + spans + [TuiSpan(" " * (inner - used), fill_style), pad]) if boxed: if meta: time_span = TuiSpan(stamp, styles["meta"]) if stamp else None gap = " " if stamp and ticks else "" tick_span = TuiSpan(ticks, styles["tick"] if ticks_read else styles["meta"]) if ticks else None tail = [span for span in (time_span, TuiSpan(gap, styles["meta"]) if gap else None, tick_span) if span is not None] rows.append([pad, TuiSpan(" " * (inner - len(meta)), bubble)] + tail + [pad]) else: rows.append([TuiSpan(" " * bubble_w, bubble)]) aligned = [] for spans in rows: if side == "out": aligned.append([TuiSpan(" " * max(0, width - bubble_w), base)] + spans) else: aligned.append(spans + [TuiSpan(" " * max(0, width - bubble_w), base)]) return aligned def tui_chat_tool_summary(args): """Compact `k=v k=v` view of a JSON tool-call argument string.""" try: data = json.loads(args) except (TypeError, ValueError): return str(args or "").strip() if not isinstance(data, dict): return str(data) return " ".join("%s=%s" % (key, value if isinstance(value, (int, float)) else json.dumps(value, ensure_ascii=False).strip('"')) for key, value in data.items()) def tui_chat_tool_row(name, args, width, base): """One-line chip for a tool call: gear, bold name, muted arguments.""" width = max(10, int(width)) chip = TuiStyle(bg="role:accent2", fg="role:on_accent", bold=True) label = TuiStyle(bg="role:tool_bg", fg="role:fg", bold=True) detail = TuiStyle(bg="role:tool_bg", fg="role:muted") room = max(0, width - len(name) - 8) text = tui_chat_tool_summary(args).replace("\n", " ") if len(text) > room: text = text[:max(0, room - 1)] + "…" spans = [TuiSpan(" ", base), TuiSpan(" ⚙ ", chip), TuiSpan(" %s " % name, label)] if text: spans.append(TuiSpan("%s " % text, detail)) return tui_pad_spans(spans, width, base) def tui_chat_live_rows(command, lines, status, width, base): """A running command as a message: header chip with the command, the last TUI_CHAT_LIVE_ROWS output lines in a fixed window, and a status row once it ended. Wide lines are cut, narrow screens just get less text.""" width = max(10, int(width)) chip = TuiStyle(bg="role:accent2", fg="role:on_accent", bold=True) head = TuiStyle(bg="role:tool_bg", fg="role:fg", bold=True) body = TuiStyle(bg="role:tool_bg", fg="role:fg") dim = TuiStyle(bg="role:tool_bg", fg="role:muted") bar = TuiStyle(bg="role:tool_bg", fg="role:accent2") title = command.replace("\n", " ") room = max(0, width - 9) if len(title) > room: title = title[:max(0, room - 1)] + "…" rows = [tui_pad_spans([TuiSpan(" ", base), TuiSpan(" ⌁ ", chip), TuiSpan(" %s " % title, head)], width, base)] shown = list(lines)[-TUI_CHAT_LIVE_ROWS:] if not shown: shown = ["(waiting for output)"] if not status else ["(no output)"] for text in shown: text = text.expandtabs(4)[:max(0, width - 4)] rows.append(tui_pad_spans([TuiSpan(" ", base), TuiSpan("│ ", bar), TuiSpan(text, body if lines else dim)], width, base)) if status: rows.append(tui_pad_spans([TuiSpan(" ", base), TuiSpan("└ %s" % status[:max(0, width - 4)], dim)], width, base)) return rows def tui_chat_note_rows(text, width, side, base): """Command output and errors: accent bar plus plain wrapped text on the tool background, no markdown so aligned columns survive.""" styles = tui_chat_styles(side) cap = max(12, int(width * 0.9)) lines = tui_chat_body_rows(text, cap - 4, styles, markdown=False) longest = max([len(segs[0][0]) if segs else 0 for segs, _fill in lines] + [1]) block_w = min(cap, longest + 4) rows = [] for segs, _fill in lines: raw = segs[0][0] if segs else "" rows.append(tui_pad_spans([TuiSpan("▎", styles["bar"]), TuiSpan(" " + raw.ljust(block_w - 3) + " ", styles["bubble"])], width, base)) return rows def tui_chat_pill_row(text, width, base_style, pill_style): label = (" %s " % (text or "").strip())[:max(0, width)] left = max(0, (width - len(label)) // 2) right = max(0, width - left - len(label)) return [TuiSpan(" " * left, base_style), TuiSpan(label, pill_style), TuiSpan(" " * right, base_style)] def tui_chat_welcome_rows(width, name): """Empty-state card shown before the first message.""" width = max(10, int(width)) base = TuiStyle(bg="role:bg", fg="role:fg") logo = TuiStyle(bg="role:bg", fg="role:accent", bold=True) sub = TuiStyle(bg="role:bg", fg="role:muted") key = TuiStyle(bg="role:service_bg", fg="role:service_fg", bold=True) lines = [ [TuiSpan("✦ tai", logo)], [TuiSpan("chatting with %s" % name, sub)], [], [TuiSpan(" Enter ", key), TuiSpan(" send ", sub), TuiSpan(" /help ", key), TuiSpan(" commands ", sub), TuiSpan(" @ ", key), TuiSpan(" mention a bot", sub)], [TuiSpan(" F2 ", key), TuiSpan(" bots ", sub), TuiSpan(" F4 ", key), TuiSpan(" theme", sub)], ] rows = [[TuiSpan(" " * width, base)]] for spans in lines: used = sum(len(span.text) for span in spans) left = max(0, (width - used) // 2) rows.append(tui_pad_spans([TuiSpan(" " * left, base)] + spans, width, base)) return rows class TuiChatLog(TuiLog): """Transcript view with a slim scrollbar in the last column, drawn only when there is something to scroll.""" def bar_geometry(self): """(thumb top row, thumb height) of the scrollbar, or None when there is nothing to scroll.""" width = self._content_w() height = self._content_h() total = len(self.lines) if total <= height or height < 3 or width < 2: return None thumb = max(1, height * height // total) span = max(1, total - height) return int((height - thumb) * min(1.0, self.scroll_y / float(span))), thumb def paint_cells(self, compositor): """The cell-space paint(): the log's reused rows with the scrollbar cell written over each content row's last column.""" kind = type(self) edge = 1 if self.border else 0 if (edge or kind.paint is not TuiChatLog.paint or kind.content_rows is not TuiLog.content_rows or self.aw < self.chrome_w() + 2): return TuiPanel.paint_cells(self, compositor) rows = self._log_cells(compositor) geometry = self.bar_geometry() if geometry is None: return rows top, thumb = geometry height = self._content_h() pad_top, pad_right = self.padding[0], self.padding[1] blank = compositor.cell(" ", self.style.key()) track = compositor.cell("│", TuiStyle(bg=self.style.bg, fg="role:border").key()) grip = compositor.cell("┃", TuiStyle(bg=self.style.bg, fg="role:accent", bold=True).key()) at = self.aw - pad_right - 1 tail = [blank] * pad_right for local in range(height): row = rows[pad_top + local] rows[pad_top + local] = row[:at] + [grip if top <= local < top + thumb else track] + tail return rows def paint(self): rows = super().paint() geometry = self.bar_geometry() if geometry is None: return rows top, thumb = geometry height = self._content_h() edge = 1 if self.border else 0 track = TuiStyle(bg=self.style.bg, fg="role:border") grip = TuiStyle(bg=self.style.bg, fg="role:accent", bold=True) out = [] for pos, spans in enumerate(rows): local = pos - edge - self.padding[0] if 0 <= local < height: on = top <= local < top + thumb spans = tui_slice_spans(spans, 0, self.aw - edge - self.padding[1] - 1, self.style) + [ TuiSpan("┃" if on else "│", grip if on else track)] spans = tui_pad_spans(spans, self.aw - edge, self.style) out.append(spans) return out class TuiChatSession: """One bot's conversation inside the chat scene: its own agent, its own journal of rendered blocks, its own turn state. Bots run independently, so a long task in one never blocks talking to another.""" def __init__(self, name, agent): self.name = name self.agent = agent self.blocks = [] self.pending = None self.busy = False self.phase = "idle" self.detail = "" self.since = 0.0 self.reveal = None self.pending_tick = None self.note = "" self.unread = 0 self.queued = None class TuiChatState(dict): """The chat scene's refs. Turn and journal keys read and write the active session, everything else is a plain dict entry, so scene code says refs["busy"] and means the bot on screen.""" PROXIED = ("blocks", "pending", "busy", "phase", "detail", "since", "reveal", "pending_tick", "note") def __init__(self, session): super().__init__() self.session = session def __getitem__(self, key): if key in TuiChatState.PROXIED: return getattr(self.session, key) return super().__getitem__(key) def __setitem__(self, key, value): if key in TuiChatState.PROXIED: setattr(self.session, key, value) else: super().__setitem__(key, value) def get(self, key, default=None): if key in TuiChatState.PROXIED: return getattr(self.session, key) return super().get(key, default) class TuiLiveStream: """Stream sink (feed/pause/close, like LiveStream) for a command's output in the chat transcript. The journal block ["live", command, lines, status] is the message; feeding mutates it from the turn thread and asks the scene to repaint through `push`, which coalesces.""" def __init__(self, block, push): self.block = block self.push = push def feed(self, line): lines = self.block[2] lines.append(tui_strip_ansi(line.rstrip("\n").split("\r")[-1])) del lines[:-TUI_CHAT_LIVE_KEEP] self.push() def pause(self): pass def close(self, status): self.block[3] = status or "done" self.push() class TuiStdioGuard: """Counted stdout/stdin swap for turn threads. Several bots can run at once; a plain redirect_stdout per thread would restore in the wrong order and leave sys.stdout pointing at a dead buffer. The first thread in swaps, the last one out restores.""" def __init__(self): self.lock = threading.Lock() self.depth = 0 self.saved = None def __enter__(self): with self.lock: if self.depth == 0: self.saved = (sys.stdout, sys.stdin) sys.stdout = io.StringIO() sys.stdin = io.StringIO("") self.depth += 1 return self def __exit__(self, *_exc): with self.lock: self.depth -= 1 if self.depth == 0 and self.saved is not None: sys.stdout, sys.stdin = self.saved self.saved = None return False TUI_STDIO_GUARD = TuiStdioGuard() TUI_CHAT_BLOCK_CAP = 300 TUI_CHAT_LIVE_ROWS = 8 TUI_CHAT_LIVE_KEEP = 200 TUI_CHAT_LIVE_TICK = 0.05 def tui_spawn_agent(base, name): """A fresh Agent for another bot, sharing the vault with base. Starts with empty messages so switch_bot never saves a stale copy of base's conversation over the real one.""" child = Agent(base.config, base.store, yolo=base.yolo, auto=base.auto, ctx=getattr(base, "ctx_id", "")) child.messages = [] child.switch_profile(base.profile, silent=True) child.messages = [] child.switch_bot(name, silent=True) child.quiet = True child.env = base.env child.chat.prefer = base.chat.prefer child.chat.provider_pin = base.chat.provider_pin child.chat.model_pin = base.chat.model_pin return child def tui_build_chat(app, agent, spawn=None): """Chat scene: bots sidebar, header, markdown transcript, rounded composer, status bar. Turns run inline via a deferred timer (see run_pending): the worker thread the research sketch assumed would share one sqlite connection across threads, which sqlite forbids, so the echo paints first and the turn blocks the loop like the telegram bot does.""" spawn = spawn or tui_spawn_agent sessions = {agent.bot: TuiChatSession(agent.bot, agent)} refs = TuiChatState(sessions[agent.bot]) refs.update({"drafts": {}, "sidebar_auto": False, "sidebar_hide": False, "last_key": None, "bots": [], "render_w": 0, "spin": 0, "sessions": sessions}) panel_style = TuiStyle(bg="role:bg", fg="role:fg") dim_style = TuiStyle(bg="role:bg", fg="role:muted") err_style = TuiStyle(bg="role:bg", fg="role:error") bar_style = TuiStyle(bg="role:panel", fg="role:fg") name_style = TuiStyle(bg="role:panel", fg="role:fg", bold=True) side_style = TuiStyle(bg="role:panel", fg="role:fg") pill_style = TuiStyle(bg="role:service_bg", fg="role:service_fg", bold=True) bubble_styles = {side: tui_chat_styles(side) for side in ("in", "out", "note", "error")} root = TuiPanel(flow="rows", style=TuiStyle(bg="role:bg"), id="chat") body = TuiCols(h="1fr", style=TuiStyle(bg="role:bg"), id="body") sidebar_wrap = TuiRows(w=tui_chat_sidebar_w(app.cols), h="100%", style=side_style, id="sidebar") brand = TuiRich(h=3, style=side_style, id="brand") sidebar = TuiChatList([], h="1fr", style=side_style, id="bots") sidebar_wrap.add(brand, sidebar) right = TuiRows(w="1fr", h="100%", style=TuiStyle(bg="role:bg"), id="right") header = TuiCols(h=3, style=bar_style, padding=(0, 1), id="header") avatar = TuiRich(w=7, h=3, style=bar_style, id="avatar") info = TuiRows(w="1fr", h=3, valign="middle", style=bar_style, padding=(0, 1), id="info") head_name = TuiText("", h=1, wrap=False, style=name_style, id="head_name") status_row = TuiCols(h=1, style=bar_style, id="status_row") head_dot = TuiText("● ", w=2, h=1, wrap=False, style=TuiStyle(bg="role:panel", fg="role:success"), id="head_dot") head_status = TuiRich(w="1fr", h=1, style=TuiStyle(bg="role:panel", fg="role:muted"), id="head_status") status_row.add(head_dot, head_status) info.add(head_name, status_row) head_meta = TuiRich(w=24, h=3, style=bar_style, id="head_meta") header.add(avatar, info, head_meta) transcript = TuiChatLog(focusable=True, h="1fr", style=panel_style, id="transcript") composer = TuiCols(h=3, gap=1, padding=(0, 1), style=TuiStyle(bg="role:bg"), id="composer") box = TuiTextBox(w="1fr", h=1, border=True, rounded=True, padding=(0, 1), id="input", style=TuiStyle(bg="role:surface", fg="role:fg"), border_style=TuiStyle(bg="role:bg", fg="role:border"), focus_border_style=TuiStyle(bg="role:bg", fg="role:accent"), complete=tui_complete_command, mentions=lambda prefix: tui_complete_mention(prefix, bots=tuple(refs["bots"])), send_on_enter=True, on_send=lambda value: do_send(value), max_chars=TUI_CHAT_INPUT_CAP, placeholder="Write a message...", history=TuiHistory(VaultHistory.for_agent(agent))) send_btn = TuiButton("Send ➤", brackets=False, w=10, h=1, border=True, rounded=True, border_style=TuiStyle(bg="role:bg", fg="role:accent"), focus_border_style=TuiStyle(bg="role:bg", fg="role:accent2"), style=TuiStyle(bg="role:accent", fg="role:on_accent", bold=True), on_press=lambda event: do_send(box.value), id="send") composer.add(box, send_btn) right.add(header, transcript, composer) body.add(sidebar_wrap, right) status = TuiRich(h=1, style=bar_style, id="status") root.add(body, status) refs.update({"root": root, "sidebar_wrap": sidebar_wrap, "sidebar": sidebar, "transcript": transcript, "box": box, "status": status, "composer": composer, "head_name": head_name, "head_status": head_status, "head_dot": head_dot, "avatar": avatar, "head_meta": head_meta, "send_btn": send_btn}) def render_block(block): width = max(10, transcript._content_w() - 3) base = panel_style kind = block[0] key = (kind, block[1]) if kind in ("msg", "note") else (kind,) if transcript.lines and refs["last_key"] is not None and (key != refs["last_key"] or kind == "msg"): transcript.append("") if kind == "pill": rows = [tui_chat_pill_row(block[1], width, base, pill_style)] elif kind == "welcome": rows = tui_chat_welcome_rows(width, block[1]) elif kind == "tool": rows = [tui_chat_tool_row(block[1], block[2], width, base)] elif kind == "live": rows = tui_chat_live_rows(block[1], block[2], block[3], width, base) elif kind == "note": rows = tui_chat_note_rows(block[2], width, block[1], base) else: _kind, side, text, stamp, ticks, ticks_read, sender = block rows = tui_chat_bubble_rows(text, width, side, bubble_styles[side], stamp, ticks, ticks_read, sender) margin = TuiSpan(" ", base) for spans in rows: transcript.append_spans([margin] + list(spans)) refs["last_key"] = key def replay(): if not transcript.aw: return pinned = transcript.scroll_y >= max(0, len(transcript.lines) - transcript._content_h()) transcript.lines = [] refs["last_key"] = None for block in refs["blocks"]: render_block(block) while len(transcript.lines) > TUI_CHAT_LINE_CAP and len(refs["blocks"]) > 1: refs["blocks"].pop(0) transcript.lines = [] refs["last_key"] = None for block in refs["blocks"]: render_block(block) if pinned: transcript.scroll_y = max(0, len(transcript.lines) - transcript._content_h()) else: transcript._clamp_scroll() refs["render_w"] = transcript._content_w() transcript.touch() def maybe_replay(): if transcript.aw and transcript._content_w() != refs["render_w"]: replay() def cur(): return refs.session def journal(block, session=None): """Journal one block into a session. The active session renders it when laid out (returns the block, stable across replays, so callers can mutate it and replay); a background session only keeps it.""" session = session or cur() session.blocks.append(block) if session is not cur(): del session.blocks[:-TUI_CHAT_BLOCK_CAP] return block if transcript.aw: render_block(block) if len(transcript.lines) > TUI_CHAT_LINE_CAP and len(session.blocks) > 1: session.blocks.pop(0) replay() return block def bubble(side, text, stamp="", ticks="", ticks_read=False, sender="", session=None): return journal(["msg", side, text, stamp, ticks, ticks_read, sender], session) def note(text, side="note", session=None): return journal(["note", side, text], session) def pill(text): return journal(["pill", text]) def show(sender, text, style): if sender == "you": return bubble("out", text) if style is err_style: return note(text, "error") if style is dim_style: return note(text) return bubble("in", text, sender=agent.bot) def render_history(force=True): """Rebuild the active bot's journal from its messages. force=False keeps an existing journal (switching back to a bot you already opened) and just replays it.""" session = cur() if session.blocks and not force: replay() return session.blocks = [] session.pending_tick = None transcript.lines = [] refs["last_key"] = None past = [msg for msg in agent.messages if msg.get("role") != "system"][-TUI_CHAT_HISTORY:] if not past: now = datetime.now() pill("%s %d" % (now.strftime("%B"), now.day)) journal(["welcome", agent.bot]) for msg in past: if msg.get("role") == "user": bubble("out", tui_strip_ansi(msg.get("content") or "")) elif msg.get("role") == "assistant": if msg.get("content"): bubble("in", tui_strip_ansi(msg["content"]), sender=agent.bot) for call in msg.get("tool_calls") or []: fn = (call.get("function") or {}) journal(["tool", fn.get("name") or "tool", fn.get("arguments") or ""]) def chip(text, style): return TuiSpan(" %s " % text, style) def phase_word(): """What the turn is really doing right now: thinking while the model or the loop works, running <tool> inside a tool call, typing once the reply exists and is being laid out.""" if refs["phase"] == "tool": return "running %s" % (refs["detail"] or "tool") if refs["phase"] == "typing": return "typing" if refs["phase"] == "ask": return "waiting for you" return "thinking" def status_spans(): """Header status line. Idle is a calm green dot; a live phase gets a spinner in cycling accent colors and a shimmer sweeping the word, with trailing dots that light up one after another.""" panel = "role:panel" if not (refs["busy"] or refs["pending"] is not None): return "online", TUI_CHAT_ONLINE_GLYPH, TuiStyle(bg=panel, fg="role:success"), [TuiSpan("online", TuiStyle(bg=panel, fg="role:muted"))] word = phase_word() frame = refs["spin"] tint = ("role:accent", "role:accent2", "role:accent3")[(frame // 3) % 3] glyph = TUI_CHAT_SPINNER[frame % len(TUI_CHAT_SPINNER)] if refs["phase"] != "typing" else ("✎" if frame % 4 < 2 else "✐") head = frame % (len(word) + 6) - 3 spans = [] for pos, char in enumerate(word): reach = abs(pos - head) fg = "role:fg" if reach == 0 else (tint if reach == 1 else "role:muted") spans.append(TuiSpan(char, TuiStyle(bg=panel, fg=fg, bold=reach <= 1))) lit = (frame // 4) % 4 for pos in range(3): spans.append(TuiSpan(".", TuiStyle(bg=panel, fg=tint if pos < lit else "role:border", bold=pos < lit))) return word + "...", glyph, TuiStyle(bg=panel, fg=tint, bold=True), spans def others_busy(): return sum(1 for item in sessions.values() if item is not cur() and item.busy) def paint_status(): width = max(20, app.cols) key_style = TuiStyle(bg="role:service_bg", fg="role:service_fg", bold=True) label_style = TuiStyle(bg="role:panel", fg="role:muted") gap = TuiSpan(" ", bar_style) left = [] if refs["note"]: warn = refs["busy"] or refs["pending"] is not None glyph = status_spans()[1] if warn else "●" left.append([TuiSpan(" %s " % glyph, TuiStyle(bg="role:panel", fg="role:warning" if warn else "role:success", bold=True)), TuiSpan(refs["note"], TuiStyle(bg="role:panel", fg="role:fg", bold=True))]) for keycap, label in (("F2", "Bots"), ("F4", "Theme"), ("Tab", "Focus"), ("i", "Input"), ("/help", "Commands")): left.append([chip(keycap, key_style), TuiSpan(" %s " % label, label_style)]) pins = " ".join(part for part in (agent.chat.provider_pin, agent.chat.model_pin) if part) right = [[TuiSpan(" %s/%s " % (agent.profile, agent.bot), TuiStyle(bg="role:accent", fg="role:on_accent", bold=True))], [TuiSpan(" %s " % (pins or "auto"), TuiStyle(bg="role:service_bg", fg="role:service_fg"))]] busy = running_agents() + others_busy() if busy: right.append([TuiSpan(" +%d " % busy, TuiStyle(bg="role:warning", fg="role:bg", bold=True))]) def size(groups): return sum(sum(len(span.text) for span in group) + 1 for group in groups) while len(left) > 1 and size(left) + size(right) > width: left.pop() while len(right) > 1 and size(left) + size(right) > width: right.pop() spans = [] for group in left: spans.extend(group) spans.append(gap) fill = max(0, width - size(left) - size(right)) spans.append(TuiSpan(" " * fill, bar_style)) for group in right: spans.extend(group) spans.append(gap) status.set(spans) tint = "role:" + tui_avatar_role(agent.bot) av = TuiStyle(bg=tint, fg="role:on_accent", bold=True) avatar.set([[TuiSpan(" " * 7, av)], [TuiSpan((" %s " % (agent.bot[:1].upper() or "?")).center(7), av)], [TuiSpan(" " * 7, av)]]) head_name.text = agent.bot _text, glyph, dot_style, spans = status_spans() head_status.set(spans) head_status.text = _text head_dot.text = glyph + " " head_dot.style = dot_style brand.set([[], [TuiSpan(" ✦ ", TuiStyle(bg="role:panel", fg="role:accent", bold=True)), TuiSpan("tai", TuiStyle(bg="role:panel", fg="role:fg", bold=True)), TuiSpan(" %s" % VERSION, TuiStyle(bg="role:panel", fg="role:muted"))], [TuiSpan(" Bots", TuiStyle(bg="role:panel", fg="role:accent2", bold=True))]]) meta_style = TuiStyle(bg="role:panel", fg="role:muted") label = "%s" % (pins or "auto") head_meta.set([[], [TuiSpan(("◆ " + label + " ")[-22:].rjust(24), meta_style)], []]) head_meta.visible = app.cols >= 70 def clear_box(): box.lines = [""] box.cx = box.cy = box.top = 0 box._complete_hide() box.touch() def bot_snippet(name): try: msgs = agent.messages if name == agent.bot else agent.store.load_session(agent.profile, name, ctx=getattr(agent, "ctx_id", "")) except Exception: return "" for msg in reversed(msgs or []): content = (msg.get("content") or "").strip() if content: return content.split("\n")[0][:40] return "" def bot_meta(name): """Sidebar flags from live sessions only (no vault reads), so the busy spinner and unread count can repaint at animation speed.""" item = sessions.get(name) busy = bool(item and (item.busy or item.pending is not None)) glyph = TUI_CHAT_SPINNER[refs["spin"] % len(TUI_CHAT_SPINNER)] if busy else "" if item is not None and item.queued is not None: glyph = "?" return {"busy": glyph, "unread": item.unread if item else 0} def refresh_meta(): for name, meta in sidebar.row_meta.items(): meta.update(bot_meta(name)) sidebar.touch() def refresh_bots(): names = [item["name"] for item in agent.store.list_bots(agent.profile)] for name in sessions: if name not in names: names.append(name) if agent.bot not in names: names = [agent.bot] + names refs["bots"] = names sidebar.options = [TuiOptions.norm_option(name) for name in names] sidebar.selected = names.index(agent.bot) sidebar.row_meta = {} for name in names: meta = {"snippet": bot_snippet(name), "draft": refs["drafts"].get(name, "").split("\n")[0][:40]} meta.update(bot_meta(name)) sidebar.row_meta[name] = meta sidebar.touch() def activate(session): nonlocal agent refs.session = session agent = session.agent if box.history.backend is not None: box.history.backend.bot = lambda: agent.bot def session_for(name): if name not in sessions: sessions[name] = TuiChatSession(name, spawn(agent, name)) return sessions[name] def select_bot(name): """Bring a bot on screen. Every bot has its own agent, so switching never waits for, interrupts or shares state with a running turn.""" if name == cur().name: refresh_bots() return refs["drafts"][cur().name] = box.value target = session_for(name) activate(target) target.unread = 0 draft = refs["drafts"].get(name, "") box.lines = draft.split("\n") or [""] box.cx = len(box.lines[-1]) box.cy = len(box.lines) - 1 box.top = 0 box.history.reset() refresh_bots() render_history(force=False) paint_status() if target.queued is not None: opener, target.queued = target.queued, None opener() def run_command(text): buf = io.StringIO() before = (agent.profile, agent.bot) try: with contextlib.redirect_stdout(buf): stay = handle_command(agent, text) except Exception as exc: note("error: %s" % short_error(exc), "error") return out = tui_strip_ansi(buf.getvalue()).strip() if out: note("\n".join(out.split("\n")[:100])) if not stay: app.quit() return if agent.profile != before[0]: for name in [name for name, item in sessions.items() if item is not cur() and not item.busy]: del sessions[name] cur().name = agent.bot elif agent.bot != before[1]: moved = agent.bot agent.switch_bot(before[1], silent=True) select_bot(moved) refresh_bots() paint_status() def settle_pending_tick(session=None): session = session or cur() block = session.pending_tick session.pending_tick = None if block is None or not any(entry is block for entry in session.blocks): return block[4] = TUI_CHAT_TICK_READ block[5] = True if session is cur(): replay() def do_send(value): text = (value or "").strip() session = cur() if not text or session.pending is not None or session.busy: return box.history.add(text) clear_box() refs["drafts"][agent.bot] = "" if text.startswith("/"): run_command(text) return stamp = datetime.now().strftime("%H:%M") session.pending_tick = bubble("out", text, stamp, TUI_CHAT_TICK_SEND) session.pending = text session.note = "thinking..." session.phase, session.detail = "thinking", "" refresh_bots() paint_status() app.after(0.05, lambda: run_pending(session)) def run_pending(session=None): session = session or cur() text = session.pending session.pending = None if not text: return session.busy = True session.since = time.time() arm_spin() worker = session.agent worker.on_phase = lambda name, detail="": set_phase(session, name, detail) worker.prompter = lambda kind, ask, options: prompter(kind, ask, options, session) worker.stream_factory = lambda command: live_stream(command, session) def turn(): with TUI_STDIO_GUARD: try: return worker.run_turn(text, capture=True) except KeyboardInterrupt: worker.checkpoint() return "[interrupted]" except BackendError as exc: return "error: %s" % short_error(exc) def landed(answer, error): if error is not None: answer = "error: %s" % short_error(error) settle_pending_tick(session) full = tui_strip_ansi(answer or "[no reply]") stamp = datetime.now().strftime("%H:%M") foreground = session is cur() animate = app._loop is not None and len(full) > 12 and foreground block = bubble("in", "" if animate else full, stamp, sender=session.name, session=session) if not foreground: session.unread += 1 if not animate: finish_turn(session) return session.phase = "typing" session.reveal = {"block": block, "full": full, "shown": 0, "step": max(6, -(-len(full) // TUI_CHAT_REVEAL_STEPS))} app.after(TUI_CHAT_REVEAL_TICK, lambda: reveal_step(session)) app.offload(turn, landed) def live_stream(command, session): """Called from a turn thread when a command starts: its window joins the session journal as a message, then repaints at most every TUI_CHAT_LIVE_TICK while output streams in.""" loop = app._loop if loop is None: return None block = ["live", command[:300], [], ""] flag = {"queued": False} def flush(): flag["queued"] = False if session is cur() and any(entry is block for entry in session.blocks): replay() def push(): if not flag["queued"]: flag["queued"] = True loop.call_soon_threadsafe(lambda: app.after(TUI_CHAT_LIVE_TICK, flush)) loop.call_soon_threadsafe(lambda: journal(block, session)) return TuiLiveStream(block, push) def finish_turn(session=None): session = session or cur() session.busy = False session.phase, session.detail, session.reveal = "idle", "", None session.note = "" refresh_bots() paint_status() def reveal_step(session=None): """Lay the reply out word by word: real typing, not a fake wait. Bounded to TUI_CHAT_REVEAL_STEPS ticks however long the reply is. If you switch away mid-reveal the rest lands at once.""" session = session or cur() state = session.reveal if state is None: return full = state["full"] end = min(len(full), state["shown"] + state["step"]) while end < len(full) and not full[end - 1].isspace(): end += 1 if session is not cur(): end = len(full) state["shown"] = end block = state["block"] if any(entry is block for entry in session.blocks): block[2] = full[:end] if session is cur(): replay() if end >= len(full): finish_turn(session) else: app.after(TUI_CHAT_REVEAL_TICK, lambda: reveal_step(session)) def set_phase(session, name, detail=""): session.phase, session.detail = name, detail def style_dialog(dialog): """Dress a stock dialog in the chat theme: surface fill, accent rounded border, accent buttons.""" dialog.style = TuiStyle(bg="role:surface", fg="role:fg") dialog.border_style = TuiStyle(bg="role:bg", fg="role:accent") dialog.title_style = TuiStyle(bg="role:bg", fg="role:accent2", bold=True) dialog.rounded = True dialog.padding = TuiPanel.norm_padding((0, 1)) for node in dialog.walk(): if node is dialog: continue if isinstance(node, TuiButton): node.style = TuiStyle(bg="role:accent", fg="role:on_accent", bold=True) node.rounded = True node.border_style = TuiStyle(bg="role:surface", fg="role:accent") node.focus_border_style = TuiStyle(bg="role:surface", fg="role:accent2") elif isinstance(node, TuiTextBox): node.style = TuiStyle(bg="role:bg", fg="role:fg") elif not isinstance(node, (TuiRows, TuiCols)): node.style = TuiStyle(bg="role:surface", fg="role:fg") return dialog def prompter(kind, text, options, session=None): """Called from a turn thread: show a real dialog on the loop and wait for the answer. Returns the chosen option / typed text, or None when the dialog was dismissed or TUI_CHAT_PROMPT_TIMEOUT passed (so a walked-away user means deny, never an open-ended hang). The title names the bot asking, since it may not be the one on screen.""" session = session or cur() loop = app._loop if loop is None: return None answer = {"value": None} done = threading.Event() shown = [] text = str(text)[:TUI_CHAT_PROMPT_CHARS] width = min(72, max(30, app.cols - 6)) def finish(event=None): done.set() def pick(value): answer["value"] = value def ask_text(): dialog = style_dialog(TuiPromptDialog(text if kind == "ask" else "Reply:", box_h=3, on_execute=pick, title="Question - %s" % session.name, w=width)) dialog.on_close = finish shown.append(dialog) app.root.add(dialog) def chosen(value): if value is TUI_CHAT_TYPE_ANSWER: answer["typing"] = True return pick(value) def open_dialog(): if kind == "approve": dialog = TuiQuestion("Run this?\n\n%s" % text, options, on_done=pick, title="Approval needed - %s" % session.name, w=width) elif options: dialog = TuiQuestion(text, list(options) + [("Type an answer...", TUI_CHAT_TYPE_ANSWER)], on_done=chosen, title="Question - %s" % session.name, w=width) else: ask_text() return def closed(event): if answer.get("typing"): answer["typing"] = False ask_text() return finish() dialog.on_close = closed shown.append(style_dialog(dialog)) app.root.add(dialog) before = session.phase, session.detail session.phase, session.detail = "ask", "" if session is cur(): loop.call_soon_threadsafe(open_dialog) else: session.queued = lambda: loop.call_soon_threadsafe(open_dialog) loop.call_soon_threadsafe(refresh_meta) if not done.wait(TUI_CHAT_PROMPT_TIMEOUT): session.queued = None for dialog in shown: loop.call_soon_threadsafe(dialog.close) session.queued = None session.phase, session.detail = before return answer["value"] def arm_spin(): if refs.get("spin_timer") is None: refs["spin_timer"] = app.every(0.08, spin) def spin(): live = [item for item in sessions.values() if item.busy] if not live: app.cancel_timer(refs.get("spin_timer")) refs["spin_timer"] = None return refs["spin"] += 1 for item in live: word = "typing" if item.phase == "typing" else ("running %s" % (item.detail or "tool") if item.phase == "tool" else ("waiting for you" if item.phase == "ask" else "thinking")) item.note = "%s... %ds" % (word, int(time.time() - item.since)) refresh_meta() paint_status() def toggle_sidebar(): sidebar_wrap.visible = not sidebar_wrap.visible refs["sidebar_hide"] = not sidebar_wrap.visible refs["sidebar_auto"] = False if sidebar_wrap.visible: sidebar_wrap.w = tui_chat_sidebar_w(app.cols) app.ensure_focus() def on_resize(event): sidebar_wrap.w = tui_chat_sidebar_w(app.cols) compact = app.cols < 50 send_btn.label = "➤" if compact else "Send ➤" send_btn.w = 5 if compact else 10 if app.cols < TUI_CHAT_NARROW_COLS and sidebar_wrap.visible and not refs["sidebar_hide"]: sidebar_wrap.visible = False refs["sidebar_auto"] = True elif app.cols >= TUI_CHAT_NARROW_COLS and refs["sidebar_auto"] and not refs["sidebar_hide"]: sidebar_wrap.visible = True refs["sidebar_auto"] = False app.ensure_focus() app.after(0.05, maybe_replay) def on_root_key(event): key = event.key or "" if key == "f2": event.stop() toggle_sidebar() elif key == "f4": event.stop() nxt = tui_cycle_theme_name(tui_theme().name) app.set_theme(nxt) refs["note"] = "theme: %s" % nxt paint_status() elif key == "q": event.stop() app.quit() elif key in ("i", "escape"): event.stop() box.focus() def tick_input(): rows = max(1, min(5, len(box.lines))) if box.h != rows: box.h = rows send_btn.h = rows composer.h = rows + 2 sidebar.on_select = lambda event: select_bot(event.value) root.on_key = on_root_key root.on_resize = on_resize box.on_change = lambda event: tick_input() tick_input() app.every(1.0, paint_status) on_resize(None) refresh_bots() render_history() paint_status() refs.update({"do_send": do_send, "run_pending": run_pending, "select_bot": select_bot, "toggle_sidebar": toggle_sidebar, "paint_status": paint_status, "prompter": prompter, "live_stream": live_stream, "show": show, "replay": replay, "add_note": note, "render_history": render_history}) return root, refs def tui_chat(agent, theme_name): tui_set_theme(theme_name) agent.quiet = True app = TuiApp() app.rows, app.cols = app._winsize() scheduler_stop = start_scheduler(agent.config, agent.store.seal) refs = None try: root, refs = tui_build_chat(app, agent) app.mount(root) refs["replay"]() refs["box"].focus() return app.run() except TuiError as exc: print("tui unavailable: %s" % exc, file=sys.stderr) return 2 finally: scheduler_stop.set() owners = [item.agent for item in refs["sessions"].values()] if refs is not None else [agent] for owner in owners: owner.store.save_session(owner.profile, owner.messages, owner.bot, ctx=getattr(owner, "ctx_id", "")) # --------------------------------------------------------------------------- # Grr TUI: the terminal client of the grr server. Specified in grrtui.md. # Part A: constants, palette, strings, formatting, avatars, HTTP and event # stream client, saved accounts, store, router. # --------------------------------------------------------------------------- GRR_DEFAULT_URL = "http://127.0.0.1:8765" GRR_HTTP_TIMEOUT = 30 GRR_SSE_TIMEOUT = 60 GRR_SSE_RETRY = 2.0 GRR_SSE_LINE_MAX = 1 << 20 GRR_BODY_MAX = 32 << 20 GRR_SIDE_W = 34 GRR_SIDE_MIN_COLS = 100 GRR_PANE_MIN_COLS = 150 GRR_PANE_MIN_W = 40 GRR_PANE_MAX_W = 90 GRR_MESSAGE_KEEP = 400 GRR_TOAST_SECS = 3.2 GRR_BANNER_SECS = 5.2 GRR_TOAST_MAX = 4 GRR_COMPOSER_MAX_ROWS = 6 GRR_VIEWER_MAX_BYTES = 2 << 20 GRR_PROMPT_QUIET = 0.8 # seconds of silence after a prompt-like last row before the command is offered for typing GRR_PROMPT_ASKS = 20 # times one process card opens its terminal by itself GRR_PROMPT_MAX = 8 # pending approvals asked at once as dialogs in one chat GRR_DRAFT_KEEP = 64 GRR_LIST_MORE = 12 GRR_MENU_MAX_H = 14 GRR_PALETTE_FALLBACK = { "night": {"bg": "#0d0c0a", "raised": "#15130f", "sunken": "#090807", "card": "#1c1a15", "ink": "#ece6da", "soft": "#cfc8ba", "muted": "#8c857a", "faint": "#5b564e", "rule": "#2a2722", "rule-strong": "#3d3932", "accent": "#d4a020", "accent-ink": "#120f08", "success": "#7fa36b", "danger": "#c8644f", "info": "#7f9eb0", "bubble-user": "#3d3932", "bubble-bot": "#1c1a15", "code-bg": "#12110e"}, "day": {"bg": "#f6f1e6", "raised": "#fffaf0", "sunken": "#ece5d6", "card": "#fffaf0", "ink": "#1b1812", "soft": "#3d392f", "muted": "#6c675e", "faint": "#a49d8e", "rule": "#e0d8c6", "rule-strong": "#c9bfa8", "accent": "#b07d0a", "accent-ink": "#fffaf0", "success": "#4f7a3a", "danger": "#b0432e", "info": "#41697f", "bubble-user": "#e5dcc6", "bubble-bot": "#fffaf0", "code-bg": "#efe7d4"}, "variants": {"black": {"night": None, "day": None}, "blue": {"night": {"accent": "#7f9eb0", "accent-ink": "#0a1015"}, "day": {"accent": "#41697f", "accent-ink": "#f6f1e6"}}}, "bot_colors": ["#ffffff", "#9a6a3a", "#e5484d", "#f76b15", "#d4a020", "#30a46c", "#12a594", "#3e63dd", "#8e4ec6", "#e93d82", "#8c857a"], } GRR_SHAPES = ("blob", "cloud", "tilt", "plus", "flower", "leaf", "pebble", "oval", "play", "gem", "cog", "hex") # Two rows of seven cells per shape; "E" marks an eye cell, drawn in the dark eye color. GRR_AVATAR_ROWS = { "blob": ("▗█E█E█▖", "▝█E█E█▘"), "cloud": ("▟█E█E█▙", "▜█E█E█▛"), "tilt": (" ▟E█E█▘", "▗▜E█E█ "), "plus": ("▄▟E█E█▄", "▀▜E█E█▀"), "flower": ("▗▜E█E█▖", "▝▟E█E█▘"), "leaf": ("▗█E█E█▛", "▜█E█E█▖"), "pebble": ("▗█E█E█▖", "▝█E█E█▘"), "oval": (" ▟E█E▙ ", " ▜E█E▛ "), "play": ("▛█E█E█▙", "▙█E█E█▛"), "gem": ("▗▟E█E█▖", " ▜E█E█▘"), "cog": ("▟▀E▀E▀▙", "▜▄E▄E▄▛"), "hex": ("▟█E█E█▙", "▜█E█E█▛"), } GRR_ICONS = { "back": "‹", "close": "✕", "plus": "+", "search": "⌕", "gear": "⚙", "compose": "✎", "more": "⋯", "moreV": "⋮", "share": "⇧", "check": "✓", "right": "›", "down": "▾", "up": "▴", "trash": "✗", "copy": "❐", "mic": "●", "wave": "≈", "send": "↑", "stop": "■", "camera": "◘", "image": "▨", "file": "▤", "skills": "▦", "plug": "◈", "incognito": "◐", "keyboard": "▭", "clipboard": "▥", "help": "?", "bell": "♪", "sun": "☼", "moon": "☾", "contrast": "◑", "globe": "◍", "key": "⌘", "user": "☺", "folder": "▣", "box": "▢", "automation": "◷", "star": "★", "download": "⇩", "forward": "↪", "tune": "≡", "filter": "▽", "terminal": "❯", "link": "∞", "external": "↗", "sparkle": "✦", "thumbup": "▲", "thumbdown": "▼", "speaker": "♫", "refresh": "↻", "hash": "#", "group": "☻", "pause": "‖", "play": "▶", "clock": "◷", "pin": "⚑", "edit": "✎", "eye": "◉", "inbox": "▥", "rocket": "➤", "panel": "◨", "phone": "▯", "code": "‹", "expand": "⇱", "shrink": "⇲", "bolt": "ϟ", "monitor": "▭", "list": "≣", "cpu": "▦", "warn": "!", "dot": "●", } class GrrError(Exception): """A failed call to the grr server: HTTP status (0 for no answer), the stable error code, the words for the user and an optional hint.""" def __init__(self, status, code, message, hint="", vars=None): super().__init__(message) self.status = status self.code = code self.hint = hint self.vars = vars or {} def text(self): return (self.args[0] + (" " + self.hint if self.hint else "")).strip() GRR_STRING_FALLBACK = {"library.sort_new": "Newest first", "library.sort_old": "Oldest first", "library.sort_name": "Name", "library.sort_size": "Largest first"} class GrrStrings: """The server's own string table (`/locales.json`), so the TUI says the same words as the web app in every language, from one source.""" ALIAS = {"bad_value": "invalid", "bad_kind": "invalid", "bad_mode": "invalid", "bad_shape": "invalid", "bad_color": "invalid", "bad_action": "invalid", "bad_decision": "invalid", "unknown_setting": "invalid", "bot_required": "invalid", "bad_schedule": "invalid", "bad_json": "invalid"} def __init__(self, table=None, lang="en"): self.table = table or {} self.lang = lang if lang in self.table else "en" def has(self, key): return key in self.table.get(self.lang, {}) or key in self.table.get("en", {}) def t(self, key, **vars): text = self.table.get(self.lang, {}).get(key) if text is None: text = self.table.get("en", {}).get(key) if text is None: text = GRR_STRING_FALLBACK.get(key, key) for name, value in vars.items(): text = text.replace("{" + name + "}", str(value)) return text def use(self, setting, languages=(), env=None): self.lang = GrrStrings.resolve(setting, languages, env if env is not None else os.environ) if self.lang not in self.table: self.lang = "en" return self.lang @staticmethod def resolve(setting, languages, env): if setting and setting != "system": return setting raw = (env.get("LC_ALL") or env.get("LC_MESSAGES") or env.get("LANG") or "en").split(".")[0].replace("_", "-") low = raw.lower() for known in languages: if known.lower() == low: return known if low in ("zh-tw", "zh-hk"): return "zh-TW" base = low.split("-")[0] for known in languages: if known.lower() == base: return known return "en" def explain(self, status, body): """Words for an error body: the localized `apierr.<code>` when there is one, else the server's own message.""" err = (body or {}).get("error") or {} if isinstance(body, dict) else {} code = err.get("code") or "http" vars = err.get("vars") or {} alias = GrrStrings.ALIAS.get(code) or ("missing" if code.startswith("no_") else "") key = "apierr." + code if self.has("apierr." + code) else ("apierr." + alias if alias and self.has("apierr." + alias) else "") if key: hint = self.t(key + "_hint") if self.has(key + "_hint") else "" return GrrError(status, code, self.t(key, **vars), hint, vars) return GrrError(status, code, err.get("message") or ("HTTP %d" % status), err.get("hint") or "", vars) class GrrPalette: """The grr colors for one mode (night or day) and variant, read from the server's spec so the TUI never carries its own copy of the brand.""" def __init__(self, spec=None, theme="system", variant="black"): palette = (spec or {}).get("palette") or GRR_PALETTE_FALLBACK self.mode = GrrPalette.mode_of(theme) base = dict(GRR_PALETTE_FALLBACK[self.mode]) base.update(palette.get(self.mode) or {}) change = ((palette.get("variants") or {}).get(variant) or {}).get(self.mode) if change: base.update(change) self.colors = base self.variant = variant self.bot_colors = list(palette.get("bot_colors") or GRR_PALETTE_FALLBACK["bot_colors"]) self.name = "grr-%s-%s" % (self.mode, variant) @staticmethod def mode_of(theme): if theme in ("day", "night"): return theme hint = os.environ.get("COLORFGBG", "") if hint and hint.split(";")[-1].isdigit() and int(hint.split(";")[-1]) in (7, 15): return "day" return "night" def c(self, name): return self.colors.get(name) or self.colors["ink"] def tui_theme(self): c = self.c roles = {"bg": c("bg"), "surface": c("card"), "fg": c("ink"), "muted": c("muted"), "border": c("rule-strong"), "accent": c("accent"), "on_accent": c("accent-ink"), "selection": c("rule-strong"), "success": c("success"), "warning": c("accent"), "error": c("danger"), "info": c("info"), "bubble_in": c("bubble-bot"), "bubble_out": c("bubble-user"), "bubble_fg": c("ink"), "meta_in": c("muted"), "meta_out": c("soft"), "tick_read": c("info"), "service_bg": c("rule"), "service_fg": c("muted"), "unread": c("accent"), "unread_muted": c("faint"), "panel": c("raised"), "accent2": c("info"), "accent3": c("accent"), "code_bg": c("code-bg"), "tool_bg": c("sunken")} return TuiTheme(self.name, self.mode == "night", roles) def apply(self): tui_register_theme(self.tui_theme()) tui_set_theme(self.name) @staticmethod def light(color): try: n = int(color.lstrip("#"), 16) except ValueError: return False return (((n >> 16) & 255) * 299 + ((n >> 8) & 255) * 587 + (n & 255) * 114) / 1000 > 150 class GrrUi: """What every drawing routine needs: palette, strings, formatting, style cache. One instance per app, replaced as a whole on a theme change.""" def __init__(self, palette, strings, spec=None): self.palette = palette self.strings = strings self.spec = spec or {} self.version = 1 self._styles = {} self.fmt = GrrFormat(strings) def t(self, key, **vars): return self.strings.t(key, **vars) def st(self, fg="ink", bg="bg", bold=False, dim=False, underline=False, reverse=False): key = (fg, bg, bold, dim, underline, reverse) style = self._styles.get(key) if style is None: pal = self.palette style = self._styles[key] = TuiStyle(fg=pal.c(fg) if not fg.startswith("#") else fg, bg=pal.c(bg) if not bg.startswith("#") else bg, bold=bold, dim=dim, underline=underline, reverse=reverse) return style def cap(self, name, default): return ((self.spec.get("caps") or {}).get(name)) or default def icon(self, name): return GRR_ICONS.get(name, "?") class GrrFormat: """Times, sizes and schedules in the words of the server's string table.""" def __init__(self, strings): self.s = strings @staticmethod def when(ts): return datetime.fromtimestamp(float(ts)) def clock(self, ts): return GrrFormat.when(ts).strftime("%H:%M") def list_time(self, ts): if not ts: return "" d, now = GrrFormat.when(ts), datetime.now() if d.date() == now.date(): return self.clock(ts) if d.date() == (now - timedelta(days=1)).date(): return self.s.t("time.yesterday") if (now - d).days < 6: return d.strftime("%A") return "%d %s" % (d.day, d.strftime("%b")) def stamp(self, ts): d, now = GrrFormat.when(ts), datetime.now() if d.date() == now.date(): return self.s.t("time.today") + " " + self.clock(ts) if d.date() == (now - timedelta(days=1)).date(): return self.s.t("time.yesterday") + " " + self.clock(ts) if (now - d).days < 6: return d.strftime("%A") + " " + self.clock(ts) return "%d %s %s" % (d.day, d.strftime("%b"), self.clock(ts)) def short_date(self, ts): d = GrrFormat.when(ts) return "%d %s" % (d.day, d.strftime("%b")) def ago(self, ts): secs = int(float(ts) - time.time()) for unit, size in (("day", 86400), ("hour", 3600), ("minute", 60)): if abs(secs) >= size: n = int(round(secs / float(size))) if unit == "day" and n == -1: return "yesterday" word = "%d %s%s" % (abs(n), unit, "" if abs(n) == 1 else "s") return word + " ago" if n < 0 else "in " + word return "now" @staticmethod def bytes(n): n = int(n or 0) if n < 1024: return "%d B" % n if n < 1048576: return ("%.1f kB" if n < 10240 else "%.0f kB") % (n / 1024.0) if n < 1073741824: return "%.1f MB" % (n / 1048576.0) return "%.2f GB" % (n / 1073741824.0) @staticmethod def num(n): return "{:,}".format(int(n or 0)) @staticmethod def weekday(i): return ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")[int(i) % 7] @staticmethod def clock_label(hhmm): return hhmm def schedule(self, s): if not s: return "" if s.get("kind") == "interval": sec = int(s.get("every_seconds") or 0) if sec and sec % 86400 == 0: return self.s.t("sched.every_days", n=sec // 86400) if sec and sec % 3600 == 0: return self.s.t("sched.every_hours", n=sec // 3600) if sec and sec % 60 == 0: return self.s.t("sched.every_minutes", n=sec // 60) return self.s.t("sched.every_seconds", n=sec) if s.get("kind") == "daily": return self.s.t("sched.daily", time=s.get("time", "")) return self.s.t("sched.weekly", day=GrrFormat.weekday(s.get("day", 0)), time=s.get("time", "")) @staticmethod def next_run(ts): return GrrFormat.when(ts).strftime("%a %d %b %H:%M") if ts else "-" @staticmethod def domain(url): try: host = urllib.parse.urlsplit(url).hostname or url except ValueError: return url return host[4:] if host.startswith("www.") else host class GrrAvatar: """A bot's mark in cells: the character shape with two eyes. `rows` draws it 2 rows tall (5 wide when `small`, 7 wide otherwise; `hero` makes it 4 rows), `chip` is one row, `chip_rows` a tiny 2x2 body.""" @staticmethod def eye_color(color): return "#1b1812" if GrrPalette.light(color) else "#120f08" @staticmethod def rows(ui, bot, bg="bg", small=False, hero=False): shape = (bot or {}).get("shape") or "blob" color = (bot or {}).get("color") or ui.palette.c("accent") body = TuiStyle(fg=color, bg=ui.palette.c(bg)) dark = TuiStyle(fg=GrrAvatar.eye_color(color), bg=color) top, bottom = GRR_AVATAR_ROWS.get(shape, GRR_AVATAR_ROWS["blob"]) if small: top = top[0] + "E█E" + top[-1] bottom = bottom[0] + "E█E" + bottom[-1] templates = [top, bottom] if hero: mid = "█" + top[1:-1] + "█" templates = [top, mid, mid, bottom] out = [] for template in templates: spans = [] for ch in template: spans.append(TuiSpan("▌", dark) if ch == "E" else TuiSpan(ch, body)) out.append(tui_merge_spans(spans)) return out @staticmethod def chip(ui, bot, bg="bg"): color = (bot or {}).get("color") or ui.palette.c("accent") return [TuiSpan(" ▮ ▮ ", TuiStyle(fg=GrrAvatar.eye_color(color), bg=color))] @staticmethod def chip_rows(ui, bot, bg="bg"): color = (bot or {}).get("color") or ui.palette.c("accent") body = TuiStyle(fg=color, bg=ui.palette.c(bg)) return [[TuiSpan("▟▙", body)], [TuiSpan("▜▛", body)]] def tui_merge_spans(spans): """Join neighbouring spans that share a style into one.""" out = [] for span in spans: if out and out[-1].style is span.style: out[-1] = TuiSpan(out[-1].text + span.text, span.style) else: out.append(span) return out class GrrClient: """Blocking JSON client of the grr REST API on the standard library. Each call opens its own connection, so any thread may use it. Call it from a worker (`app.offload`), never from the loop.""" def __init__(self, base=GRR_DEFAULT_URL, token="", strings=None, timeout=GRR_HTTP_TIMEOUT): self.base = (base or GRR_DEFAULT_URL).rstrip("/") self.token = token self.strings = strings or GrrStrings() self.timeout = timeout self.on_unauthorized = None def _conn(self, timeout=None): parts = urllib.parse.urlsplit(self.base) host = parts.hostname or "127.0.0.1" cls = http.client.HTTPSConnection if parts.scheme == "https" else http.client.HTTPConnection return cls(host, parts.port or (443 if parts.scheme == "https" else 80), timeout=timeout or self.timeout) def request(self, method, path, body=None, raw=None, ctype="", timeout=None, quiet=False): headers = {"Accept": "application/json"} if self.token: headers["Authorization"] = "Bearer " + self.token payload = None if raw is not None: payload = raw headers["Content-Type"] = ctype or "application/octet-stream" elif body is not None: payload = json.dumps(body).encode("utf-8") headers["Content-Type"] = "application/json" conn = self._conn(timeout) try: conn.request(method, path, body=payload, headers=headers) res = conn.getresponse() data = res.read(GRR_BODY_MAX + 1) except (OSError, http.client.HTTPException) as exc: raise GrrError(0, "offline", self.strings.t("err.offline"), "", {"reason": str(exc)}) finally: conn.close() if len(data) > GRR_BODY_MAX: raise GrrError(res.status, "too_big", "The answer was larger than %s." % GrrFormat.bytes(GRR_BODY_MAX)) if res.status == 204: return {} try: parsed = json.loads(data.decode("utf-8")) if data else {} except ValueError: parsed = None if res.status >= 400: if res.status == 401 and not quiet and callable(self.on_unauthorized): self.on_unauthorized() raise self.strings.explain(res.status, parsed) return parsed if parsed is not None else {} def get(self, path, **kw): return self.request("GET", path, **kw) def post(self, path, body=None, **kw): return self.request("POST", path, {} if body is None else body, **kw) def patch(self, path, body, **kw): return self.request("PATCH", path, body, **kw) def put(self, path, body, **kw): return self.request("PUT", path, body, **kw) def delete(self, path, body=None, **kw): return self.request("DELETE", path, body, **kw) def upload(self, name, data, chat="", bot=""): query = urllib.parse.urlencode({k: v for k, v in (("name", name), ("chat", chat), ("bot", bot)) if v}) return self.request("POST", "/api/uploads?" + query, raw=data) def url(self, path): return self.base + path + ("&" if "?" in path else "?") + "token=" + urllib.parse.quote(self.token) def file_url(self, file_id, download=False): return self.url("/api/files/%s%s" % (file_id, "?download=1" if download else "")) def fetch(self, path, limit=GRR_VIEWER_MAX_BYTES): """The raw bytes of a file path, capped at limit, with the type.""" headers = {"Authorization": "Bearer " + self.token} if self.token else {} conn = self._conn() try: conn.request("GET", path, headers=headers) res = conn.getresponse() data = res.read(limit + 1) if res.status >= 400: raise GrrError(res.status, "http", "HTTP %d" % res.status) return data[:limit], len(data) > limit, res.getheader("Content-Type") or "" except (OSError, http.client.HTTPException) as exc: raise GrrError(0, "offline", self.strings.t("err.offline"), "", {"reason": str(exc)}) finally: conn.close() class GrrStream: """Server-sent events on a thread of its own. `on_event(name, data)` and `on_link(up)` are posted to the app loop through `post`. It reconnects every GRR_SSE_RETRY seconds until stop(); a line over GRR_SSE_LINE_MAX ends the connection (it is counted, never parsed).""" def __init__(self, client, path, on_event, post, on_link=None, reconnect=True): self.client = client self.path = path self.on_event = on_event self.on_link = on_link self.post = post self.reconnect = reconnect self.wanted = False self.thread = None self.conn = None self.oversize = 0 self.sock_lock = threading.Lock() def start(self): if self.thread is not None and self.thread.is_alive(): return self.wanted = True self.thread = threading.Thread(target=self.run, name="grr-stream", daemon=True) self.thread.start() def stop(self): self.wanted = False with self.sock_lock: conn = self.conn if conn is not None: try: if conn.sock is not None: conn.sock.shutdown(socket.SHUT_RDWR) except OSError: pass try: conn.close() except OSError: pass def run(self): while self.wanted: try: self.pump() except (OSError, http.client.HTTPException, ValueError): pass if self.on_link is not None: self.post(lambda: self.on_link(False)) if not self.reconnect: return waited = 0.0 while self.wanted and waited < GRR_SSE_RETRY: time.sleep(0.1) waited += 0.1 def pump(self): conn = self.client._conn(GRR_SSE_TIMEOUT) with self.sock_lock: self.conn = conn try: headers = {"Accept": "text/event-stream"} if self.client.token: headers["Authorization"] = "Bearer " + self.client.token conn.request("GET", self.path, headers=headers) res = conn.getresponse() if res.status != 200: return if self.on_link is not None: self.post(lambda: self.on_link(True)) name, data = "message", [] while self.wanted: line = res.readline(GRR_SSE_LINE_MAX + 1) if not line: return if len(line) > GRR_SSE_LINE_MAX: self.oversize += 1 return text = line.decode("utf-8", "replace").rstrip("\r\n") if not text: if data: self.emit(name, "\n".join(data)) name, data = "message", [] elif text.startswith(":"): continue elif text.startswith("event:"): name = text[6:].strip() elif text.startswith("data:"): data.append(text[5:].lstrip(" ")) finally: conn.close() def emit(self, name, raw): try: data = json.loads(raw) except ValueError: data = {"raw": raw} self.post(lambda: self.on_event(name, data)) class GrrSession: """The signed-in accounts and the server address, kept in `<TAI_HOME>/grr.json` (mode 0600). The token is the grr session token, not the DevPlace key.""" def __init__(self, path=None): home = os.environ.get("TAI_HOME") or os.path.join(os.path.expanduser("~"), ".tai") self.path = path or os.path.join(home, "grr.json") self.url = os.environ.get("GRR_URL") or GRR_DEFAULT_URL self.accounts = [] self.active = "" self.load() def load(self): try: with open(self.path, "r", encoding="utf-8") as fh: data = json.load(fh) except (OSError, ValueError): return if not os.environ.get("GRR_URL"): self.url = data.get("url") or self.url self.accounts = [a for a in data.get("accounts", []) if isinstance(a, dict) and a.get("token")] self.active = data.get("active") or "" def save(self): os.makedirs(os.path.dirname(self.path), exist_ok=True) tmp = self.path + ".tmp" fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w", encoding="utf-8") as fh: json.dump({"url": self.url, "accounts": self.accounts, "active": self.active}, fh) os.replace(tmp, self.path) @property def token(self): for account in self.accounts: if account["token"] == self.active: return self.active return self.accounts[0]["token"] if self.accounts else "" def add(self, token, account): self.accounts = [a for a in self.accounts if a.get("username") != account.get("username")] self.accounts.append({"token": token, "username": account.get("username", ""), "email": account.get("email", "")}) self.active = token self.save() def switch(self, token): self.active = token self.save() def drop(self, token): self.accounts = [a for a in self.accounts if a["token"] != token] if self.active == token: self.active = self.accounts[0]["token"] if self.accounts else "" self.save() class GrrEmitter: """Tiny publish and subscribe. A handler that raises is counted in `errors` and never stops the others.""" def __init__(self): self.handlers = {} self.errors = 0 def on(self, kind, fn): self.handlers.setdefault(kind, []).append(fn) return lambda: self.off(kind, fn) def off(self, kind, fn): if fn in self.handlers.get(kind, []): self.handlers[kind].remove(fn) def emit(self, kind, data=None): for fn in list(self.handlers.get(kind, [])): try: fn(data) except Exception: self.errors += 1 class GrrStore(GrrEmitter): """Bots, chats and live working status, kept fresh by the event stream. All state is touched on the loop thread only.""" def __init__(self, app, client): super().__init__() self.app = app self.client = client self.bots = [] self.chats = [] self.status = {} self.turn = 0 self.pending = None def refresh(self, done=None): self.turn += 1 turn = self.turn def work(): return self.client.get("/api/bots"), self.client.get("/api/chats") def landed(result, error): if error is None and turn == self.turn: self.bots = result[0].get("bots", []) self.chats = result[1].get("chats", []) self.emit("changed") if done is not None: done(error) self.app.offload(work, landed) def soon(self): if self.pending is not None: return self.pending = self.app.after(0.15, self._fire) def _fire(self): self.pending = None self.refresh() def bot(self, bot_id): for bot in self.bots: if bot["id"] == bot_id: return bot return None def chat(self, chat_id): for chat in self.chats: if chat["id"] == chat_id: return chat return None def status_of(self, chat_id, bot_id=""): return self.status.get("%s:%s" % (chat_id, bot_id)) def set_status(self, data): key = "%s:%s" % (data.get("chat_id"), data.get("bot_id") or "") if data.get("state") == "idle": self.status.pop(key, None) else: self.status[key] = data self.emit("status", data) def is_working(self, bot_id): for key, value in self.status.items(): if key.endswith(":" + bot_id) and value.get("state") != "idle": return value return None def active_in(self, chat_id): return [v for v in self.status.values() if v.get("chat_id") == chat_id and v.get("state") != "idle"] class GrrRoute: """A path pattern like `/bot/:id/skills` and the view factory it opens.""" def __init__(self, pattern, factory): self.pattern = pattern self.factory = factory self.names = re.findall(r":([a-z_]+)", pattern) self.rx = re.compile("^" + re.sub(r":[a-z_]+", "([^/]+)", pattern) + "$") class GrrRouter: """Hash-style routes with a back stack, like the web app's router. The stack is capped at 60. `on_route(route, params, query, raw)` fires on every change.""" STACK_MAX = 60 def __init__(self, on_route): self.routes = [] self.stack = [] self.on_route = on_route def add(self, pattern, factory): self.routes.append(GrrRoute(pattern, factory)) @staticmethod def parse(raw): raw = (raw or "/").lstrip("#") or "/" path, _, qs = raw.partition("?") query = {k: v[-1] for k, v in urllib.parse.parse_qs(qs).items()} return path or "/", query def match(self, path): for route in self.routes: m = route.rx.match(path) if m: return route, {n: urllib.parse.unquote(m.group(i + 1)) for i, n in enumerate(route.names)} return None, {} def go(self, raw, replace=False): raw = raw or "/" if replace and self.stack: self.stack[-1] = raw elif len(self.stack) >= 2 and self.stack[-2] == raw: self.stack.pop() elif not self.stack or self.stack[-1] != raw: self.stack.append(raw) if len(self.stack) > GrrRouter.STACK_MAX: del self.stack[0] self.fire(raw) def fire(self, raw): path, query = GrrRouter.parse(raw) route, params = self.match(path) if route is None: route, params = self.match("/") self.on_route(route, params, query, raw) def back(self, fallback="/"): if len(self.stack) > 1: self.stack.pop() self.fire(self.stack[-1]) else: self.go(fallback, replace=True) @property def current(self): return self.stack[-1] if self.stack else "/" # --------------------------------------------------------------------------- # Part B: the page engine. A screen body is a list of items (rows in cards, # conversation rows, buttons, tabs, tiles ...). The page scrolls them, moves a # selection with the keyboard and routes clicks to the item under the pointer. # --------------------------------------------------------------------------- def grr_text(value): """A server value as text: None is empty, anything else its string.""" return "" if value is None else str(value) def grr_clip(text, width): text = str(text).replace("\n", " ") if width <= 0: return "" return text if len(text) <= width else text[:max(0, width - 1)] + "…" def grr_wrap(text, width): lines = [] for para in str(text).split("\n"): lines.extend(textwrap.wrap(para, max(1, width), replace_whitespace=False, drop_whitespace=True, break_long_words=True) or [""]) return lines def grr_line(spans, width, fill): return tui_slice_spans(list(spans), 0, width, fill) def grr_margin(width): return 1 if width < 48 else 2 class GrrCard: """The rounded card every grouped row sits in. Top and bottom edges are half-height rows with quarter-block corners, the sides are half blocks, so the card reads as rounded in any terminal font.""" @staticmethod def inner(width): return max(1, width - 2 * grr_margin(width) - 2) @staticmethod def top(ui, width, bg="card"): m = grr_margin(width) cw = max(2, width - 2 * m) return grr_line([TuiSpan(" " * m + "▗" + "▄" * (cw - 2) + "▖", ui.st(bg, "bg"))], width, ui.st("ink", "bg")) @staticmethod def bottom(ui, width, bg="card"): m = grr_margin(width) cw = max(2, width - 2 * m) return grr_line([TuiSpan(" " * m + "▝" + "▀" * (cw - 2) + "▘", ui.st(bg, "bg"))], width, ui.st("ink", "bg")) @staticmethod def line(ui, width, spans, bg="card"): m = grr_margin(width) page = ui.st("ink", "bg") edge = ui.st(bg, "bg") body = grr_line(list(spans), GrrCard.inner(width), ui.st("ink", bg)) return [TuiSpan(" " * m, page), TuiSpan("▐", edge)] + body + [TuiSpan("▌", edge), TuiSpan(" " * m, page)] class GrrItem: """One block of a page. `build(ui, width, selected)` returns span rows that are exactly `width` cells wide. Rows are cached until the item's version, the width, the selection or the palette changes. `hit(...)` registers clickable regions during build: (row, x0, x1, callback).""" selectable = False key = None def __init__(self): self.page = None self.version = 0 self.stop = 0 self.stops = 1 self._cache = None self._hits = [] def touch(self): self.version += 1 if self.page is not None: self.page.item_changed(self) def rows(self, ui, width, selected): key = (ui.version, width, selected, self.version, self.stop) if self._cache is not None and self._cache[0] == key: return self._cache[1] self._hits = [] rows = self.build(ui, width, selected) self._cache = (key, rows, self._hits) return rows def build(self, ui, width, selected): return [] def hit(self, row, x0, x1, fn): self._hits.append((row, x0, x1, fn)) def hit_at(self, ui, width, row, x): self.rows(ui, width, False) for hit_row, x0, x1, fn in self._cache[2]: if hit_row == row and x0 <= x < x1: return fn return None def ident(self): """What identifies this item when a page is rebuilt: its key, or its kind and words, so the selection stays where the user put it.""" if self.key is not None: return self.key return (type(self).__name__, str(getattr(self, "title", None) or getattr(self, "text", None) or getattr(self, "label", None) or "")) def activate(self): action = getattr(self, "action", None) if callable(action): action() def nav(self, direction): return False class GrrSpacer(GrrItem): def __init__(self, n=1): super().__init__() self.n = n def build(self, ui, width, selected): return [[TuiSpan(" " * width, ui.st())] for _ in range(self.n)] class GrrLabel(GrrItem): """A small muted caption above a group, like Character or Planning.""" def __init__(self, text): super().__init__() self.text = text def build(self, ui, width, selected): m = grr_margin(width) + 1 return [grr_line([TuiSpan(" " * m + grr_clip(self.text, width - m * 2), ui.st("muted"))], width, ui.st())] class GrrHeading(GrrItem): def __init__(self, text, center=False): super().__init__() self.text = text self.center = center def build(self, ui, width, selected): m = grr_margin(width) text = grr_clip(self.text, width - 2 * m) pad = (width - len(text)) // 2 if self.center else m return [grr_line([TuiSpan(" " * pad + text, ui.st("ink", bold=True))], width, ui.st())] class GrrNote(GrrItem): def __init__(self, text, center=False, tone="faint"): super().__init__() self.text = text self.center = center self.tone = tone def build(self, ui, width, selected): m = grr_margin(width) + 1 out = [] for line in grr_wrap(self.text, width - 2 * m): pad = (width - len(line)) // 2 if self.center else m out.append(grr_line([TuiSpan(" " * pad + line, ui.st(self.tone))], width, ui.st())) return out class GrrRowItem(GrrItem): """A row inside a card group: icon or lead, title, sub, value and a switch, check or chevron on the right. Activate runs `action`, or flips the switch when `toggle` is given.""" def __init__(self, title, sub="", icon="", lead=None, value="", chevron=False, check=False, toggle=None, on_toggle=None, action=None, danger=False, link=False, wrap=False, pos="only", key=None, tone=""): super().__init__() self.title = title self.sub = sub self.icon = icon self.lead = lead self.value = value self.chevron = chevron self.check = check self.toggle = toggle self.on_toggle = on_toggle self.action = action self.danger = danger self.link = link self.wrap = wrap self.pos = pos self.key = key self.tone = tone self.selectable = action is not None or toggle is not None def set_toggle(self, on): self.toggle = on self.touch() def activate(self): if self.toggle is not None and self.on_toggle is not None: nxt = not self.toggle self.toggle = nxt self.touch() self.on_toggle(nxt) elif callable(self.action): self.action() def build(self, ui, width, selected): inner = GrrCard.inner(width) bg = "rule-strong" if selected else "card" base = ui.st("ink", bg) title_fg = "danger" if self.danger else ("accent" if self.link else "ink") lead = list(self.lead(ui, bg)) if callable(self.lead) else [] lead_w = tui_span_width(lead) icon = (ui.icon(self.icon) + " ") if self.icon else "" right = [] if self.value: right.append(TuiSpan(grr_clip(self.value, max(6, inner // 2)) + " ", ui.st("muted", bg))) if self.toggle is not None: on = bool(self.toggle) edge = ui.st("accent" if on else "rule-strong", bg) pill = TuiStyle(fg=ui.palette.c("accent-ink" if on else "muted"), bg=ui.palette.c("accent" if on else "rule-strong")) right += [TuiSpan("▐", edge), TuiSpan(" ●" if on else "● ", pill), TuiSpan("▌", edge), TuiSpan(" ", base)] if self.check: right.append(TuiSpan(ui.icon("check") + " ", ui.st("ink", bg, bold=True))) if self.chevron: right.append(TuiSpan(ui.icon("external" if self.chevron == "ext" else "right") + " ", ui.st("muted", bg))) right_w = tui_span_width(right) lead_cols = len(icon) + lead_w title_w = max(4, inner - 1 - lead_cols - right_w) lines = grr_wrap(self.title, title_w)[:3] if self.wrap else [grr_clip(self.title, title_w)] subs = grr_wrap(self.sub, inner - 1 - lead_cols - 1)[:2] if self.sub else [] body = [] for pos, text in enumerate(lines): spans = [TuiSpan(" ", base)] if pos == 0: if icon: spans.append(TuiSpan(icon, ui.st("soft", bg))) spans.extend(lead) else: spans.append(TuiSpan(" " * lead_cols, base)) spans.append(TuiSpan(text, ui.st(title_fg, bg, bold=selected and not self.danger))) body.append(grr_line(spans, inner - right_w, base) + (right if pos == 0 else [TuiSpan(" " * right_w, base)])) for text in subs: body.append(grr_line([TuiSpan(" " + " " * lead_cols + text, ui.st(self.tone or "muted", bg))], inner, base)) out = [] if self.pos in ("first", "only"): out.append(GrrCard.top(ui, width)) for row in body: out.append(GrrCard.line(ui, width, row, bg)) if self.pos in ("last", "only"): out.append(GrrCard.bottom(ui, width)) return out def grr_group(rows): """Mark the rows of one card as first, middle and last, return them.""" rows = [r for r in rows if r is not None] for pos, row in enumerate(rows): row.pos = "only" if len(rows) == 1 else ("first" if pos == 0 else ("last" if pos == len(rows) - 1 else "mid")) return rows class GrrConvItem(GrrItem): """A conversation row: avatar, name, time, preview, unread dot.""" selectable = True def __init__(self, avatar, title, time_text="", preview="", dot=False, tag="", current=False, action=None, menu=None, key=None, busy=False): super().__init__() self.avatar = avatar self.title = title self.time_text = time_text self.preview = preview self.dot = dot self.tag = tag self.current = current self.action = action self.menu = menu self.key = key self.busy = busy def build(self, ui, width, selected): m = grr_margin(width) bg = "rule-strong" if selected else ("card" if self.current else "bg") base = ui.st("ink", bg) art = self.avatar(ui, bg) if callable(self.avatar) else [[TuiSpan(" ", base)], [TuiSpan(" ", base)]] art_w = tui_span_width(art[0]) text_w = width - 2 * m - art_w - 2 stamp = self.time_text tag = (" " + self.tag + " ") if self.tag else "" name_w = max(4, text_w - len(stamp) - 1 - len(tag)) top = [TuiSpan(grr_clip(self.title, name_w), ui.st("ink", bg, bold=True))] if tag: top.append(TuiSpan(tag, ui.st("muted", "rule", ))) top = grr_line(top, text_w - len(stamp), base) + [TuiSpan(stamp, ui.st("muted", bg))] preview = grr_clip(self.preview, text_w - (2 if self.dot or self.busy else 0)) sub = [TuiSpan(preview, ui.st("accent" if self.busy else "muted", bg))] sub = grr_line(sub, text_w - 2, base) + [TuiSpan(" ", base), TuiSpan("●" if (self.dot or self.busy) else " ", ui.st("accent" if self.dot else "success", bg))] out = [] for pos, art_row in enumerate(art[:2]): text = top if pos == 0 else sub row = [TuiSpan(" " * m, ui.st("ink", "bg"))] + list(art_row) + [TuiSpan(" ", base)] + text + [TuiSpan(" " * m, ui.st("ink", "bg"))] out.append(grr_line(row, width, ui.st("ink", "bg"))) if self.menu is not None: self.hit(0, width - m - 2, width, self.menu) return out class GrrSectionHead(GrrItem): def __init__(self, text, icon="", action=None, aria=""): super().__init__() self.text = text self.icon = icon self.action = action self.selectable = action is not None def build(self, ui, width, selected): m = grr_margin(width) bg = "rule-strong" if selected else "bg" right = (" " + ui.icon(self.icon) + " ") if self.icon else "" row = grr_line([TuiSpan(" " * m + self.text, ui.st("muted", bg, bold=True))], width - len(right) - m, ui.st("ink", bg)) row += [TuiSpan(right, ui.st("soft", bg)), TuiSpan(" " * m, ui.st("ink", bg))] if right and self.action is not None: self.hit(0, width - m - len(right), width, self.action) return [row] class GrrButtonItem(GrrItem): """A pill button: three rows (cap, label, cap) so the ends look round.""" def __init__(self, label, action, kind="primary", inline=False, icon="", disabled=False): super().__init__() self.label = label self.action = action self.kind = kind self.inline = inline self.icon = icon self.disabled = disabled self.selectable = not disabled def build(self, ui, width, selected): m = grr_margin(width) text = ((ui.icon(self.icon) + " ") if self.icon else "") + self.label if self.kind == "primary": fg, bg = "accent-ink", "accent" elif self.kind == "danger": fg, bg = "danger", "card" elif self.kind == "ghost": fg, bg = "muted", "bg" else: fg, bg = "ink", "card" if selected: bg = "ink" if self.kind in ("primary",) else "rule-strong" fg = "bg" if self.kind in ("primary",) else fg cw = min(width - 2 * m, len(text) + 8) if self.inline else width - 2 * m lead = (width - cw) // 2 page = ui.st("ink", "bg") edge = ui.st(bg, "bg") body = ui.st(fg, bg, bold=True, dim=self.disabled) out = [grr_line([TuiSpan(" " * lead + "▗" + "▄" * (cw - 2) + "▖", edge)], width, page), grr_line([TuiSpan(" " * lead, page), TuiSpan(text.center(cw)[:cw], body)], width, page), grr_line([TuiSpan(" " * lead + "▝" + "▀" * (cw - 2) + "▘", edge)], width, page)] if not self.disabled: for pos in range(3): self.hit(pos, lead, lead + cw, self.action) return out class GrrTextCard(GrrItem): """Text in a card, wrapped. Selectable when it has an action (edit).""" def __init__(self, text, action=None, placeholder="", mono=False, label=""): super().__init__() self.text = text self.action = action self.placeholder = placeholder self.mono = mono self.label = label self.selectable = action is not None def build(self, ui, width, selected): inner = GrrCard.inner(width) bg = "rule-strong" if selected else "card" text = self.text if self.text else self.placeholder tone = "ink" if self.text else "muted" lines = grr_wrap(text, inner - 2) if len(lines) > 24: lines = lines[:24] + ["…"] out = [GrrCard.top(ui, width, bg)] for line in lines: out.append(GrrCard.line(ui, width, [TuiSpan(" " + line, ui.st(tone, bg))], bg)) out.append(GrrCard.bottom(ui, width, bg)) return out class GrrTabsItem(GrrItem): """Tabs along the top of a body. When the labels do not fit the width they fold into a single dropdown chip, like the web app does on a phone.""" selectable = True def __init__(self, tabs, value, on_pick): super().__init__() self.tabs = list(tabs) self.value = value self.on_pick = on_pick self.stops = len(self.tabs) def pick(self, tab_id): self.value = tab_id self.touch() self.on_pick(tab_id) def index(self): for pos, tab in enumerate(self.tabs): if tab["id"] == self.value: return pos return 0 def nav(self, direction): if direction in ("left", "right"): pos = max(0, min(len(self.tabs) - 1, self.index() + (1 if direction == "right" else -1))) if self.tabs[pos]["id"] != self.value: self.pick(self.tabs[pos]["id"]) return True return False def menu(self): app = self.page.app if self.page is not None else None if app is not None and getattr(app, "grr", None) is not None: app.grr.menu([{"label": t["label"], "checked": t["id"] == self.value, "action": (lambda i=t["id"]: self.pick(i))} for t in self.tabs]) def activate(self): if self._folded: self.menu() _folded = False def build(self, ui, width, selected): m = grr_margin(width) need = sum(len(t["label"]) + 4 for t in self.tabs) page = ui.st("ink", "bg") self._folded = need > width - 2 * m if self._folded: label = next((t["label"] for t in self.tabs if t["id"] == self.value), "") text = " %s %s " % (label, ui.icon("down")) row = [TuiSpan(" " * m, page), TuiSpan(text, ui.st("ink", "rule-strong" if selected else "card", bold=True))] self.hit(0, m, m + len(text), self.menu) return [grr_line(row, width, page), [TuiSpan(" " * width, page)]] spans, under, x = [TuiSpan(" " * m, page)], [TuiSpan(" " * m, page)], m for tab in self.tabs: on = tab["id"] == self.value text = " %s " % tab["label"] focus = selected and on spans.append(TuiSpan(text + " ", ui.st("ink" if on else "muted", "rule-strong" if focus else "bg", bold=on))) under.append(TuiSpan(("▀" * len(text)) if on else (" " * len(text)), ui.st("accent", "bg"))) under.append(TuiSpan(" ", page)) self.hit(0, x, x + len(text), (lambda i=tab["id"]: self.pick(i))) x += len(text) + 2 return [grr_line(spans, width, page), grr_line(under, width, page)] class GrrSegmentedItem(GrrItem): """A pill group of exclusive choices (Every, Daily, Weekly).""" selectable = True def __init__(self, items, value, on_pick): super().__init__() self.items = list(items) self.value = value self.on_pick = on_pick def pick(self, item_id): self.value = item_id self.touch() self.on_pick(item_id) def nav(self, direction): if direction in ("left", "right"): ids = [i["id"] for i in self.items] pos = max(0, min(len(ids) - 1, ids.index(self.value) + (1 if direction == "right" else -1))) if self.value in ids else 0 self.pick(ids[pos]) return True return False def build(self, ui, width, selected): m = grr_margin(width) page = ui.st("ink", "bg") each = max(6, (width - 2 * m) // max(1, len(self.items))) spans, x = [TuiSpan(" " * m, page)], m for item in self.items: on = item["id"] == self.value spans.append(TuiSpan(grr_clip(item["label"], each - 2).center(each), ui.st("accent-ink" if on else "ink", "accent" if on else "card", bold=on))) self.hit(1, x, x + each, (lambda i=item["id"]: self.pick(i))) x += each edge_up = [TuiSpan(" " * m, page)] + [TuiSpan("▄" * each, ui.st("accent" if i["id"] == self.value else "card", "bg")) for i in self.items] edge_dn = [TuiSpan(" " * m, page)] + [TuiSpan("▀" * each, ui.st("accent" if i["id"] == self.value else "card", "bg")) for i in self.items] return [grr_line(edge_up, width, page), grr_line(spans, width, page), grr_line(edge_dn, width, page)] class GrrChipsItem(GrrItem): """A row of chips; each opens a menu or runs an action. Used for mode, tier, sort and filter dropdowns.""" selectable = True def __init__(self, chips): super().__init__() self.chips = list(chips) self.stops = len(self.chips) def nav(self, direction): if direction in ("left", "right"): nxt = max(0, min(len(self.chips) - 1, self.stop + (1 if direction == "right" else -1))) if nxt != self.stop: self.stop = nxt self.touch() return True return False def activate(self): chip = self.chips[min(self.stop, len(self.chips) - 1)] chip["action"]() def build(self, ui, width, selected): m = grr_margin(width) page = ui.st("ink", "bg") spans, x = [TuiSpan(" " * m, page)], m for pos, chip in enumerate(self.chips): label = ((ui.icon(chip["icon"]) + " ") if chip.get("icon") else "") + chip["label"] + (" " + ui.icon("down") if chip.get("caret", True) else "") text = " %s " % label focus = selected and pos == self.stop spans.append(TuiSpan(text, ui.st("ink", "rule-strong" if focus else "card", bold=focus))) spans.append(TuiSpan(" ", page)) self.hit(0, x, x + len(text), chip["action"]) x += len(text) + 1 return [grr_line(spans, width, page)] class GrrDividerItem(GrrItem): def __init__(self, text, accent=False): super().__init__() self.text = text self.accent = accent def build(self, ui, width, selected): label = " %s " % self.text side = max(0, (width - len(label)) // 2) tone = "accent" if self.accent else "muted" spans = [TuiSpan("─" * side if self.accent else " " * side, ui.st("accent", "bg")), TuiSpan(label, ui.st(tone, "bg", bold=self.accent))] spans.append(TuiSpan("─" * (width - side - len(label)) if self.accent else "", ui.st("accent", "bg"))) return [grr_line(spans, width, ui.st("ink", "bg"))] class GrrEmptyItem(GrrItem): def __init__(self, icon, text, sub=""): super().__init__() self.icon = icon self.text = text self.sub = sub def build(self, ui, width, selected): page = ui.st("ink", "bg") out = [[TuiSpan(" " * width, page)] for _ in range(2)] out.append(grr_line([TuiSpan((" %s " % ui.icon(self.icon)).center(width), ui.st("muted", "bg", bold=True))], width, page)) out.append([TuiSpan(" " * width, page)]) for line in grr_wrap(self.text, width - 6): out.append(grr_line([TuiSpan(line.center(width), ui.st("ink", "bg", bold=True))], width, page)) for line in (grr_wrap(self.sub, width - 8) if self.sub else []): out.append(grr_line([TuiSpan(line.center(width), ui.st("muted", "bg"))], width, page)) return out class GrrKvItem(GrrItem): """Key and value rows in one card (usage details, server health).""" def __init__(self, pairs, tone=None): super().__init__() self.pairs = list(pairs) self.tone = tone or {} def build(self, ui, width, selected): inner = GrrCard.inner(width) out = [GrrCard.top(ui, width)] for key, value in self.pairs: value = str(value) left = grr_clip(key, max(4, inner - len(value) - 4)) row = [TuiSpan(" " + left, ui.st("ink", "card")), TuiSpan(" " * max(1, inner - 2 - len(left) - len(value)), ui.st("ink", "card")), TuiSpan(value + " ", ui.st(self.tone.get(key, "muted"), "card"))] out.append(GrrCard.line(ui, width, row)) out.append(GrrCard.bottom(ui, width)) return out class GrrProgressItem(GrrItem): def __init__(self, label, pct, right="", foot="", level=""): super().__init__() self.label = label self.pct = max(0.0, float(pct or 0)) self.right = right self.foot = foot self.level = level def build(self, ui, width, selected): inner = GrrCard.inner(width) base = ui.st("ink", "card") tone = "danger" if self.pct >= 100 else ("accent" if self.pct >= 80 else "info") bar_w = max(4, inner - 4) filled = int(round(min(100.0, self.pct) / 100.0 * bar_w)) head = grr_line([TuiSpan(" " + self.label, ui.st("ink", "card", bold=True))], inner - len(self.right) - 1, base) + [TuiSpan(self.right + " ", ui.st("muted", "card"))] bar = [TuiSpan(" ", base), TuiSpan("█" * filled, ui.st(tone, "card")), TuiSpan("▒" * (bar_w - filled), ui.st("rule-strong", "card")), TuiSpan(" ", base)] rows = [head, bar] if self.foot: rows.append([TuiSpan(" " + self.foot, ui.st("muted", "card"))]) out = [GrrCard.top(ui, width)] for row in rows: out.append(GrrCard.line(ui, width, row)) out.append(GrrCard.bottom(ui, width)) return out class GrrBarsItem(GrrItem): """A small column chart of the last days of usage.""" def __init__(self, days): super().__init__() self.days = list(days) def build(self, ui, width, selected): inner = GrrCard.inner(width) peak = max([d.get("tokens", 0) for d in self.days] + [1]) levels = " ▁▂▃▄▅▆▇█" cell = max(3, min(8, (inner - 2) // max(1, len(self.days)))) top = [TuiSpan(" ", ui.st("ink", "card"))] + [TuiSpan((levels[min(8, max(1, int(round(d.get("tokens", 0) / float(peak) * 8))))] * (cell - 1)) + " ", ui.st("accent", "card")) for d in self.days] low = [TuiSpan(" ", ui.st("ink", "card"))] + [TuiSpan(str(d.get("day", ""))[5:].ljust(cell), ui.st("muted", "card")) for d in self.days] return [GrrCard.top(ui, width), GrrCard.line(ui, width, top), GrrCard.line(ui, width, low), GrrCard.bottom(ui, width)] class GrrTileGridItem(GrrItem): """Tiles in columns (library, themes, shapes): a box with an icon, a name and a sub line. Arrows move between tiles.""" selectable = True def __init__(self, tiles, cols=0, tile_h=4, min_w=22, max_cols=4, on_pick=None): super().__init__() self.tiles = list(tiles) self.cols_wanted = cols self.tile_h = tile_h self.min_w = min_w self.max_cols = max_cols self.on_pick = on_pick self.stops = max(1, len(self.tiles)) self.cols = 1 def nav(self, direction): step = {"left": -1, "right": 1, "up": -self.cols, "down": self.cols}.get(direction) if step is None: return False nxt = self.stop + step if 0 <= nxt < len(self.tiles): self.stop = nxt self.touch() return True return False def activate(self): if self.tiles: tile = self.tiles[min(self.stop, len(self.tiles) - 1)] (tile.get("action") or (lambda: None))() def build(self, ui, width, selected): m = grr_margin(width) gap = 1 cols = self.cols_wanted or max(1, min(self.max_cols, (width - 2 * m + gap) // (self.min_w + gap))) self.cols = cols tw = (width - 2 * m - gap * (cols - 1)) // cols page = ui.st("ink", "bg") out = [] for start in range(0, len(self.tiles), cols): chunk = self.tiles[start:start + cols] lines = [[TuiSpan(" " * m, page)] for _ in range(self.tile_h)] for off, tile in enumerate(chunk): focus = selected and start + off == self.stop on = tile.get("on") bg = "rule-strong" if focus else "card" edge = ui.st("accent" if on else bg, "bg") body = ui.st("ink", bg) art = tile.get("art") shown = art(ui, bg) if callable(art) else [[TuiSpan(ui.icon(tile.get("icon", "file")).center(tw), ui.st("soft", bg, bold=True))]] art_rows = [] for part in shown: pad = max(0, (tw - tui_span_width(part)) // 2) art_rows.append(grr_line([TuiSpan(" " * pad, body)] + list(part), tw, body)) rows = [[TuiSpan("▗" + "▄" * (tw - 2) + "▖", edge)]] + art_rows if tile.get("label"): rows.append(grr_line([TuiSpan(grr_clip(tile["label"], tw - 2).center(tw), ui.st("ink", bg, bold=on))], tw, body)) if tile.get("sub") and self.tile_h >= 6: rows.append(grr_line([TuiSpan(grr_clip(tile["sub"], tw - 2).center(tw), ui.st("muted", bg))], tw, body)) rows.append([TuiSpan("▝" + "▀" * (tw - 2) + "▘", edge)]) for pos in range(self.tile_h): cell = rows[pos] if pos < len(rows) else [TuiSpan(" " * tw, page)] lines[pos].extend(cell) lines[pos].append(TuiSpan(" " * gap, page)) self.hit(len(out) + pos, m + off * (tw + gap), m + off * (tw + gap) + tw, tile.get("action") or (lambda: None)) for line in lines: out.append(grr_line(line, width, page)) return out class GrrPage(TuiPanel): """A scrolling list of items with a keyboard selection. Up and Down move the selection (an item may take Left and Right first), Enter or Space activates, the wheel and PageUp/PageDown scroll, a click activates the item under the pointer or the region it registered. `stick` keeps the view on the newest row while the user is at the bottom.""" def __init__(self, ui, stick=False, auto_select=True, **kwargs): kwargs.setdefault("focusable", True) super().__init__(**kwargs) self.ui = ui self._items = [] self._sel = -1 self._top = 0 self._stick = stick self._follow = True self._auto = auto_select self._starts = [] self._total = 0 self._width = -1 self._dirty = True self._visible = [] self._reveal = False self.on_bottom = None @property def items(self): return self._items def set_items(self, items): keep = self._items[self._sel].ident() if 0 <= self._sel < len(self._items) else None for item in self._items: item.page = None self._items = list(items) for item in self._items: item.page = self self._sel = -1 if keep is not None: for pos, item in enumerate(self._items): if item.selectable and item.ident() == keep: self._sel = pos break if self._sel < 0 and self._auto: self._sel = self._first_selectable() self._dirty = True self.touch() def append(self, item): item.page = self self._items.append(item) self._dirty = True self.touch() def item_changed(self, item): self._dirty = True self.touch() def _first_selectable(self): for pos, item in enumerate(self._items): if item.selectable: return pos return -1 def selectables(self): return [pos for pos, item in enumerate(self._items) if item.selectable] def _layout(self, width): if not self._dirty and width == self._width: return self._width = width self._dirty = False self._starts = [] row = 0 for item in self._items: self._starts.append(row) row += len(item.rows(self.ui, width, False)) self._total = row def total_rows(self): self._layout(self._content_w()) return self._total def scroll_to_end(self): self._follow = True self._top = max(0, self.total_rows() - self._content_h()) self.touch() def at_bottom(self): return self._top >= max(0, self._total - self._content_h()) def scroll_by(self, delta): height = self._content_h() self._layout(self._content_w()) self._top = max(0, min(max(0, self._total - height), self._top + delta)) self._follow = self._top >= max(0, self._total - height) self.touch() if self.on_bottom is not None: self.on_bottom(self._follow) def content_rows(self): width, height = self._content_w(), self._content_h() self._layout(width) ui = self.ui focused = self.app is not None and self.app.focused is self if self._stick and self._follow: self._top = max(0, self._total - height) if self._reveal and 0 <= self._sel < len(self._items): start = self._starts[self._sel] end = start + len(self._items[self._sel].rows(ui, width, False)) if start < self._top: self._top = start elif end > self._top + height: self._top = min(start, end - height) self._reveal = False self._top = max(0, min(self._top, max(0, self._total - height))) rows, visible = [], [] pos = self._first_at(self._top) blank = ui.st("ink", "bg") while pos < len(self._items) and len(rows) < height: item = self._items[pos] sel = focused and pos == self._sel built = item.rows(ui, width, sel) skip = max(0, self._top - self._starts[pos]) if not rows else 0 take = built[skip:skip + (height - len(rows))] visible.append((len(rows), pos, skip, len(take))) rows.extend(take) pos += 1 self._visible = visible while len(rows) < height: rows.append([TuiSpan(" " * width, blank)]) return rows def _first_at(self, row): lo, hi = 0, len(self._starts) while lo < hi: mid = (lo + hi) // 2 if self._starts[mid] + len(self._items[mid].rows(self.ui, self._width, False)) <= row: lo = mid + 1 else: hi = mid return lo def _select(self, pos): if pos != self._sel: self._sel = pos self._reveal = True self.touch() def move(self, delta): if 0 <= self._sel < len(self._items) and self._items[self._sel].nav("down" if delta > 0 else "up"): self._reveal = True self.touch() return True order = self.selectables() if not order: self.scroll_by(delta * 3) return False if self._sel not in order: self._select(order[0] if delta > 0 else order[-1]) return True at = order.index(self._sel) + delta if 0 <= at < len(order): self._select(order[at]) return True return False def focus_last(self): order = self.selectables() if order and self._sel < 0: self._select(order[-1]) def on_key(self, event): key = event.key or "" char = getattr(event, "char", "") or "" if key == "up": event.stop() self.move(-1) elif key == "down": event.stop() self.move(1) elif key in ("left", "right") and 0 <= self._sel < len(self._items): if self._items[self._sel].nav(key): event.stop() self._reveal = True self.touch() elif key in ("enter", "space") or (char == " " and not key.startswith("ctrl")): if 0 <= self._sel < len(self._items): event.stop() self._items[self._sel].activate() elif key == "pageup": event.stop() self.scroll_by(-max(1, self._content_h() - 1)) elif key == "pagedown": event.stop() self.scroll_by(max(1, self._content_h() - 1)) elif key == "home": event.stop() order = self.selectables() if order: self._select(order[0]) self._top = 0 self._follow = False self.touch() elif key == "end": event.stop() order = self.selectables() if order: self._select(order[-1]) self.scroll_to_end() def on_scroll(self, event): event.stop() self.scroll_by(-3 if event.direction == "up" else 3) def on_click(self, event): if not event.pressed: return event.stop() local_y = event.y - self.ay local_x = event.x - self.ax for start, pos, skip, count in self._visible: if start <= local_y < start + count: item = self._items[pos] row = skip + local_y - start fn = item.hit_at(self.ui, self._width, row, local_x) if fn is not None: if item.selectable: self._sel = pos fn() return if item.selectable: self._sel = pos self._reveal = False self.touch() item.activate() return # --------------------------------------------------------------------------- # Part C: bars, overlays and the shell (boot, login, layout, routes, events). # --------------------------------------------------------------------------- GRR_KEYS = ( ("Esc", "back, or close the open dialog or menu"), ("Up Down", "move the selection"), ("Left Right", "move inside tabs, chips, tiles and card buttons"), ("Enter, Space", "open the selected row, press the selected button, flip the selected switch"), ("PageUp PageDown", "scroll the page or the conversation"), ("Home End", "first or last row, End jumps to the newest message"), ("Tab, Shift+Tab", "next or previous area"), ("Ctrl+N", "new chat"), ("Ctrl+F", "search"), ("Ctrl+G", "settings"), ("Ctrl+O, F10", "the More menu of the screen"), ("Ctrl+P", "the side pane of a bot chat"), ("Ctrl+]", "leave the terminal of a bot computer"), ("F1", "key help"), ("Ctrl+Q", "quit"), ) class GrrBar(TuiPanel): """One row of clickable parts placed left to right. A part is a dict: text or spans, fn, fg, bg, bold, fill (takes the leftover width) and align ("center" for a title). Page headers and the bottom bar use it.""" def __init__(self, ui, **kwargs): kwargs.setdefault("h", 1) super().__init__(**kwargs) self.ui = ui self._parts = [] self._bg = "bg" self._regions = [] def set(self, parts, bg="bg"): self._parts = list(parts) self._bg = bg self.touch() def content_rows(self): width = self._content_w() ui = self.ui base = ui.st("ink", self._bg) self._regions = [] sized = [] for part in self._parts: spans = part.get("spans") if spans is None and not part.get("fill"): spans = [TuiSpan(part.get("text", ""), ui.st(part.get("fg", "ink"), part.get("bg", self._bg), bold=part.get("bold", False)))] sized.append((part, spans, tui_span_width(spans) if spans is not None else 0)) fixed = sum(w + 1 for part, spans, w in sized if not part.get("fill")) fills = [part for part, spans, w in sized if part.get("fill")] room = max(0, width - 1 - fixed - (1 if fills else 0)) each = room // len(fills) if fills else 0 row, x = [TuiSpan(" ", base)], 1 for part, spans, w in sized: if part.get("fill"): style = ui.st(part.get("fg", "ink"), part.get("bg", self._bg), bold=part.get("bold", False)) text = grr_clip(part.get("text", ""), each) spans = [TuiSpan(text.center(each) if part.get("align") == "center" else text.ljust(each), style)] w = each row.extend(spans) row.append(TuiSpan(" ", base)) if part.get("fn"): self._regions.append((x, x + w, part["fn"])) x += w + 1 return [grr_line(row, width, base)] def last_fn(self): """The action of the right-most clickable part (the More menu).""" return self._regions[-1][2] if self._regions else None def on_click(self, event): if not event.pressed: return event.stop() x = event.x - self.ax for x0, x1, fn in self._regions: if x0 <= x < x1: fn() return class GrrTuiApp(TuiApp): """The stock app plus two hooks the grr scene needs: where the last click landed (menus open there) and a click outside a modal popup, which closes it.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.last_click = (0, 0, 0.0) def _dispatch_click(self, event): if event.pressed: self.last_click = (event.x, event.y, time.time()) modal = self.top_modal() if modal is not None and event.pressed: hit = self.hitmap.at(event.x, event.y) if hit is None or self._modal_blocks(hit): outside = getattr(modal, "on_outside", None) if callable(outside): outside(event) return super()._dispatch_click(event) class GrrSecretBox(TuiTextBox): """A one-line box that shows bullets instead of what is typed.""" def content_rows(self): width, height = self._content_w(), self._content_h() self._clamp() left = max(0, self.cx - width + 1) focused = self.app is not None and self.app.focused is self and self.enabled hint = self.placeholder if (self.placeholder and not self.value) else "" rows = [] for pos in range(height): raw = self.lines[pos] if pos < len(self.lines) else "" line = ("•" * len(raw))[left:left + width] style = self.style if hint and pos == 0: line, style = hint, self.style.merged(TuiStyle(fg="role:muted", dim=True)) line = line.ljust(width) if focused and pos == self.cy: col = self.cx - left rows.append([TuiSpan(line[:col], style), TuiSpan(line[col:col + 1] or " ", style.merged(TuiStyle(reverse=True))), TuiSpan(line[col + 1:], style)]) else: rows.append([TuiSpan(line[:width], style)]) return rows class GrrMenu(TuiPanel): """A popup list of actions near the click that opened it. Up and Down move, Enter runs, Esc or a click outside closes.""" def __init__(self, shell, items, anchor=None, align_left=False): ui = shell.ui self.shell = shell self.entries = list(items) labels = [e["label"] for e in self.entries if e != "-"] icons = any(e != "-" and e.get("icon") for e in self.entries) width = min(shell.app.cols - 2, max([len(s) for s in labels] + [10]) + 6 + (3 if icons else 0)) height = min(GRR_MENU_MAX_H, len(self.entries)) cols, rows = shell.app.cols, shell.app.rows if anchor is None: x, y = max(0, (cols - width) // 2), max(0, (rows - height) // 3) else: ax, ay = anchor x = ax if align_left else ax - width + 1 x = max(1, min(x, cols - width - 1)) y = ay + 1 if y + height > rows - 1: y = max(0, ay - height) super().__init__(layer="dialog", modal=True, focusable=True, x=x, y=y, w=width, h=height, style=ui.st("ink", "card")) self.ui = ui self.sel = next((i for i, e in enumerate(self.entries) if e != "-"), 0) self.top = 0 self.icons = icons def content_rows(self): width, height = self._content_w(), self._content_h() ui = self.ui if self.sel < self.top: self.top = self.sel elif self.sel >= self.top + height: self.top = self.sel - height + 1 rows = [] for pos in range(height): index = self.top + pos entry = self.entries[index] if index < len(self.entries) else None if entry is None: rows.append([TuiSpan(" " * width, ui.st("ink", "card"))]) elif entry == "-": rows.append([TuiSpan("─" * width, ui.st("rule-strong", "card"))]) else: on = index == self.sel bg = "rule-strong" if on else "card" tone = "danger" if entry.get("danger") else "ink" lead = (ui.icon(entry["icon"]) + " ") if entry.get("icon") else (" " if self.icons else "") mark = ui.icon("check") if entry.get("checked") else " " text = (" " + lead + entry["label"]).ljust(width - 2)[:width - 2] + mark + " " rows.append([TuiSpan(text, ui.st(tone, bg, bold=on))]) return rows def run(self, index): entry = self.entries[index] if entry == "-": return self.shell.close_menu() action = entry.get("action") if callable(action): action() def step(self, delta): pos = self.sel while True: pos += delta if pos < 0 or pos >= len(self.entries): return if self.entries[pos] != "-": self.sel = pos self.touch() return def on_key(self, event): key = event.key or "" event.stop() if key == "escape": self.shell.close_menu() elif key == "up": self.step(-1) elif key == "down": self.step(1) elif key == "home": self.sel = next((i for i, e in enumerate(self.entries) if e != "-"), 0) self.touch() elif key == "end": self.sel = max(i for i, e in enumerate(self.entries) if e != "-") self.touch() elif key in ("enter", "space"): self.run(self.sel) def on_click(self, event): if not event.pressed: return event.stop() index = self.top + (event.y - self.ay) if 0 <= index < len(self.entries): self.run(index) def on_outside(self, event): self.shell.close_menu() def on_scroll(self, event): event.stop() self.step(-1 if event.direction == "up" else 1) class GrrToast(TuiRich): """A short message over the screen. Clicking a banner runs its action.""" def __init__(self, rows, w, y, click=None, **kwargs): super().__init__(rows, layer="toast", x="center", y=y, w=w, h=len(rows) if rows and isinstance(rows[0], list) else 1, **kwargs) self.click = click def on_click(self, event): if event.pressed and self.click is not None: event.stop() self.click() class GrrWelcome(TuiPanel): """The empty main pane of the two-pane layout: the mark, the name and the tagline, centered.""" def __init__(self, shell): super().__init__(w="1fr", h="100%", style=shell.ui.st("ink", "bg")) self.shell = shell def content_rows(self): shell = self.shell ui, width, height = shell.ui, self._content_w(), self._content_h() art = GrrAvatar.rows(ui, {"shape": "blob", "color": ui.palette.c("accent")}, "bg", hero=True) lines = [grr_line([TuiSpan(" " * ((width - tui_span_width(r)) // 2), ui.st())] + r, width, ui.st()) for r in art] lines.append([TuiSpan(" " * width, ui.st())]) lines.append([TuiSpan("grr".center(width), ui.st("ink", "bg", bold=True))]) for line in grr_wrap(shell.t("app.tagline"), max(10, min(width - 4, 46))): lines.append([TuiSpan(line.center(width), ui.st("muted", "bg"))]) top = max(0, (height - len(lines)) // 2) blank = [TuiSpan(" " * width, ui.st())] rows = [blank] * top + lines return (rows + [blank] * height)[:height] class GrrScreen: """The standard screen: header bar, scrolling page, optional foot page. `right` is a list of bar parts, `back` a route (False for none) and `close` draws a close mark instead of a back arrow.""" def __init__(self, shell, title="", back="/", close=False, right=(), on_back=None, foot=None, stick=False, titleless=False, auto_select=True): ui = shell.ui self.shell = shell self.title = title self.back = back self.close = close self.on_back = on_back self.right = list(right) self.titleless = titleless self.center = None self.root = TuiRows(w="1fr", h="100%", style=ui.st("ink", "bg")) self.bar = GrrBar(ui, style=ui.st("ink", "bg")) self.page = GrrPage(ui, stick=stick, auto_select=auto_select, style=ui.st("ink", "bg"), h="1fr") self.foot_page = None self.root.add(self.bar, self.page) if foot is not None: self.foot_page = GrrPage(ui, style=ui.st("ink", "bg"), h=3) self.foot_page.set_items(foot) self.root.add(self.foot_page) self.refresh() def go_back(self): if self.on_back is not None: self.on_back() else: self.shell.router.back(self.back or "/") def refresh(self): ui = self.shell.ui parts = [] if self.back is not False or self.close: parts.append({"text": " %s " % ui.icon("close" if self.close else "back"), "fn": self.go_back, "bg": "card", "bold": True}) if self.center is not None: parts.append(self.center) else: parts.append({"text": self.title, "fill": True, "align": "center", "bold": True}) parts.extend(self.right) self.bar.set(parts) def set_title(self, title): self.title = title self.refresh() def chip(self, icon, fn): return {"text": " %s " % self.shell.ui.icon(icon), "fn": fn, "bg": "card", "bold": True} def items(self, items): self.page.set_items(items) class GrrView: """A screen of the app. `open()` builds panels and calls `show`. Server calls go through `get`, `post` ... which drop their answer when the user has already left the screen.""" fallback = "/" def __init__(self, shell, params=None, query=None): self.g = shell self.ui = shell.ui self.params = params or {} self.query = query or {} self.closed = False self.screen = None self.subs = [] self.timers = [] def open(self): pass def destroy(self): self.closed = True for off in self.subs: off() self.subs = [] for handle in self.timers: self.g.app.cancel_timer(handle) self.timers = [] def sub(self, emitter, kind, fn): self.subs.append(emitter.on(kind, fn)) def every(self, seconds, fn): handle = self.g.app.every(seconds, fn) self.timers.append(handle) return handle def show(self, panel, focus=None): self.g.show_main(panel, focus) def guard(self, fn): def run(*args): if not self.closed: fn(*args) return run def call(self, method, path, body=None, then=None, fail=None): self.g.call(method, path, body, self.guard(then) if then else None, fail, view=self) def get(self, path, then, fail=None): self.call("GET", path, None, then, fail) def batch(self, calls, then, fail=None): self.g.batch(calls, self.guard(then), fail, view=self) def on_escape(self): if self.screen is not None: self.screen.go_back() return True return False def hotkey(self, key): """Keys the shell offers the open screen before it uses them itself. Returns True when handled.""" return False def header_action(self): bar = getattr(self.screen, "bar", None) or getattr(self, "bar", None) fn = bar.last_fn() if bar is not None else None if fn is None: return False fn() return True def standard(self, title="", **kwargs): self.screen = GrrScreen(self.g, title, **kwargs) self.show(self.screen.root, self.screen.page) return self.screen def fail_page(self, err, back="/"): screen = self.screen or self.standard("", back=back) screen.items([GrrEmptyItem("warn", err.text() if isinstance(err, GrrError) else str(err))]) class GrrShell: """The grr scene: boots against the server, owns the session, the store, the router and the two panes, and turns server events into UI changes.""" def __init__(self, app, session=None): self.app = app self.session = session or GrrSession() self.strings = GrrStrings() self.client = GrrClient(self.session.url, self.session.token, self.strings) self.client.on_unauthorized = lambda: self.app.run_on_loop(self.expired) self.spec = {} self.options = {} self.me = None self.settings = {"theme": "system", "variant": "black", "language": "system", "tier": "auto"} self.palette = GrrPalette() self.ui = GrrUi(self.palette, self.strings) self.store = GrrStore(app, self.client) self.events = GrrEmitter() self.router = GrrRouter(self.on_route) self.view = None self.home = None self.stream = None self.link_up = False self.menu_panel = None self.dialogs = [] self.toasts = [] self.signed_in = False self.drafts = {} self.pane_state = {} self.root = TuiCols(style=self.ui.st("ink", "bg"), id="grr") self.side = TuiRows(w=GRR_SIDE_W, h="100%", style=self.ui.st("ink", "bg"), id="side") self.main = TuiRows(w="1fr", h="100%", style=self.ui.st("ink", "bg"), id="main") self.root.add(self.side, self.main) self.side.visible = False self.root.on_key = self.on_root_key self.root.on_resize = lambda event: self.layout_sync() app.grr = self self.register_routes() # ---- routes def register_routes(self): r = self.router r.add("/", None) for pattern, name in GRR_ROUTES: r.add(pattern, name) def factory(self, name): return globals()[name] # ---- server calls def call(self, method, path, body=None, then=None, fail=None, view=None): def work(): return self.client.request(method, path, body) def landed(result, error): if view is not None and view.closed: return if error is not None: if fail is not None: fail(error) else: self.error(error) elif then is not None: then(result) self.app.offload(work, landed) def batch(self, calls, then, fail=None, view=None): def work(): return [self.client.request(m, p, b) for (m, p, b) in calls] def landed(result, error): if view is not None and view.closed: return if error is not None: (fail or self.error)(error) else: then(result) self.app.offload(work, landed) def error(self, err): if isinstance(err, GrrError): self.toast(err.text(), "bad") else: self.toast(str(err), "bad") def t(self, key, **vars): return self.strings.t(key, **vars) # ---- boot def boot(self): self.show_main(self.message_panel("grr ...")) def work(): return self.client.get("/api/spec"), self.client.get("/locales.json"), self.client.get("/api/auth/options") def landed(result, error): if error is not None: self.show_offline(error) return self.spec, locales, self.options = result self.strings.table = locales self.settings["language"] = "system" self.strings.use("system", self.spec.get("languages", [])) self.apply_settings() if self.session.token: self.enter() else: self.show_login() self.app.offload(work, landed) def message_panel(self, text): ui = self.ui return TuiRich([TuiSpan(text.center(60), ui.st("muted", "bg"))], h="100%", valign="middle", style=ui.st("muted", "bg")) def show_offline(self, err): ui = self.ui screen = GrrScreen(self, "grr", back=False) reason = (err.vars or {}).get("reason", "") if isinstance(err, GrrError) else str(err) screen.items([GrrEmptyItem("warn", "grr cannot reach its server", "%s\n%s" % (self.session.url, reason)), GrrNote("Start it with: grr-serve (or python3 -m grr serve), or point TAI at another one with GRR_URL.", center=True), GrrButtonItem("Try again", self.boot, inline=True), GrrButtonItem("Change address", self.change_url, "secondary", inline=True)]) self.layout_single(screen.root) self.app.set_focus(screen.page) def change_url(self): def done(value, values): if value == "ok" and values[0].strip(): self.session.url = values[0].strip().rstrip("/") self.session.save() self.client.base = self.session.url self.boot() self.dialog(title="grr server", text="Address of the grr server", fields=[{"value": self.session.url, "label": ""}], actions=[{"label": self.t("ui.cancel"), "value": "cancel"}, {"label": self.t("ui.save"), "value": "ok", "primary": True}], done=done) def show_login(self, error=""): self.signed_in = False self.stop_stream() view = GrrLoginView(self, {}, {"error": error}) self.set_view(view) view.open() def apply_settings(self): s = self.settings self.strings.use(s.get("language", "system"), self.spec.get("languages", [])) self.palette = GrrPalette(self.spec, s.get("theme", "system"), s.get("variant", "black")) self.palette.apply() self.ui = GrrUi(self.palette, self.strings, self.spec) self.restyle() def restyle(self): """Apply a new palette: containers get the new background, the drawer and the open screen are built again so every item reads the new ui.""" ui = self.ui for node in (self.root, self.side, self.main): node.style = ui.st("ink", "bg") for node in self.root.walk(): if hasattr(node, "ui"): node.ui = ui if self.signed_in and self.home is not None: self.home.destroy() for child in list(self.side.children): self.side.remove(child) self.home = GrrHome(self) self.side.add(self.home.root) self.router.fire(self.router.current) self.app.dirty_paint() def enter(self): def landed(result, error): if error is not None: if isinstance(error, GrrError) and error.status == 401: self.session.drop(self.session.token) self.client.token = self.session.token self.show_login() else: self.show_offline(error) return self.me = result self.settings.update(result.get("settings") or {}) self.apply_settings() self.store.refresh(self.entered) self.app.offload(lambda: self.client.get("/api/me"), landed) def entered(self, error): if error is not None: self.show_offline(error) return self.signed_in = True self.sync_timezone() self.start_stream() self.home = GrrHome(self) self.layout_dual() self.router.go("/") def sync_timezone(self): try: zone = os.environ.get("TZ") or "" if not zone and os.path.exists("/etc/timezone"): with open("/etc/timezone", "r", encoding="utf-8") as fh: zone = fh.read().strip() except OSError: zone = "" if zone and self.settings.get("auto_tz") and self.settings.get("timezone") != zone: self.call("PATCH", "/api/me/settings", {"timezone": zone}, then=lambda me: self.merge_me(me)) def merge_me(self, me): self.me = me self.settings.update(me.get("settings") or {}) def save_settings(self, patch, then=None): def landed(me): self.merge_me(me) self.apply_settings() if then is not None: then(me) self.call("PATCH", "/api/me/settings", patch, then=landed) def expired(self): if self.signed_in: self.stop_stream() self.session.drop(self.client.token) self.client.token = self.session.token self.show_login() def sign_out(self): self.call("POST", "/api/auth/logout", {}, then=lambda _r: self.after_sign_out(), fail=lambda _e: self.after_sign_out()) def after_sign_out(self): self.stop_stream() self.session.drop(self.client.token) self.client.token = self.session.token self.me = None self.signed_in = False if self.client.token: self.enter() else: self.show_login() def switch_account(self, token): self.stop_stream() self.session.switch(token) self.client.token = token self.signed_in = False self.enter() # ---- live events def start_stream(self): self.stop_stream() path = "/api/events?token=" + urllib.parse.quote(self.client.token) self.stream = GrrStream(self.client, path, self.on_event, self.app.run_on_loop, on_link=self.on_link) self.stream.start() def stop_stream(self): if self.stream is not None: self.stream.stop() self.stream = None def on_link(self, up): was = self.link_up self.link_up = up if up and not was: self.store.soon() self.events.emit("link", up) PASS = ("message.new", "message.update", "routine.update", "automation.update", "library.update", "usage.update", "builder.update", "files.changed", "port.opened", "port.closed", "preview.ready", "preview.failed", "chat.draft", "connector.update") def on_event(self, name, data): if name in ("bot.update", "bot.delete", "chat.update", "chat.delete"): self.store.soon() self.events.emit(name, data) elif name in GrrShell.PASS: self.events.emit(name, data) elif name == "status": self.store.set_status(data) self.events.emit("status", data) elif name == "toast": self.banner_event(data) def banner_event(self, data): viewing = isinstance(self.view, GrrChatView) and self.view.chat and self.view.chat.get("id") == data.get("chat_id") if viewing: return bot = next((b for b in self.store.bots if b.get("name") == data.get("bot")), None) chat_id = data.get("chat_id") self.banner(bot, data.get("text", ""), (lambda: self.router.go("/chat/" + chat_id)) if chat_id else None) # ---- layout def set_view(self, view): if self.view is not None: self.view.destroy() self.view = view def show_main(self, panel, focus=None): for child in list(self.main.children): self.main.remove(child) self.main.add(panel) self.layout_sync() if focus is not None: self.app.set_focus(focus) else: self.app.ensure_focus() def layout_single(self, panel): self.signed_in = False self.side.visible = False self.show_main(panel) def layout_dual(self): self.layout_sync() def on_route(self, route, params, query, raw): self.close_layers() path, _q = GrrRouter.parse(raw) if not self.signed_in: return self.set_view(None) if route is None or route.factory is None: self.view = None self.show_main(self.welcome_panel()) else: view = self.factory(route.factory)(self, params, query) self.view = view view.open() if self.home is not None: self.home.select(path) self.layout_sync() def welcome_panel(self): return GrrWelcome(self) def layout_sync(self): cols = self.app.cols wide = cols >= GRR_SIDE_MIN_COLS at_home = self.router.current == "/" if not self.signed_in or self.home is None: self.side.visible = False self.main.visible = True return if self.side.children == []: self.side.add(self.home.root) if wide: self.side.visible = True self.side.w = GRR_SIDE_W self.main.visible = True else: self.side.visible = at_home self.side.w = "100%" self.main.visible = not at_home self.app.ensure_focus() def on_root_key(self, event): key = event.key or "" if key == "ctrl+q": event.stop() self.app.quit() elif key == "escape" and self.signed_in: event.stop() if self.view is not None and self.view.on_escape(): return if self.router.current != "/": self.router.go("/") elif key == "f1" and self.signed_in: event.stop() self.help() elif key in ("f10", "ctrl+o") and self.signed_in and self.view is not None: if self.view.header_action(): event.stop() elif self.signed_in and self.view is not None and self.view.hotkey(key): event.stop() elif key == "ctrl+n" and self.signed_in: event.stop() self.router.go("/chat/new") elif key == "ctrl+f" and self.signed_in: event.stop() self.router.go("/search") elif key == "ctrl+g" and self.signed_in: event.stop() self.router.go("/settings") elif key == "f6" and self.signed_in: event.stop() self.cycle_panes() def cycle_panes(self): pages = [n for n in self.root.walk() if isinstance(n, (GrrPage, TuiTextBox)) and n.is_shown()] if pages: self.app.focus_next() def help(self): text = "\n".join("%-16s %s" % row for row in GRR_KEYS) self.dialog(title=self.t("settings.help"), text=text, w=min(self.app.cols - 2, 96), actions=[{"label": self.t("ui.ok"), "value": True, "primary": True}]) # ---- overlays def close_layers(self): self.close_menu() for dlg in list(self.dialogs): self.close_dialog(dlg, None, quiet=True) def menu(self, items, anchor=None, align_left=False): self.close_menu() if anchor is None: x, y, at = self.app.last_click if time.time() - at < 1.0: anchor = (x, y) panel = GrrMenu(self, items, anchor, align_left) self.menu_panel = panel self.root.add(panel) self.app.remember_focus() self.app.set_focus(panel) def close_menu(self): panel = self.menu_panel if panel is not None: self.menu_panel = None self.app.close_panel(panel) def toast(self, text, kind=""): ui = self.ui text = " %s " % grr_clip(text, self.app.cols - 8) style = ui.st("ink" if kind != "bad" else "danger", "rule-strong", bold=True) panel = GrrToast([TuiSpan(text, style)], len(text), 1 + len(self.toasts)) self.toasts.append(panel) self.root.add(panel) if len(self.toasts) > GRR_TOAST_MAX: self.drop_toast(self.toasts[0]) self.app.after(GRR_TOAST_SECS, lambda: self.drop_toast(panel)) def drop_toast(self, panel): if panel in self.toasts: self.toasts.remove(panel) if panel.parent is not None: panel.parent.remove(panel) for pos, other in enumerate(self.toasts): other.y = 1 + pos def banner(self, bot, text, click=None): ui = self.ui name = (bot or {}).get("name", "grr") width = min(self.app.cols - 4, 60) chip = GrrAvatar.chip(ui, bot) if bot else [TuiSpan(" ♪ ", ui.st("accent", "card"))] rows = [chip + [TuiSpan(" " + grr_clip(name, width - 8), ui.st("ink", "card", bold=True))], [TuiSpan(" " + grr_clip(text, width - 2), ui.st("muted", "card"))]] rows = [grr_line(r, width, ui.st("ink", "card")) for r in rows] panel = GrrToast(rows, width, 1 + len(self.toasts) * 2, click=(lambda: (self.drop_toast(panel), click and click()))) self.toasts.append(panel) self.root.add(panel) self.app.after(GRR_BANNER_SECS, lambda: self.drop_toast(panel)) def dialog(self, title="", text="", fields=(), actions=None, done=None, w=None, body=None, rows=None, page_h=0): """Open a modal dialog. fields: dicts with value, label, multiline, rows, max, placeholder, secret. actions: dicts with label, value, primary, danger. done(value, texts) runs once with the pressed action's value (None for Esc). `rows` puts a page of items in the dialog (pickers).""" ui = self.ui width = min(self.app.cols - 2, w or 64) dlg = TuiDialog(w=width, layer="dialog", modal=True, border=True, rounded=True, padding=(0, 2), gap=0, style=ui.st("ink", "card"), border_style=ui.st("rule-strong", "bg"), title_style=ui.st("ink", "bg", bold=True)) dlg.grr_done = done dlg.grr_fields = [] dlg.grr_actions = actions or [{"label": self.t("ui.ok"), "value": True, "primary": True}] dlg.grr_open = True if title: dlg.add(TuiText(title, wrap=True, style=ui.st("ink", "card", bold=True))) dlg.add(TuiText(" ", h=1, style=ui.st("ink", "card"))) if text: dlg.add(TuiText(text, wrap=True, style=ui.st("soft", "card"))) dlg.add(TuiText(" ", h=1, style=ui.st("ink", "card"))) primary = next((a for a in dlg.grr_actions if a.get("primary")), dlg.grr_actions[-1]) for spec in fields: if spec.get("label"): dlg.add(TuiText(spec["label"], style=ui.st("muted", "card"))) kind = GrrSecretBox if spec.get("secret") else TuiTextBox box = kind(spec.get("value", ""), h=spec.get("rows", 1), border=True, rounded=True, padding=(0, 1), max_chars=spec.get("max", 0), placeholder=spec.get("placeholder", ""), style=ui.st("ink", "bg"), border_style=ui.st("rule-strong", "card"), focus_border_style=ui.st("accent", "card")) if spec.get("rows", 1) <= 1: box.send_on_enter = True box.on_send = lambda _v, d=dlg, p=primary: self.dialog_pick(d, p) dlg.grr_fields.append(box) dlg.add(box) if rows is not None: page = GrrPage(ui, style=ui.st("ink", "card"), h=page_h or min(len(rows) * 2 + 2, max(4, self.app.rows - 12))) dlg.grr_page = page dlg.add(page) page.set_items(rows) if body is not None: dlg.add(body) if title or text or fields or rows is not None or body is not None: dlg.add(TuiText(" ", h=1, style=ui.st("ink", "card"))) bar = TuiCols(gap=2, style=ui.st("ink", "card"), align="right") for act in dlg.grr_actions: tone = "accent" if act.get("primary") else ("danger" if act.get("danger") else "ink") bg = "accent" if act.get("primary") and not act.get("danger") else "rule-strong" fg = "accent-ink" if act.get("primary") and not act.get("danger") else tone btn = TuiButton(act["label"], brackets=False, border=False, style=ui.st(fg, bg, bold=True), on_press=lambda _e, d=dlg, a=act: self.dialog_pick(d, a)) btn.label = " %s " % act["label"] bar.add(btn) dlg.add(bar) dlg.on_close = lambda _event, d=dlg: self.close_dialog(d, None) dlg.on_key_extra = None orig = dlg.on_key def on_key(event, d=dlg, original=orig, p=primary): if event.key == "ctrl+s": event.stop() self.dialog_pick(d, p) return original(event) dlg.on_key = on_key self.dialogs.append(dlg) self.root.add(dlg) return dlg def dialog_pick(self, dlg, action): self.close_dialog(dlg, action.get("value")) def close_dialog(self, dlg, value, quiet=False): if not getattr(dlg, "grr_open", False): return dlg.grr_open = False texts = [box.value for box in dlg.grr_fields] if dlg in self.dialogs: self.dialogs.remove(dlg) if dlg.parent is not None: self.app.close_panel(dlg) done = dlg.grr_done if done is not None and not quiet: done(value, texts) def confirm(self, title, text, yes, then, danger=False): self.dialog(title=title, text=text, actions=[{"label": self.t("ui.cancel"), "value": False}, {"label": yes, "value": True, "primary": True, "danger": danger}], done=lambda value, _t: then() if value else None) def alert(self, title, text): self.dialog(title=title, text=text) def edit(self, title, value, then, multiline=False, rows=6, max_chars=0, text=""): def done(action, texts): if action == "save": then(texts[0]) self.dialog(title=title, text=text, w=72 if multiline else 56, fields=[{"value": value, "rows": rows if multiline else 1, "multiline": multiline, "max": max_chars}], actions=[{"label": self.t("ui.cancel"), "value": "cancel"}, {"label": self.t("ui.save"), "value": "save", "primary": True}], done=done) def pick(self, title, rows, text=""): """A picker: rows are GrrRowItem with their own actions; the dialog closes before an action runs.""" holder = {} for row in rows: original = row.action def wrapped(row=row, original=original): self.close_dialog(holder["dlg"], None, quiet=True) if original: original() row.action = wrapped row.selectable = True holder["dlg"] = self.dialog(title=title, text=text, rows=grr_group(rows), actions=[{"label": self.t("ui.close"), "value": None}]) return holder["dlg"] # --------------------------------------------------------------------------- # Part D: the sidebar, login, and the screens of the app (bots, profile, # automations, library, projects, search, settings, templates). # --------------------------------------------------------------------------- GRR_LANG_NAMES = {"zh-CN": "简体中文", "zh-TW": "繁體中文", "en": "English", "hi": "हिन्दी", "pl": "Polski", "es": "Español", "pt": "Português", "ja": "日本語", "fr": "Français", "ko": "한국어", "de": "Deutsch", "nl": "Nederlands"} GRR_TEMPLATE_ASK = "Create a template of yourself that I can share with somebody else." def grr_bot_avatar(bot): return lambda ui, bg: GrrAvatar.rows(ui, bot, bg, small=True) def grr_user_avatar(account): def art(ui, bg): letter = ((account or {}).get("username") or "?")[:1].upper() style = ui.st("ink", "rule-strong", bold=True) return [[TuiSpan((" %s " % letter), style)], [TuiSpan(" ", style)]] return art def grr_group_avatar(bots): def art(ui, bg): rows = [[], []] for bot in bots[:3]: for pos, row in enumerate(GrrAvatar.chip_rows(ui, bot, bg)): rows[pos].extend(row) width = tui_span_width(rows[0]) for pos in range(2): rows[pos] = grr_line(rows[pos], 5, ui.st("ink", bg)) return rows return art class GrrHome: """The drawer: account, shortcuts, bots, pinned chats, bottom bar.""" def __init__(self, shell): self.g = shell ui = shell.ui self.root = TuiRows(w="100%", h="100%", style=ui.st("ink", "bg")) self.page = GrrPage(ui, style=ui.st("ink", "bg"), h="1fr") self.bar = GrrBar(ui, style=ui.st("ink", "bg")) self.root.add(self.page, self.bar) self.path = "" self.pinned_open = True self.offs = [shell.store.on("changed", lambda _d: self.paint()), shell.store.on("status", lambda _d: self.paint())] self.paint() def destroy(self): for off in self.offs: off() def select(self, path): self.path = path self.paint() def latest(self): g = self.g chats = sorted([c for c in g.store.chats if c.get("kind") != "private"], key=lambda c: -c.get("updated", 0)) g.router.go("/chat/" + chats[0]["id"] if chats else "/chat/new") def paint(self): g = self.g ui, t, store = g.ui, g.t, g.store go = g.router.go bots = sorted(store.bots, key=lambda b: -(((b.get("chat") or {}).get("updated")) or b.get("updated", 0))) pinned = [c for c in store.chats if c.get("pinned") and c.get("kind") in ("general", "group")] groups = [c for c in store.chats if c.get("kind") == "group" and not c.get("pinned")] me = g.me or {} items = [GrrConvItem(grr_user_avatar(me), me.get("username", ""), "", "grr " + str(me.get("version", "")), action=self.latest, key="me"), GrrSpacer(1)] items += grr_group([GrrRowItem(t("home.automations"), icon="automation", action=lambda: go("/automations"), key="n-auto"), GrrRowItem(t("home.library"), icon="box", action=lambda: go("/library"), key="n-lib"), GrrRowItem(t("home.projects"), icon="folder", action=lambda: go("/projects"), key="n-proj")]) items += [GrrSpacer(1), GrrSectionHead(t("home.bots"), "plus", self.plus_menu)] for bot in bots[:3]: chat = bot.get("chat") or {} working = store.is_working(bot["id"]) items.append(GrrConvItem(grr_bot_avatar(bot), bot["name"], ui.fmt.list_time(chat.get("updated")), (working.get("label") or t("chat.working")) if working else (chat.get("preview") or bot.get("description", "")), dot=bool(chat.get("unread")), current=self.path == "/chat/" + chat.get("id", "-"), busy=bool(working), action=(lambda c=chat: go("/chat/" + c["id"])), key="bot-" + bot["id"])) for chat in groups[:2]: members = [store.bot(i) for i in chat.get("bot_ids", [])] items.append(GrrConvItem(grr_group_avatar([m for m in members if m]), chat.get("title", ""), ui.fmt.list_time(chat.get("updated")), chat.get("preview") or t("bots.group"), dot=bool(chat.get("unread")), current=self.path == "/chat/" + chat["id"], action=(lambda c=chat: go("/chat/" + c["id"])), key="grp-" + chat["id"])) if len(bots) + len(groups) > 3: items.append(GrrRowItem(t("home.show_more"), link=True, action=lambda: go("/bots"), key="more", pos="only")) if not bots: items.append(GrrNote(t("home.no_bots"))) if pinned: items += [GrrSpacer(1), GrrSectionHead(t("home.pinned"), "up" if self.pinned_open else "down", self.toggle_pinned)] if self.pinned_open: for chat in pinned: items.append(GrrConvItem(lambda ui_, bg_: [[TuiSpan(" ", ui_.st("ink", bg_))], [TuiSpan(" ", ui_.st("ink", bg_))]], chat.get("title") or t("chat.untitled"), "", ui.fmt.short_date(chat.get("updated", 0)), current=self.path == "/chat/" + chat["id"], action=(lambda c=chat: go("/chat/" + c["id"])), menu=(lambda c=chat: self.chat_menu(c)), key="pin-" + chat["id"])) self.page.set_items(items) self.bar.set([{"text": " %s %s" % (ui.icon("search"), t("home.search")), "fn": lambda: go("/search"), "bg": "card", "fill": True}, {"text": " %s " % ui.icon("gear"), "fn": lambda: go("/settings"), "bg": "card"}, {"text": " %s " % ui.icon("compose"), "fn": lambda: go("/chat/new"), "bg": "card"}]) def toggle_pinned(self): self.pinned_open = not self.pinned_open self.paint() def plus_menu(self): g, t = self.g, self.g.t g.menu([{"icon": "plus", "label": t("bots.new_bot"), "action": lambda: GrrBots.new_bot(g)}, {"icon": "group", "label": t("bots.new_group"), "action": lambda: g.router.go("/bots?group=1")}]) def chat_menu(self, chat): g, t = self.g, self.g.t def rename(): g.edit(t("chat.rename"), chat.get("title", ""), lambda v: g.call("PATCH", "/api/chats/" + chat["id"], {"title": v})) def delete(): def go(): g.call("DELETE", "/api/chats/" + chat["id"], then=lambda _r: g.router.go("/") if chat["id"] in g.router.current else None) g.confirm(t("chat.delete_title"), t("chat.delete_text"), t("ui.delete"), go, True) g.menu([{"icon": "pin", "label": t("chat.unpin") if chat.get("pinned") else t("chat.pin"), "action": lambda: g.call("PATCH", "/api/chats/" + chat["id"], {"pinned": not chat.get("pinned")})}, {"icon": "edit", "label": t("chat.rename"), "action": rename}, "-", {"icon": "trash", "label": t("ui.delete"), "danger": True, "action": delete}]) class GrrBots: """Actions shared by the bot list, the drawer and the chat menu.""" @staticmethod def new_bot(g): def landed(bot): g.store.refresh(lambda _e: g.router.go("/bot/%s?edit=1" % bot["id"])) g.call("POST", "/api/bots", {"name": g.t("bots.default_name")}, then=landed) @staticmethod def new_group(g): t = g.t bots = g.store.bots if len(bots) < 2: g.toast(t("bots.group_need_two"), "bad") return cap = g.ui.cap("GROUP_MAX_BOTS", 6) picked = [] rows = [] def toggle(bot, on): if on and len(picked) >= cap: row = next(r for r in rows if r.key == bot["id"]) row.set_toggle(False) return if on: picked.append(bot["id"]) elif bot["id"] in picked: picked.remove(bot["id"]) for bot in bots: rows.append(GrrRowItem(bot["name"], lead=(lambda ui, bg, b=bot: GrrAvatar.chip(ui, b, bg)), toggle=False, on_toggle=(lambda on, b=bot: toggle(b, on)), key=bot["id"])) def done(value, texts): if value != "create": return if len(picked) < 2: g.toast(t("bots.group_need_two"), "bad") return g.call("POST", "/api/chats", {"kind": "group", "bot_ids": list(picked), "title": texts[0]}, then=lambda chat: g.store.refresh(lambda _e: g.router.go("/chat/" + chat["id"]))) g.dialog(title=t("bots.new_group"), text=t("bots.group_hint", n=cap), fields=[{"value": "", "placeholder": t("bots.group_name"), "max": 60}], rows=grr_group(rows), actions=[{"label": t("ui.cancel"), "value": "cancel"}, {"label": t("bots.group_create"), "value": "create", "primary": True}], done=done) class GrrLoginView(GrrView): """Sign in with a DevPlace username or email and password, or a key.""" def open(self): g, ui, t = self.g, self.ui, self.g.t self.mode = "password" self.busy = False width = max(24, min(g.app.cols - 4, 50)) self.width = width self.root = TuiRows(w="1fr", h="100%", valign="middle", align="center", style=ui.st("ink", "bg")) art = GrrAvatar.rows(ui, {"shape": "blob", "color": ui.palette.c("accent")}, "bg") head = [[TuiSpan(" " * ((width - 7) // 2), ui.st()), *row] for row in art] head += [[TuiSpan(" " * width, ui.st())], [TuiSpan("grr".center(width), ui.st("ink", "bg", bold=True))]] for line in grr_wrap(t("app.tagline"), width - 2): head.append([TuiSpan(line.center(width), ui.st("muted", "bg"))]) self.root.add(TuiRich([grr_line(r, width, ui.st()) for r in head], w=width, style=ui.st("ink", "bg"))) self.fields = TuiRows(w=width, style=ui.st("ink", "bg")) self.root.add(TuiText(" ", h=1, w=width, style=ui.st("ink", "bg")), self.fields) self.err = TuiText("", w=width, wrap=True, style=ui.st("danger", "bg")) self.root.add(self.err) self.submit = TuiButton(t("login.submit"), brackets=False, border=False, w=width, style=ui.st("accent-ink", "accent", bold=True), on_press=lambda _e: self.go(False)) self.root.add(self.submit) if (g.options or {}).get("env_key"): self.root.add(TuiButton(t("login.use_session"), brackets=False, border=False, w=width, style=ui.st("ink", "card", bold=True), on_press=lambda _e: self.go(True))) self.other = TuiButton("", brackets=False, border=False, w=width, style=ui.st("muted", "bg"), on_press=lambda _e: self.swap()) self.root.add(self.other) for line in grr_wrap(t("login.hint"), width - 2)[:6]: self.root.add(TuiText(line, w=width, style=ui.st("faint", "bg"))) if self.query.get("error"): self.err.text = self.query["error"] self.draw() self.show(self.root, self.user) def box(self, placeholder, secret=False): ui = self.ui kind = GrrSecretBox if secret else TuiTextBox box = kind("", h=1, w=self.width - 4, border=True, rounded=True, padding=(0, 1), placeholder=placeholder, style=ui.st("ink", "card"), border_style=ui.st("rule-strong", "bg"), focus_border_style=ui.st("accent", "bg"), send_on_enter=True, on_send=lambda _v: self.go(False)) return box def draw(self): t = self.g.t for child in list(self.fields.children): self.fields.remove(child) self.err.text = "" if self.mode == "key": self.key = self.box(t("login.key_placeholder"), True) self.fields.add(self.key) self.user = self.key self.other.label = t("login.use_password") else: self.user = self.box(t("login.username")) self.passw = self.box(t("login.password"), True) self.fields.add(self.user, self.passw) self.other.label = t("login.use_key") self.other.label = " %s " % self.other.label self.g.app.set_focus(self.user) def swap(self): self.mode = "key" if self.mode == "password" else "password" self.draw() def go(self, use_env): if self.busy: return g = self.g if use_env: body = {"use_env": True} elif self.mode == "key": body = {"api_key": self.key.value} else: body = {"username": self.user.value, "password": self.passw.value} self.busy = True self.err.text = "" def work(): return g.client.request("POST", "/api/auth/login", body, quiet=True) def landed(result, error): self.busy = False if self.closed: return if error is not None: self.err.text = error.text() if isinstance(error, GrrError) else str(error) return g.session.add(result["token"], result["account"]) g.client.token = result["token"] g.enter() g.app.offload(work, landed) def on_escape(self): return True class GrrStripItem(GrrItem): """The bots as big avatars in a row, names below. Left and Right walk along it, Enter opens the chat. Wraps onto more lines on a narrow screen.""" selectable = True def __init__(self, bots, on_pick): super().__init__() self.bots = list(bots) self.on_pick = on_pick self.stops = max(1, len(self.bots)) self.cols = 1 def nav(self, direction): step = {"left": -1, "right": 1, "up": -self.cols, "down": self.cols}.get(direction) if step is None: return False nxt = self.stop + step if 0 <= nxt < len(self.bots): self.stop = nxt self.touch() return True return False def activate(self): if self.bots: self.on_pick(self.bots[min(self.stop, len(self.bots) - 1)]) def build(self, ui, width, selected): m = grr_margin(width) cell = 11 self.cols = max(1, (width - 2 * m) // cell) page = ui.st("ink", "bg") out = [] for start in range(0, len(self.bots), self.cols): lines = [[TuiSpan(" " * m, page)] for _ in range(3)] for off, bot in enumerate(self.bots[start:start + self.cols]): focus = selected and start + off == self.stop bg = "rule-strong" if focus else "bg" art = GrrAvatar.rows(ui, bot, bg) pad = (cell - 7) // 2 for pos in range(2): lines[pos].extend([TuiSpan(" " * pad, ui.st("ink", bg))] + list(art[pos]) + [TuiSpan(" " * (cell - 7 - pad), ui.st("ink", bg))]) lines[2].append(TuiSpan(grr_clip(bot["name"], cell - 1).center(cell), ui.st("ink", bg, bold=focus))) for pos in range(3): self.hit(len(out) + pos, m + off * cell, m + (off + 1) * cell, (lambda b=bot: self.on_pick(b))) for line in lines: out.append(grr_line(line, width, page)) out.append([TuiSpan(" " * width, page)]) return out class GrrBotsView(GrrView): fallback = "/" def open(self): g, t, ui = self.g, self.g.t, self.ui right = [{"text": " %s " % ui.icon("search"), "fn": lambda: g.router.go("/search"), "bg": "card"}, {"text": " %s " % ui.icon("plus"), "fn": self.plus, "bg": "card"}] self.standard(t("bots.title"), back="/", right=right) self.sub(g.store, "changed", lambda _d: self.paint()) self.sub(g.store, "status", lambda _d: self.paint()) self.paint() if self.query.get("group"): g.app.after(0.05, lambda: GrrBots.new_group(g)) def plus(self): g, t = self.g, self.g.t g.menu([{"icon": "plus", "label": t("bots.new_bot"), "action": lambda: GrrBots.new_bot(g)}, {"icon": "group", "label": t("bots.new_group"), "action": lambda: GrrBots.new_group(g)}]) def paint(self): g, ui, t, store = self.g, self.ui, self.g.t, self.g.store rows = [] for bot in store.bots: chat = bot.get("chat") or {} working = store.is_working(bot["id"]) rows.append((chat.get("updated") or 0, GrrConvItem(grr_bot_avatar(bot), bot["name"], ui.fmt.list_time(chat.get("updated")), (working.get("label") or t("chat.working")) if working else (chat.get("preview") or bot.get("description", "")), dot=bool(chat.get("unread")), busy=bool(working), action=(lambda c=chat: g.router.go("/chat/" + c["id"])), key="b" + bot["id"]))) for chat in [c for c in store.chats if c.get("kind") == "group"]: members = [store.bot(i) for i in chat.get("bot_ids", [])] rows.append((chat.get("updated", 0), GrrConvItem(grr_group_avatar([m for m in members if m]), chat.get("title", ""), ui.fmt.list_time(chat.get("updated")), chat.get("preview", ""), dot=bool(chat.get("unread")), tag=t("bots.group"), action=(lambda c=chat: g.router.go("/chat/" + c["id"])), key="g" + chat["id"]))) rows.sort(key=lambda r: -r[0]) items = [r[1] for r in rows] or [GrrEmptyItem("hash", t("bots.empty"), t("bots.empty_sub"))] if store.bots: items.insert(0, GrrStripItem(store.bots, lambda b: g.router.go("/chat/" + (b.get("chat") or {}).get("id", "new")))) self.screen.items(items) class GrrProfileView(GrrView): fallback = "/bots" def open(self): g, t, ui = self.g, self.g.t, self.ui self.tab = self.query.get("tab") or "info" self.gen = 0 self.shown = GRR_LIST_MORE self.standard("", back="/bots") self.screen.items([GrrEmptyItem("hash", "...")]) self.get("/api/bots/" + self.params["id"], self.loaded) def loaded(self, bot): g, t = self.g, self.g.t self.bot = bot self.screen.right = [self.screen.chip("share", self.share), self.screen.chip("more", self.more)] self.screen.refresh() self.sub(g.events, "bot.update", lambda d: self.refetch(d)) self.paint() if self.query.get("edit"): g.app.after(0.05, self.rename) def refetch(self, data): if data and data.get("id") == self.bot["id"]: self.get("/api/bots/" + self.bot["id"], lambda bot: (setattr(self, "bot", bot), self.paint())) def more(self): g, t, bot = self.g, self.g.t, self.bot g.menu([{"icon": "copy", "label": t("profile.copy_id"), "action": lambda: g.copy_text(bot["id"])}, {"icon": "trash", "label": t("profile.delete_bot"), "danger": True, "action": self.remove}]) def remove(self): g, t, bot = self.g, self.g.t, self.bot def go(): g.call("DELETE", "/api/bots/" + bot["id"], then=lambda _r: g.store.refresh(lambda _e: g.router.go("/bots", True))) g.confirm(t("profile.delete_bot"), t("profile.delete_text", name=bot["name"]), t("ui.delete"), go, True) def share(self): g, t, bot = self.g, self.g.t, self.bot def go(): chat = bot["chat"] g.call("POST", "/api/chats/%s/messages" % chat["id"], {"text": GRR_TEMPLATE_ASK}, then=lambda _r: g.router.go("/chat/" + chat["id"])) g.confirm(t("profile.share_title"), t("profile.share_text"), t("ui.continue"), go) def patch(self, body, repaint=True): def landed(res): self.bot.update(res) self.g.store.soon() if repaint: self.paint() self.call("PATCH", "/api/bots/" + self.bot["id"], body, landed) def rename(self): t = self.g.t self.g.edit(t("profile.name"), self.bot["name"], lambda v: self.patch({"name": v}) if v.strip() else None, max_chars=60) def describe(self): t = self.g.t self.g.edit(t("profile.description"), self.bot.get("description", ""), lambda v: self.patch({"description": v}), True, 4, 400) def hero_items(self): t, ui = self.g.t, self.ui return [GrrHeroItem(self.bot), GrrNameCard(self.bot["name"], self.bot.get("description", ""), t("profile.description_ph"), self.rename, self.describe)] def paint(self): t = self.g.t tabs = GrrTabsItem([{"id": "info", "label": t("profile.info")}, {"id": "documents", "label": t("profile.documents")}, {"id": "computer", "label": t("profile.computer")}, {"id": "links", "label": t("profile.links")}], self.tab, self.pick_tab) tabs.key = "tabs" self.tabs_item = tabs self.screen.items(self.hero_items() + [tabs] + self.content()) def pick_tab(self, tab): self.tab = tab self.gen += 1 self.screen.items(self.hero_items() + [self.tabs_item] + self.content()) def content(self): for handle in self.timers: self.g.app.cancel_timer(handle) self.timers = [] return {"info": self.info, "documents": self.documents, "computer": self.computer, "links": self.links}.get(self.tab, self.info)() def info(self): g, t, ui, bot = self.g, self.g.t, self.ui, self.bot go = g.router.go defaults = {"shape": "blob", "color": (ui.palette.bot_colors + ["#d4a020"])[4]} shapes = (self.g.spec.get("shapes") or list(GRR_SHAPES)) tiles = [{"label": "", "on": bot.get("shape") == s, "art": (lambda ui_, bg_, s=s: GrrAvatar.rows(ui_, {"shape": s, "color": bot.get("color")}, bg_, small=True)), "action": (lambda s=s: self.patch({"shape": s}))} for s in shapes] swatches = GrrSwatchItem(ui.palette.bot_colors, bot.get("color"), lambda c: self.patch({"color": c})) items = [GrrLabel(t("profile.character")), GrrTileGridItem(tiles, tile_h=4, min_w=9, max_cols=6), swatches, GrrRowItem(t("profile.reset_default"), action=lambda: self.patch(defaults), tone="ink"), GrrNote(t("profile.character_note")), GrrSpacer(1)] items += grr_group([GrrRowItem(t("profile.instructions"), icon="file", chevron=True, action=lambda: go("/bot/%s/instructions" % bot["id"])), GrrRowItem(t("profile.memories"), icon="inbox", value=str(bot.get("memories", 0)), chevron=True, action=lambda: go("/bot/%s/memories" % bot["id"])), GrrRowItem(t("profile.skills"), icon="skills", value=str(bot.get("skills", 0)), chevron=True, action=lambda: go("/bot/%s/skills" % bot["id"]))]) routines = bot.get("routines") or [] rr = [GrrRowItem(r["name"], icon="clock", sub=ui.fmt.schedule(r.get("schedule")) + ("" if r.get("active") else " · " + t("automations.paused")), chevron=True, action=(lambda r=r: go("/automation/" + r["id"]))) for r in routines] rr.append(GrrRowItem(t("profile.add_routine"), icon="plus", link=True, action=lambda: go("/automations/new?bot=" + bot["id"]))) items += [GrrLabel(t("profile.routines"))] + grr_group(rr) engine = bot.get("voice") or (g.spec.get("voice") or {}).get("default_engine", "browser") vr = [GrrRowItem(t("profile.voice_" + e), sub=t("profile.voice_" + e + "_sub"), icon="sparkle" if e == "neural" else "speaker", check=engine == e, action=(lambda e=e: None if engine == e else self.patch({"voice": e}))) for e in (g.spec.get("voice") or {}).get("engines", ["browser", "neural"])] items += [GrrLabel(t("profile.voice"))] + grr_group(vr) + [GrrNote(t("profile.voice_note"))] items += grr_group([GrrRowItem(t("profile.notifications"), toggle=bool(bot.get("notifications")), on_toggle=lambda v: self.patch({"notifications": v}, False))]) + [GrrNote(t("profile.notifications_note"))] items += grr_group([GrrRowItem(t("profile.auto_approve"), sub=t("profile.auto_approve_note"), toggle=bool(bot.get("auto_approve")), on_toggle=lambda v: self.patch({"auto_approve": v}, False))]) items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("profile.share_template"), icon="share", chevron=True, action=self.share)]) return items def documents(self): t, ui = self.g.t, self.ui gen = self.gen def landed(res): if gen != self.gen: return files = res.get("files", []) if not files: rows = [GrrEmptyItem("file", t("profile.no_docs"), t("profile.no_docs_sub"))] else: rows = grr_group([GrrRowItem(f["name"], icon="image" if f.get("kind") == "media" else ("terminal" if f.get("kind") == "app" else "file"), sub=ui.fmt.ago(f.get("created", 0)) + " · " + GrrFormat.bytes(f.get("size", 0)), chevron=True, action=(lambda f=f: self.g.open_file(f))) for f in files[:self.shown]]) if len(files) > self.shown: rows.append(GrrButtonItem(t("home.show_more"), lambda: self.more_docs(), "ghost", inline=True)) self.screen.items(self.hero_items() + [self.tabs_item] + rows) self.get("/api/library?bot=" + self.bot["id"], landed) return [GrrEmptyItem("file", "...")] def more_docs(self): self.shown += GRR_LIST_MORE self.pick_tab("documents") def computer(self): g, t, ui, bot = self.g, self.g.t, self.ui, self.bot meta = GrrNote("", center=True) def refresh(): def landed(c): meta.text = t("computer.disk") + ": " + t("computer.disk_" + c["disk"]["state"]) + (" · " + t("computer.busy") if c.get("busy") else "") meta.touch() self.call("GET", "/api/bots/%s/computer" % bot["id"], None, landed, lambda _e: None) refresh() self.every(4.0, refresh) return [GrrScreenPreview(t("profile.screen_of", name=bot["name"])), GrrButtonItem(t("profile.take_control"), lambda: g.router.go("/bot/%s/screen" % bot["id"]), inline=True), GrrButtonItem(t("profile.open_terminal"), lambda: g.router.go("/bot/%s/computer" % bot["id"]), "secondary", inline=True, icon="terminal"), meta] def links(self): t, ui = self.g.t, self.ui gen = self.gen def landed(res): if gen != self.gen: return links = res.get("links", []) rows = [GrrEmptyItem("link", t("profile.no_links"), t("profile.no_links_sub"))] if not links else grr_group([ GrrRowItem(l.get("title") or GrrFormat.domain(l["url"]), sub=l["url"], chevron="ext", lead=(lambda ui_, bg, d=GrrFormat.domain(l["url"]): [TuiSpan(" %s " % ((d[:1] or "?").upper()), ui_.st("ink", "rule-strong", bold=True)), TuiSpan(" ", ui_.st("ink", bg))]), action=(lambda l=l: self.g.open_url(l["url"]))) for l in links]) self.screen.items(self.hero_items() + [self.tabs_item] + rows) self.get("/api/bots/%s/links" % self.bot["id"], landed) return [GrrEmptyItem("link", "...")] class GrrHeroItem(GrrItem): """A big avatar, centered.""" def __init__(self, bot): super().__init__() self.bot = bot def build(self, ui, width, selected): art = GrrAvatar.rows(ui, self.bot, "bg", hero=True) out = [[TuiSpan(" " * width, ui.st())]] for row in art: out.append(grr_line([TuiSpan(" " * ((width - tui_span_width(row)) // 2), ui.st())] + row, width, ui.st())) return out class GrrNameCard(GrrItem): """The bot's name and description in one card; each opens its editor.""" selectable = True def __init__(self, name, desc, placeholder, on_name, on_desc): super().__init__() self.name = name self.desc = desc self.placeholder = placeholder self.on_name = on_name self.on_desc = on_desc self.stops = 2 def nav(self, direction): if direction in ("up", "down"): nxt = self.stop + (1 if direction == "down" else -1) if 0 <= nxt <= 1: self.stop = nxt self.touch() return True return False def activate(self): (self.on_name if self.stop == 0 else self.on_desc)() def build(self, ui, width, selected): inner = GrrCard.inner(width) name_bg = "rule-strong" if selected and self.stop == 0 else "card" desc_bg = "rule-strong" if selected and self.stop == 1 else "card" rows = [GrrCard.top(ui, width)] rows.append(GrrCard.line(ui, width, [TuiSpan(grr_clip(self.name, inner - 2).center(inner), ui.st("ink", name_bg, bold=True))], name_bg)) self.hit(1, 0, width, self.on_name) text = self.desc or self.placeholder for line in grr_wrap(text, inner - 4)[:4]: rows.append(GrrCard.line(ui, width, [TuiSpan(line.center(inner), ui.st("muted" if self.desc else "faint", desc_bg))], desc_bg)) self.hit(len(rows) - 1, 0, width, self.on_desc) rows.append(GrrCard.bottom(ui, width)) return rows class GrrSwatchItem(GrrItem): selectable = True def __init__(self, colors, value, on_pick): super().__init__() self.colors = list(colors) self.value = value self.on_pick = on_pick self.stops = len(self.colors) if value in self.colors: self.stop = self.colors.index(value) def nav(self, direction): if direction in ("left", "right"): nxt = max(0, min(len(self.colors) - 1, self.stop + (1 if direction == "right" else -1))) if nxt != self.stop: self.stop = nxt self.touch() return True return False def activate(self): self.on_pick(self.colors[self.stop]) def build(self, ui, width, selected): m = grr_margin(width) + 1 page = ui.st("ink", "bg") spans, x = [TuiSpan(" " * m, page)], m for pos, color in enumerate(self.colors): on = color == self.value focus = selected and pos == self.stop mark = "◉" if on else "●" spans.append(TuiSpan(" " + mark + " ", TuiStyle(fg=color, bg=ui.palette.c("rule-strong" if focus else "bg")))) self.hit(0, x, x + 3, (lambda c=color: self.on_pick(c))) x += 3 return [grr_line(spans, width, page)] class GrrScreenPreview(GrrItem): """The framed placeholder of the bot's screen on the Computer tab.""" def __init__(self, caption): super().__init__() self.caption = caption def build(self, ui, width, selected): inner = GrrCard.inner(width) rows = [GrrCard.top(ui, width)] for pos in range(3): text = ui.icon("monitor") if pos == 1 else "" rows.append(GrrCard.line(ui, width, [TuiSpan(text.center(inner), ui.st("muted", "card"))])) rows.append(GrrCard.bottom(ui, width)) rows.append(grr_line([TuiSpan(grr_clip(self.caption, inner).center(width), ui.st("muted", "bg"))], width, ui.st("ink", "bg"))) return rows class GrrInstructionsView(GrrView): fallback = "/bots" def open(self): t = self.g.t self.standard(t("profile.instructions"), back="/bot/" + self.params["id"]) self.get("/api/bots/" + self.params["id"], self.loaded) def loaded(self, bot): self.bot = bot self.paint() def paint(self): g, t, bot = self.g, self.g.t, self.bot cap = g.ui.cap("INSTRUCTIONS_MAX_CHARS", 12000) prompts = (g.spec.get("prompts") or {}) def editor(key, title, hint, default=""): current = bot.get(key) or default def save(value): body = {key: "" if (default and value.strip() == default.strip()) else value} self.call("PATCH", "/api/bots/" + bot["id"], body, lambda res: (bot.update(res), self.paint(), g.toast(t("ui.saved")))) items = [GrrLabel(title), GrrTextCard(current, action=lambda: g.edit(title, current, save, True, 10, cap), placeholder="")] if default: items.append(GrrRowItem(t("profile.reset_default"), action=lambda: save(default), tone="ink")) items.append(GrrNote(hint)) return items items = editor("instructions", t("profile.instruction"), t("profile.instructions_hint")) items += editor("persona", t("profile.persona"), t("profile.persona_hint", name="{name}"), prompts.get("persona", "")) items += editor("rules", t("profile.rules"), t("profile.rules_hint"), prompts.get("rules", "")) self.screen.items(items) class GrrListView(GrrView): """Memories and skills of a bot: list, add, open.""" path = "" icon = "" detail = "" def open(self): self.standard(self.heading(), back="/bot/" + self.params["id"], right=[]) self.sub(self.g.events, "bot.update", lambda _d: self.reload()) self.get("/api/bots/" + self.params["id"], self.loaded) def loaded(self, bot): self.bot = bot self.screen.right = [self.screen.chip("plus", self.add)] self.screen.refresh() self.reload() def reload(self): if getattr(self, "bot", None) is None: return self.get("/api/bots/%s/%s" % (self.bot["id"], self.path), self.paint) def paint(self, res): items = res.get(self.path, []) t = self.g.t if not items: self.screen.items(self.empty()) return self.screen.items(grr_group([GrrRowItem(self.title(it), sub=self.sub_of(it), chevron=True, wrap=True, action=(lambda it=it: self.g.router.go("/%s/%s" % (self.detail, it["id"])))) for it in items])) def sub_of(self, item): return "" class GrrMemoriesView(GrrListView): path, icon, detail = "memories", "inbox", "memory" def heading(self): return self.g.t("profile.memories") def title(self, m): return m["content"] def empty(self): t = self.g.t return [GrrEmptyItem("inbox", t("profile.no_memories"), t("profile.no_memories_sub"))] def add(self): g, t = self.g, self.g.t g.edit(t("profile.add_memory"), "", lambda v: self.call("POST", "/api/bots/%s/memories" % self.bot["id"], {"content": v}, lambda _r: self.reload()), True, 4, 1000) class GrrSkillsView(GrrListView): path, icon, detail = "skills", "skills", "skill" def heading(self): return self.g.t("profile.skills") def title(self, s): return s["name"] def sub_of(self, s): return s.get("description", "") def empty(self): t = self.g.t return [GrrEmptyItem("skills", t("profile.no_skills"), t("profile.no_skills_sub"))] def add(self): g, t = self.g, self.g.t def done(value, texts): if value == "add": self.call("POST", "/api/bots/%s/skills" % self.bot["id"], {"name": texts[0], "description": texts[1], "content": texts[2]}, lambda _r: self.reload()) g.dialog(title=t("profile.add_skill"), w=72, fields=[{"value": "", "placeholder": t("profile.skill_name"), "max": 80}, {"value": "", "placeholder": t("profile.skill_desc"), "max": 300}, {"value": "", "placeholder": t("profile.skill_content"), "rows": 6, "max": 12000}], actions=[{"label": t("ui.cancel"), "value": "cancel"}, {"label": t("ui.add"), "value": "add", "primary": True}], done=done) class GrrMemoryView(GrrView): def open(self): self.standard(self.g.t("profile.memory"), back="/bots") self.get("/api/memories/" + self.params["id"], self.loaded) def loaded(self, mem): g, t = self.g, self.g.t self.mem = mem self.screen.back = "/bot/%s/memories" % mem["bot_id"] self.screen.right = [self.screen.chip("trash", self.delete)] self.screen.refresh() self.paint() def paint(self): g, t, mem = self.g, self.g.t, self.mem def save(value): self.call("PATCH", "/api/memories/" + mem["id"], {"content": value}, lambda res: (mem.update(res), self.paint())) self.screen.items([GrrLabel(t("profile.content")), GrrTextCard(mem["content"], action=lambda: g.edit(t("profile.memory"), mem["content"], save, True, 6, 1000))]) def delete(self): g, t, mem = self.g, self.g.t, self.mem g.confirm(t("ui.delete"), t("profile.delete_memory"), t("ui.delete"), lambda: self.call("DELETE", "/api/memories/" + mem["id"], None, lambda _r: g.router.back("/bot/%s/memories" % mem["bot_id"])), True) class GrrSkillView(GrrView): def open(self): self.standard("", back="/bots") self.get("/api/skills/" + self.params["id"], self.loaded) def loaded(self, skill): self.skill = skill self.screen.back = "/bot/%s/skills" % skill["bot_id"] self.screen.title = skill["name"] self.screen.right = [self.screen.chip("trash", self.delete)] self.screen.refresh() self.paint() def paint(self): g, t, skill = self.g, self.g.t, self.skill def field(key, label, rows, cap): def save(value): self.call("PATCH", "/api/skills/" + skill["id"], {key: value}, lambda res: (skill.update(res), self.paint())) return [GrrLabel(label), GrrTextCard(skill.get(key, ""), action=lambda: g.edit(label, skill.get(key, ""), save, True, rows, cap))] self.screen.items(field("description", t("profile.description"), 3, 300) + field("content", t("profile.content"), 12, 12000)) def delete(self): g, t, skill = self.g, self.g.t, self.skill g.confirm(t("ui.delete"), t("profile.delete_skill"), t("ui.delete"), lambda: self.call("DELETE", "/api/skills/" + skill["id"], None, lambda _r: g.router.back("/bot/%s/skills" % skill["bot_id"])), True) # ---- automations class GrrRoutineItem(GrrItem): """A routine as a card: bot, name, prompt, schedule line.""" selectable = True def __init__(self, routine, on_open, on_menu): super().__init__() self.r = routine self.action = on_open self.on_menu = on_menu self.key = routine["id"] def build(self, ui, width, selected): inner = GrrCard.inner(width) r = self.r bg = "rule-strong" if selected else "card" base = ui.st("ink", bg) bot = r.get("bot") chip = GrrAvatar.chip(ui, bot, bg) if bot else [TuiSpan(" %s " % ui.icon("automation"), ui.st("soft", bg))] head = [TuiSpan(" ", base)] + chip + [TuiSpan(" " + grr_clip(r["name"], inner - 14), ui.st("ink" if r.get("active") else "muted", bg, bold=True))] head = grr_line(head, inner - 4, base) + [TuiSpan(" %s " % ui.icon("moreV"), ui.st("soft", bg))] lines = [head] for line in grr_wrap(r.get("instruction", ""), inner - 3)[:3]: lines.append([TuiSpan(" " + line, ui.st("muted", bg))]) foot = (bot["name"] + " · " if bot else "") + ui.fmt.schedule(r.get("schedule")) + ("" if r.get("active") else " · " + ui.t("automations.paused")) lines.append([TuiSpan(" " + grr_clip(foot, inner - 3), ui.st("faint", bg))]) out = [GrrCard.top(ui, width, bg)] + [GrrCard.line(ui, width, line, bg) for line in lines] + [GrrCard.bottom(ui, width, bg)] self.hit(1, width - grr_margin(width) - 6, width, lambda: self.on_menu(self.r)) return out class GrrAutomationsView(GrrView): fallback = "/" def open(self): g, t = self.g, self.g.t self.standard(t("home.automations"), close=True, back="/", right=[]) self.screen.right = [self.screen.chip("plus", lambda: g.router.go("/automations/new"))] self.screen.refresh() self.sub(g.events, "routine.update", lambda _d: self.reload()) self.reload() def reload(self): self.get("/api/routines", self.paint) def paint(self, res): g, t = self.g, self.g.t items = [GrrRoutineItem(r, (lambda r=r: g.router.go("/automation/" + r["id"])), self.menu) for r in res.get("routines", [])] if not items: items.append(GrrEmptyItem("automation", t("automations.empty"), t("automations.empty_sub"))) items.append(GrrButtonItem(t("automations.new"), lambda: g.router.go("/automations/new"), "secondary", inline=True, icon="plus")) self.screen.items(items) def menu(self, r): g, t = self.g, self.g.t def delete(): g.confirm(t("automations.delete"), t("automations.delete_text"), t("ui.delete"), lambda: g.call("DELETE", "/api/routines/" + r["id"]), True) g.menu([{"icon": "play", "label": t("automations.run_now"), "action": lambda: g.call("POST", "/api/routines/%s/run" % r["id"], {}, then=lambda _x: g.toast(t("automations.started")))}, {"icon": "pause" if r.get("active") else "play", "label": t("automations.pause") if r.get("active") else t("automations.resume"), "action": lambda: g.call("PATCH", "/api/routines/" + r["id"], {"active": not r.get("active")})}, "-", {"icon": "trash", "label": t("automations.delete"), "danger": True, "action": delete}]) def grr_schedule_dialog(g, initial, done): """The schedule picker: Every, Daily or Weekly. done(schedule) gets the picked schedule dict, never a partial one.""" t, ui = g.t, g.ui state = {"kind": "interval", "every": 5, "unit": 60, "time": "09:00", "day": 0} if initial: state["kind"] = initial.get("kind", "interval") if state["kind"] == "interval": sec = int(initial.get("every_seconds", 300)) unit = 86400 if sec % 86400 == 0 else (3600 if sec % 3600 == 0 else 60) state.update({"every": max(1, sec // unit), "unit": unit}) else: state.update({"time": initial.get("time", "09:00"), "day": initial.get("day", 0)}) kinds = (g.spec.get("schedule_kinds") or ["interval", "daily", "weekly"]) box = {} def build(): rows = [GrrSegmentedItem([{"id": k, "label": t("sched.kind_" + k)} for k in kinds], state["kind"], lambda k: (state.update(kind=k), rebuild()))] if state["kind"] == "interval": units = [(60, t("sched.minutes")), (3600, t("sched.hours")), (86400, t("sched.days"))] rows.append(GrrSegmentedItem([{"id": u, "label": l} for u, l in units], state["unit"], lambda u: state.update(unit=u))) elif state["kind"] == "weekly": days = [GrrFormat.weekday(i)[:3] for i in range(7)] rows.append(GrrSegmentedItem([{"id": i, "label": d} for i, d in enumerate(days)], state["day"], lambda i: state.update(day=i))) return rows def rebuild(): dlg = box["dlg"] dlg.grr_page.set_items(build()) field = dlg.grr_fields[0] label = t("sched.every") if state["kind"] == "interval" else t("sched.time") field.placeholder = label field.lines = [str(state["every"]) if state["kind"] == "interval" else state["time"]] field.cx = len(field.lines[0]) def finish(value, texts): if value != "done": return raw = texts[0].strip() if state["kind"] == "interval": try: n = float(raw) except ValueError: g.toast(t("sched.floor", n=g.ui.cap("ROUTINE_MIN_INTERVAL", 30)), "bad") return secs = int(round(n * state["unit"])) floor = g.ui.cap("ROUTINE_MIN_INTERVAL", 30) if secs < floor: g.toast(t("sched.floor", n=floor), "bad") return done({"kind": "interval", "every_seconds": secs}) elif not re.match(r"^\d{1,2}:\d{2}$", raw): g.toast(t("sched.need_time"), "bad") elif state["kind"] == "daily": done({"kind": "daily", "time": raw.zfill(5)}) else: done({"kind": "weekly", "day": state["day"], "time": raw.zfill(5)}) first = str(state["every"]) if state["kind"] == "interval" else state["time"] dlg = g.dialog(title=t("automations.select_schedule"), w=60, fields=[{"value": first, "placeholder": t("sched.every")}], rows=build(), page_h=5, actions=[{"label": t("ui.cancel"), "value": "cancel"}, {"label": t("ui.done"), "value": "done", "primary": True}], done=finish) box["dlg"] = dlg class GrrAutomationView(GrrView): fallback = "/automations" def open(self): self.standard("", back="/automations") self.get("/api/routines/" + self.params["id"], self.loaded) def loaded(self, r): g, t = self.g, self.g.t self.r = r self.screen.title = r["name"] self.screen.right = [self.screen.chip("moreV", self.more)] self.screen.refresh() self.sub(g.events, "routine.update", lambda d: self.refetch(d)) self.paint() def refetch(self, d): if d and d.get("id") == self.r["id"] and not d.get("deleted"): self.get("/api/routines/" + self.r["id"], lambda r: (setattr(self, "r", r), self.paint())) def more(self): g, t, r = self.g, self.g.t, self.r def delete(): g.confirm(t("automations.delete"), t("automations.delete_text"), t("ui.delete"), lambda: self.call("DELETE", "/api/routines/" + r["id"], None, lambda _x: g.router.back("/automations")), True) g.menu([{"icon": "trash", "label": t("automations.delete"), "danger": True, "action": delete}]) def patch(self, body): self.call("PATCH", "/api/routines/" + self.r["id"], body, lambda res: (setattr(self, "r", res), self.paint())) def paint(self): g, t, ui, r = self.g, self.g.t, self.ui, self.r cap = ui.cap("INSTRUCTIONS_MAX_CHARS", 12000) def save_text(value): if not value.strip(): g.toast(t("automations.prompt_empty"), "bad") return self.patch({"instruction": value}) def pick_schedule(): grr_schedule_dialog(g, r.get("schedule"), lambda s: self.patch({"schedule": s})) bot = r.get("bot") items = grr_group([GrrRowItem(t("automations.active"), toggle=bool(r.get("active")), on_toggle=lambda v: self.patch({"active": v}))]) items += [GrrLabel(t("automations.instruction")), GrrTextCard(r.get("instruction", ""), action=lambda: g.edit(t("automations.instruction"), r.get("instruction", ""), save_text, True, 8, cap)), GrrLabel(t("automations.planning"))] items += grr_group([GrrRowItem(t("automations.schedule"), icon="clock", value=ui.fmt.schedule(r.get("schedule")), chevron=True, action=pick_schedule), GrrRowItem(t("automations.bot"), value=bot["name"] if bot else "-", lead=(lambda ui_, bg: GrrAvatar.chip(ui_, bot, bg)) if bot else None, chevron=True, action=(lambda: g.router.go("/bot/" + bot["id"])) if bot else None)]) items += [GrrLabel(t("automations.notifications"))] items += grr_group([GrrRowItem(t("automations.push"), icon="bell", toggle=bool(r.get("notify_push")), on_toggle=lambda v: self.patch({"notify_push": v})), GrrRowItem(t("automations.email"), icon="inbox", toggle=bool(r.get("notify_email")), on_toggle=lambda v: self.patch({"notify_email": v}))]) items += [GrrSpacer(1), GrrButtonItem(t("automations.run_now"), lambda: self.call("POST", "/api/routines/%s/run" % r["id"], {}, lambda _x: g.toast(t("automations.started"))), "secondary")] items.append(GrrNote(t("automations.next", when=ui.fmt.next_run(r.get("next_run")) if r.get("active") else "-"))) if r.get("last_run"): ok = r.get("last_status") == "ok" items.append(GrrNote(t("automations.last", when=ui.fmt.ago(r["last_run"]), status=t("automations.status_" + (r.get("last_status") or "ok"))), tone="success" if ok else "danger")) if r.get("last_result"): items.append(GrrTextCard(r["last_result"])) self.screen.items(items) class GrrNewAutomationView(GrrView): fallback = "/automations" def open(self): g, t, ui = self.g, self.g.t, self.ui self.s = None self.tier = "auto" self.bot_id = self.query.get("bot") or (g.store.bots[0]["id"] if g.store.bots else "") self.name = "" self.text = "" self.push = True self.email = False self.standard(t("automations.new"), close=True, back="/automations") self.screen.right = [{"text": " %s " % t("ui.create"), "fn": self.create, "bg": "accent", "fg": "accent-ink", "bold": True}] self.screen.refresh() self.paint() def paint(self): g, t, ui = self.g, self.g.t, self.ui cap = ui.cap("INSTRUCTIONS_MAX_CHARS", 12000) bots = [{"label": b["name"], "checked": b["id"] == self.bot_id, "action": (lambda b=b: self.set_bot(b["id"]))} for b in g.store.bots] tiers = [{"label": t("tier." + x), "checked": x == self.tier, "action": (lambda x=x: self.set_tier(x))} for x in (g.spec.get("tiers") or ["auto", "fast", "expert"])] bot = g.store.bot(self.bot_id) items = [GrrHeading(ui.icon("automation"), center=True), GrrRowItem(self.name or t("automations.name_ph"), icon="edit", tone="", action=lambda: g.edit(t("automations.name"), self.name, self.set_name, max_chars=60), pos="only"), GrrLabel(t("automations.planning")), GrrRowItem(ui.fmt.schedule(self.s) if self.s else t("automations.select_schedule"), icon="clock" if self.s else "plus", link=not self.s, action=self.pick_schedule), GrrLabel(t("automations.instructions")), GrrTextCard(self.text, placeholder=t("automations.instruction_ph"), action=lambda: g.edit(t("automations.instructions"), self.text, self.set_text, True, 8, cap)), GrrSpacer(1), GrrChipsItem([{"label": bot["name"] if bot else "-", "icon": "user", "action": lambda: g.menu(bots)}, {"label": t("tier." + self.tier), "icon": "sparkle", "action": lambda: g.menu(tiers)}]), GrrLabel(t("automations.notifications"))] items += grr_group([GrrRowItem(t("automations.push"), icon="bell", toggle=self.push, on_toggle=lambda v: setattr(self, "push", v)), GrrRowItem(t("automations.email"), icon="inbox", toggle=self.email, on_toggle=lambda v: setattr(self, "email", v))]) self.screen.items(items) def set_bot(self, bot_id): self.bot_id = bot_id self.paint() def set_tier(self, tier): self.tier = tier self.paint() def set_name(self, value): self.name = value self.paint() def set_text(self, value): self.text = value self.paint() def pick_schedule(self): grr_schedule_dialog(self.g, self.s, lambda s: (setattr(self, "s", s), self.paint())) def create(self): g, t = self.g, self.g.t if not self.text.strip(): g.alert("", t("automations.prompt_empty")) elif not self.s: g.alert("", t("automations.need_schedule")) elif not self.bot_id: g.alert("", t("automations.need_bot")) else: body = {"bot_id": self.bot_id, "name": self.name or self.text[:40], "instruction": self.text, "schedule": self.s, "notify_push": self.push, "notify_email": self.email, "tier": self.tier} self.call("POST", "/api/routines", body, lambda r: g.router.go("/automation/" + r["id"], True)) # ---- library, projects, search GRR_FILE_ICON = {"media": "image", "app": "terminal", "audio": "mic", "file": "file"} class GrrLibraryView(GrrView): fallback = "/" def open(self): g, t, ui = self.g, self.g.t, self.ui self.tab = "all" self.q = "" self.sort = "new" self.files = [] self.standard(t("home.library"), back="/") self.sub(g.events, "library.update", lambda _d: self.reload()) self.reload() def reload(self): self.get("/api/library", self.loaded) def loaded(self, res): self.files = res.get("files", []) self.paint() def paint(self): g, t, ui = self.g, self.g.t, self.ui tabs = GrrTabsItem([{"id": "all", "label": t("library.all")}, {"id": "media", "label": t("library.media")}, {"id": "app", "label": t("library.apps")}, {"id": "file", "label": t("library.files")}], self.tab, self.pick_tab) tabs.key = "tabs" sorts = [{"label": t("library.sort_" + s), "checked": self.sort == s, "action": (lambda s=s: self.set_sort(s))} for s in ("new", "old", "name", "size")] tools = GrrChipsItem([{"label": t("library.sort_" + self.sort), "icon": "tune", "action": lambda: g.menu(sorts)}, {"label": self.q or t("ui.search"), "icon": "search", "caret": False, "action": self.ask_filter}, {"label": t("library.upload"), "icon": "plus", "caret": False, "action": self.upload}]) rows = [f for f in self.files if (self.tab == "all" or (f.get("kind") in ("file", "audio") if self.tab == "file" else f.get("kind") == self.tab)) and (not self.q or self.q in f["name"].lower())] by = {"new": lambda f: -f.get("created", 0), "old": lambda f: f.get("created", 0), "name": lambda f: f["name"].lower(), "size": lambda f: -f.get("size", 0)} rows.sort(key=by[self.sort]) if rows: tiles = [{"label": f["name"], "sub": ui.fmt.ago(f.get("created", 0)) + " · " + GrrFormat.bytes(f.get("size", 0)), "icon": GRR_FILE_ICON.get(f.get("kind"), "file"), "action": (lambda f=f: g.open_file(f))} for f in rows] body = GrrTileGridItem(tiles, tile_h=6) else: text = {"app": t("library.no_apps"), "media": t("library.no_media"), "file": t("library.no_files")}.get(self.tab, t("library.empty")) body = GrrEmptyItem("box", text, t("library.empty_sub")) self.screen.items([tabs, tools, GrrSpacer(1), body]) def pick_tab(self, tab): self.tab = tab self.paint() def set_sort(self, sort): self.sort = sort self.paint() def ask_filter(self): t = self.g.t self.g.edit(t("ui.search"), self.q, lambda v: (setattr(self, "q", v.strip().lower()), self.paint())) def upload(self): g, t = self.g, self.g.t def send(path): path = os.path.expanduser(path.strip()) try: size = os.path.getsize(path) if size > g.ui.cap("UPLOAD_MAX_BYTES", 20971520): raise GrrError(0, "too_big", t("err.too_big", max=GrrFormat.bytes(g.ui.cap("UPLOAD_MAX_BYTES", 20971520)))) with open(path, "rb") as fh: data = fh.read() except (OSError, GrrError) as exc: g.error(exc if isinstance(exc, GrrError) else GrrError(0, "file", str(exc))) return g.app.offload(lambda: g.client.upload(os.path.basename(path), data), lambda _r, e: (g.error(e) if e else self.reload())) g.edit(t("library.upload"), "", send, text="Path of the file on this machine") class GrrProjectsView(GrrView): fallback = "/" def open(self): g, t = self.g, self.g.t self.filter = "all" self.sort = "updated" self.standard(t("home.projects"), back="/") self.screen.right = [self.screen.chip("plus", self.create)] self.screen.refresh() self.load() def load(self): self.get("/api/projects?sort=%s%s" % (self.sort, "&starred=1" if self.filter == "starred" else ""), self.paint) def paint(self, res): g, t, ui = self.g, self.g.t, self.ui filters = [{"label": t("projects.all"), "checked": self.filter == "all", "action": lambda: self.set("filter", "all")}, {"label": t("projects.starred"), "checked": self.filter == "starred", "action": lambda: self.set("filter", "starred")}] sorts = [{"label": t("projects.last_edited"), "checked": self.sort == "updated", "action": lambda: self.set("sort", "updated")}, {"label": t("projects.name"), "checked": self.sort == "name", "action": lambda: self.set("sort", "name")}] chips = GrrChipsItem([{"label": t("projects.all") if self.filter == "all" else t("projects.starred"), "icon": "tune", "action": lambda: g.menu(filters)}, {"label": t("projects.last_edited") if self.sort == "updated" else t("projects.name"), "icon": "tune", "action": lambda: g.menu(sorts)}]) projects = res.get("projects", []) if projects: tiles = [{"label": p["name"], "sub": t("projects.modified", date=ui.fmt.short_date(p.get("updated", 0))), "icon": "star" if p.get("starred") else "folder", "on": bool(p.get("starred")), "action": (lambda p=p: g.router.go("/project/" + p["id"]))} for p in projects] body = GrrTileGridItem(tiles, tile_h=6) else: body = GrrEmptyItem("folder", t("projects.empty"), t("projects.empty_sub")) self.screen.items([chips, GrrSpacer(1), body]) def set(self, key, value): setattr(self, key, value) self.load() def create(self): g, t = self.g, self.g.t def done(value, texts): if value == "create": self.call("POST", "/api/projects", {"name": texts[0], "instructions": texts[1]}, lambda p: g.router.go("/project/" + p["id"])) g.dialog(title=t("projects.new"), w=64, fields=[{"value": "", "placeholder": t("projects.name_ph"), "max": 60}, {"value": "", "placeholder": t("projects.instructions_ph"), "rows": 4}], actions=[{"label": t("ui.cancel"), "value": "cancel"}, {"label": t("ui.create"), "value": "create", "primary": True}], done=done) class GrrProjectView(GrrView): fallback = "/projects" def open(self): self.standard("", back="/projects", foot=None) self.sub(self.g.events, "chat.update", lambda _d: self.reload()) self.reload() def reload(self): self.get("/api/projects/" + self.params["id"], self.loaded) def loaded(self, p): self.p = p t, ui = self.g.t, self.ui self.screen.title = ("%s " % ui.icon("star") if p.get("starred") else "") + p["name"] self.screen.right = [self.screen.chip("moreV", self.menu)] self.screen.refresh() chats = p.get("chats") or [] items = [] if not chats: items.append(GrrEmptyItem("compose", t("projects.no_chats"), t("projects.no_chats_sub"))) for c in chats: items.append(GrrConvItem(lambda ui_, bg: [[TuiSpan(" ", ui_.st("ink", bg))], [TuiSpan(" ", ui_.st("ink", bg))]], c.get("title") or t("chat.untitled"), "", ui.fmt.short_date(c.get("updated", 0)), action=(lambda c=c: self.g.router.go("/chat/" + c["id"])), menu=(lambda c=c: self.chat_menu(c)), key=c["id"])) items.append(GrrButtonItem(t("projects.new_chat"), lambda: self.g.router.go("/chat/new?project=" + p["id"]), "secondary", inline=True, icon="compose")) self.screen.items(items) def chat_menu(self, c): g, t = self.g, self.g.t g.menu([{"icon": "trash", "label": t("ui.delete"), "danger": True, "action": lambda: g.call("DELETE", "/api/chats/" + c["id"], then=lambda _r: self.reload())}]) def menu(self): g, t, p = self.g, self.g.t, self.p def rename(): g.edit(t("chat.rename"), p["name"], lambda v: self.call("PATCH", "/api/projects/" + p["id"], {"name": v}, lambda _r: self.reload()), max_chars=60) def instructions(): g.edit(t("projects.instructions"), p.get("instructions", ""), lambda v: self.call("PATCH", "/api/projects/" + p["id"], {"instructions": v}, lambda _r: self.reload()), True, 6, g.ui.cap("INSTRUCTIONS_MAX_CHARS", 12000), text=t("projects.instructions_hint")) def delete(): g.confirm(t("projects.delete"), t("projects.delete_text"), t("ui.delete"), lambda: self.call("DELETE", "/api/projects/" + p["id"], None, lambda _r: g.router.go("/projects", True)), True) g.menu([{"icon": "edit", "label": t("chat.rename"), "action": rename}, {"icon": "star", "label": t("projects.unstar") if p.get("starred") else t("projects.star"), "action": lambda: self.call("PATCH", "/api/projects/" + p["id"], {"starred": not p.get("starred")}, lambda _r: self.reload())}, {"icon": "file", "label": t("projects.instructions"), "action": instructions}, "-", {"icon": "trash", "label": t("ui.delete"), "danger": True, "action": delete}]) class GrrSearchView(GrrView): fallback = "/" def open(self): g, t, ui = self.g, self.g.t, self.ui self.kind = "all" self.q = "" self.seq = 0 self.root = TuiRows(w="1fr", h="100%", style=ui.st("ink", "bg")) self.bar = GrrBar(ui, style=ui.st("ink", "bg")) self.box = TuiTextBox("", h=1, border=True, rounded=True, padding=(0, 1), placeholder=t("ui.search"), style=ui.st("ink", "card"), border_style=ui.st("rule-strong", "bg"), focus_border_style=ui.st("accent", "bg"), send_on_enter=True, on_send=lambda _v: self.run()) self.page = GrrPage(ui, style=ui.st("ink", "bg"), h="1fr") self.root.add(self.bar, self.box, self.page) self.screen = None self.refresh_bar() self.every(0.25, self.watch) self.run() self.show(self.root, self.box) def refresh_bar(self): g, t, ui = self.g, self.g.t, self.ui kinds = [{"label": t("search.kind_" + k), "checked": self.kind == k, "action": (lambda k=k: self.set_kind(k))} for k in (g.spec.get("search_kinds") or ["all"])] self.bar.set([{"text": " %s " % ui.icon("close"), "fn": lambda: g.router.back("/"), "bg": "card", "bold": True}, {"text": t("ui.search") + " · " + t("search.kind_" + self.kind), "fill": True, "align": "center", "bold": True}, {"text": " %s " % ui.icon("filter"), "fn": lambda: g.menu(kinds), "bg": "card"}]) def on_escape(self): self.g.router.back("/") return True def set_kind(self, kind): self.kind = kind self.refresh_bar() self.run() def watch(self): if self.box.value != self.q: self.q = self.box.value self.run() def run(self): self.seq += 1 mine = self.seq path = "/api/search?kind=%s&q=%s" % (self.kind, urllib.parse.quote(self.q)) def landed(res): if mine == self.seq: self.paint(res) self.get(path, landed) def paint(self, res): g, t, ui = self.g, self.g.t, self.ui results = res.get("results", []) if not results: self.page.set_items([GrrEmptyItem("search", t("search.none"), t("search.none_sub"))]) return def snippet(x): return re.sub(r"[\[\]]", "", str(x.get("snippet", ""))) items = [] blank = lambda ui_, bg: [[TuiSpan(" ", ui_.st("ink", bg))], [TuiSpan(" ", ui_.st("soft", bg))]] for x in results: kind = x.get("type") if kind == "bot": bot = g.store.bot(x["id"]) or x.get("bot") items.append(GrrConvItem(grr_bot_avatar(bot), x["title"], "", snippet(x), tag=t("search.tag_bot"), action=lambda b=bot: g.router.go("/chat/" + (g.store.bot(b["id"]) or {}).get("chat", {}).get("id", "new")))) elif kind == "group": items.append(GrrConvItem(blank, x["title"], "", snippet(x), tag=t("bots.group"), action=lambda x=x: g.router.go("/chat/" + x["id"]))) elif kind == "message": items.append(GrrConvItem(blank, x["title"], ui.fmt.short_date(x.get("ts", 0)), snippet(x), action=lambda x=x: g.router.go("/chat/" + x["chat_id"]))) elif kind == "file": items.append(GrrConvItem(blank, x["title"], "", snippet(x), tag=t("search.tag_file"), action=lambda x=x: g.open_file({"id": x["id"], "name": x["title"], "mime": x.get("snippet", ""), "kind": x.get("kind")}))) else: items.append(GrrConvItem(blank, x["title"], "", snippet(x), tag=t("search.tag_routine"), action=lambda x=x: g.router.go("/automation/" + x["id"]))) self.page.set_items(items) # --------------------------------------------------------------------------- # Part E: settings and its sub screens, templates, shell helpers. # --------------------------------------------------------------------------- class GrrSettingsView(GrrView): fallback = "/" def open(self): t = self.g.t self.standard(t("home.settings"), close=True, back="/") self.batch([("GET", "/api/rules", None), ("GET", "/api/usage", None)], self.paint) def paint(self, res): g, t, ui = self.g, self.g.t, self.ui rules, usage = res s = g.settings me = g.me or {} go = g.router.go docs = lambda name: (lambda: g.open_url("%s/docs/%s.html" % (g.client.base, name))) items = grr_group([GrrRowItem(me.get("username", ""), sub=me.get("email", ""), lead=(lambda ui_, bg: [TuiSpan(" %s " % (me.get("username", "?")[:1].upper()), ui_.st("ink", "rule-strong", bold=True)), TuiSpan(" ", ui_.st("ink", bg))]), chevron=True, action=lambda: go("/settings/account")), GrrRowItem(t("settings.usage"), value="%s%%" % usage.get("pct", 0), chevron=True, action=lambda: go("/settings/usage"))]) items += [GrrSpacer(1)] items += grr_group([GrrRowItem(t("settings.connect"), sub=t("settings.connect_sub"), chevron=True, action=lambda: go("/settings/connectors")), GrrRowItem(t("settings.builder"), sub=t("settings.builder_sub"), chevron=True, action=lambda: go("/settings/builder")) if me.get("is_admin") else None]) items += [GrrLabel(t("settings.bots"))] items += grr_group([GrrRowItem(t("settings.auto_review"), sub=t("settings.auto_review_sub"), toggle=bool(s.get("auto_review")), on_toggle=lambda v: g.save_settings({"auto_review": v})), GrrRowItem(t("settings.rules"), value=str(len(rules.get("rules", []))), chevron=True, action=lambda: go("/settings/rules")), GrrRowItem(t("settings.auto_tz"), sub=t("settings.auto_tz_sub"), toggle=bool(s.get("auto_tz")), on_toggle=lambda v: g.save_settings({"auto_tz": v}, lambda _m: g.sync_timezone() if v else None)), GrrRowItem(t("settings.timezone"), value=s.get("timezone", ""), chevron=True, action=lambda: go("/settings/timezone")), GrrRowItem(t("settings.computer"), chevron=True, action=lambda: go("/settings/computer"))]) items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("settings.notifications"), toggle=bool(s.get("notifications")), on_toggle=lambda v: g.save_settings({"notifications": v}))]) lang = s.get("language", "system") items += [GrrSpacer(1)] + grr_group([ GrrRowItem(t("settings.appearance"), value=t("appearance." + s.get("theme", "system")) + " · " + t("appearance.v_" + s.get("variant", "black")), chevron=True, action=lambda: go("/settings/appearance")), GrrRowItem(t("settings.language"), value=t("lang.system") if lang == "system" else GRR_LANG_NAMES.get(lang, lang), chevron=True, action=lambda: go("/settings/language")), GrrRowItem(t("settings.haptics"), value=t("ui.on") if s.get("haptics") else t("ui.off"), chevron=True, action=lambda: go("/settings/haptics")), GrrRowItem(t("settings.widget"), chevron=True, action=lambda: go("/settings/widget")), GrrRowItem(t("settings.advanced"), chevron=True, action=lambda: go("/settings/advanced"))]) items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("settings.help"), chevron="ext", action=docs("help")), GrrRowItem(t("settings.privacy"), chevron="ext", action=docs("privacy")), GrrRowItem(t("settings.terms"), chevron="ext", action=docs("terms")), GrrRowItem(t("settings.bot_terms"), chevron="ext", action=docs("bots"))]) items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("settings.feedback"), chevron=True, action=lambda: go("/settings/feedback"))]) items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("settings.sign_out"), danger=True, action=lambda: sign_out_dialog(g))]) items.append(GrrNote("grr " + str((g.spec or {}).get("version", "")), center=True)) self.screen.items(items) def sign_out_dialog(g): t = g.t g.confirm(t("settings.sign_out"), t("settings.sign_out_text"), t("settings.sign_out"), g.sign_out, True) class GrrSubView(GrrView): """A settings sub screen: header, page, optional foot button.""" title_key = "" fallback = "/settings" def open(self): self.standard(self.g.t(self.title_key), back="/settings", foot=self.foot()) self.start() def foot(self): return None def start(self): self.paint(None) def paint(self, res): pass class GrrAccountView(GrrSubView): title_key = "account.title" def paint(self, res): g, t = self.g, self.g.t me = g.me or {} accounts = g.session.accounts items = grr_group([GrrRowItem(me.get("username", ""), sub=me.get("email", ""), lead=(lambda ui_, bg: [TuiSpan(" %s " % (me.get("username", "?")[:1].upper()), ui_.st("ink", "rule-strong", bold=True)), TuiSpan(" ", ui_.st("ink", bg))]))]) rows = [GrrRowItem(a.get("email") or a.get("username"), check=a["token"] == g.client.token, action=(lambda a=a: g.switch_account(a["token"]) if a["token"] != g.client.token else None)) for a in accounts] rows.append(GrrRowItem(t("account.add"), icon="plus", action=lambda: g.show_login())) items += [GrrLabel(t("account.switch"))] + grr_group(rows) items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("settings.sign_out"), danger=True, action=lambda: sign_out_dialog(g))]) items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("account.delete"), icon="trash", danger=True, action=self.remove)]) + [GrrNote(t("account.delete_note"))] self.screen.items(items) def remove(self): g, t = self.g, self.g.t me = g.me or {} def done(value, texts): if value == "delete": g.call("DELETE", "/api/me", {"confirm": texts[0]}, then=lambda _r: g.after_sign_out()) g.dialog(title=t("account.delete"), text=t("account.delete_confirm", name=me.get("username", "")), fields=[{"value": "", "placeholder": me.get("username", "")}], actions=[{"label": t("ui.cancel"), "value": "cancel"}, {"label": t("ui.delete"), "value": "delete", "primary": True, "danger": True}], done=done) class GrrUsageView(GrrSubView): title_key = "settings.usage" def start(self): self.get("/api/usage", self.paint) def paint(self, u): g, t, ui = self.g, self.g.t, self.ui items = [GrrProgressItem(t("usage.weekly"), u.get("pct", 0), t("usage.resets", n=u.get("resets_in_days", 0)), "%s%% · %s / %s %s" % (u.get("pct", 0), GrrFormat.num(u.get("tokens", 0)), GrrFormat.num(u.get("limit", 0)), t("usage.tokens"))), GrrRowItem(t("usage.change_limit"), chevron=True, action=lambda: self.change(u)), GrrLabel(t("usage.days"))] days = u.get("days", []) items.append(GrrBarsItem(days) if days else GrrNote(t("usage.none"))) dp = u.get("devplace") items.append(GrrLabel(t("usage.gateway"))) if dp: items.append(GrrKvItem([(t("usage.gw_spent"), "$%.4f" % float(dp.get("spent_usd", 0))), (t("usage.gw_limit"), t("usage.unlimited") if dp.get("unlimited") else "$%s" % dp.get("limit_usd"))])) else: items.append(GrrKvItem([(t("usage.gw"), t("usage.gw_down"))])) models = u.get("models") or {} if models: items.append(GrrLabel(t("usage.models"))) items.append(GrrKvItem([(m, "%s/%s · %s ms%s" % (v.get("wins"), v.get("runs"), v.get("latency_ms"), " · " + t("usage.cooling") if v.get("cooled_until", 0) * 1000 > time.time() * 1000 else "")) for m, v in models.items()])) counters = u.get("counters") or {} if counters: items += [GrrLabel(t("usage.counters")), GrrKvItem([(k, str(v)) for k, v in counters.items()]), GrrNote(t("usage.counters_note"))] self.screen.items(items) def change(self, u): g, t = self.g, self.g.t def save(value): try: limit = int(float(value)) except ValueError: g.toast(t("apierr.bad_limit"), "bad") return g.call("PUT", "/api/usage/limit", {"limit": limit}, then=lambda _r: self.start()) g.edit(t("usage.change_limit"), str(u.get("limit", "")), save, text=t("usage.limit_text")) class GrrRulesView(GrrSubView): title_key = "settings.rules" def start(self): self.sub(self.g.events, "connector.update", lambda _d: self.start()) self.get("/api/rules", self.paint) def paint(self, res): g, t = self.g, self.g.t rules = res.get("rules", []) items = grr_group([GrrRowItem(r["text"], sub=t("rule." + r["action"]), wrap=True, chevron=True, action=(lambda r=r: g.router.go("/settings/rule/" + r["id"]))) for r in rules]) if rules else [] items.append(GrrRowItem(t("rules.add"), link=True, action=lambda: g.router.go("/settings/rule/new"))) items.append(GrrNote(t("rules.note"))) self.screen.items(items) class GrrRuleView(GrrView): fallback = "/settings/rules" def open(self): g, t = self.g, self.g.t self.new = self.params["id"] == "new" self.rule = {"text": "", "action": "allow", "bot_id": ""} self.standard(t("rules.edit"), back="/settings/rules", foot=[GrrButtonItem(t("rules.save"), self.save)]) if self.new: self.paint() else: self.get("/api/rules", self.loaded) def loaded(self, res): rule = next((r for r in res.get("rules", []) if r["id"] == self.params["id"]), None) if rule is None: self.fail_page(GrrError(404, "not_found", self.g.t("err.not_found")), "/settings/rules") return self.rule = dict(rule) self.paint() def paint(self): g, t = self.g, self.g.t rule = self.rule bots = [{"label": t("rules.all_bots"), "checked": not rule.get("bot_id"), "action": lambda: self.set("bot_id", "")}] bots += [{"label": b["name"], "checked": rule.get("bot_id") == b["id"], "action": (lambda b=b: self.set("bot_id", b["id"]))} for b in g.store.bots] current = g.store.bot(rule.get("bot_id")) if rule.get("bot_id") else None items = [GrrLabel(t("rules.when")), GrrChipsItem([{"label": current["name"] if current else t("rules.all_bots"), "icon": "user", "action": lambda: g.menu(bots)}]), GrrTextCard(rule.get("text", ""), placeholder=t("rules.placeholder"), action=lambda: g.edit(t("rules.text"), rule.get("text", ""), lambda v: self.set("text", v), True, 4, 300)), GrrLabel(t("rules.should"))] items += grr_group([GrrRowItem(t("rule." + a), check=rule.get("action") == a, action=(lambda a=a: self.set("action", a))) for a in (g.spec.get("rule_actions") or ["allow", "ask"])]) if not self.new: items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("rules.delete"), danger=True, action=self.delete)]) self.screen.items(items) def set(self, key, value): self.rule[key] = value self.paint() def save(self): g, rule = self.g, self.rule body = {"text": rule.get("text", ""), "action": rule.get("action", "allow"), "bot_id": rule.get("bot_id", "")} if self.new: self.call("POST", "/api/rules", body, lambda _r: g.router.back("/settings/rules")) else: self.call("PATCH", "/api/rules/" + rule["id"], body, lambda _r: g.router.back("/settings/rules")) def delete(self): self.call("DELETE", "/api/rules/" + self.rule["id"], None, lambda _r: self.g.router.back("/settings/rules")) class GrrAppearanceView(GrrSubView): title_key = "settings.appearance" def paint(self, res): g, t, ui = self.g, self.g.t, self.ui s = g.settings icons = {"system": "contrast", "day": "sun", "night": "moon"} themes = [{"label": t("appearance." + x), "icon": icons[x], "on": s.get("theme") == x, "action": (lambda x=x: g.save_settings({"theme": x}, lambda _m: self.paint(None)))} for x in (g.spec.get("themes") or ["system", "day", "night"])] variants = [] for v in (g.spec.get("variants") or ["black", "blue"]): accent = ((((g.spec.get("palette") or {}).get("variants") or {}).get(v) or {}).get("night") or {}).get("accent") or ui.palette.c("accent") variants.append({"label": t("appearance.v_" + v), "on": s.get("variant") == v, "art": (lambda ui_, bg, a=accent: [[TuiSpan("●", TuiStyle(fg=a, bg=ui_.palette.c(bg)))]]), "action": (lambda v=v: g.save_settings({"variant": v}, lambda _m: self.paint(None)))}) box = g.spec.get("ui_scale") or {"min": 70, "max": 150, "step": 5, "default": 100} size = int(s.get("ui_scale") or box["default"]) def step(delta): nxt = max(box["min"], min(box["max"], size + delta)) g.save_settings({"ui_scale": nxt}, lambda _m: self.paint(None)) items = [GrrTileGridItem(themes, cols=3, tile_h=4), GrrSpacer(1), GrrTileGridItem(variants, cols=2, tile_h=4), GrrSpacer(1)] items += grr_group([GrrRowItem(t("appearance.size"), value="%d%%" % size, sub=t("appearance.size_sub"), icon="tune", action=lambda: step(box["step"])), GrrRowItem(t("appearance.size_reset"), action=lambda: g.save_settings({"ui_scale": box["default"]}, lambda _m: self.paint(None)), tone="ink")]) self.screen.items(items) class GrrLanguageView(GrrSubView): title_key = "settings.language" def paint(self, res): g, t = self.g, self.g.t cur = g.settings.get("language", "system") rows = [GrrRowItem(t("lang.system") if l == "system" else GRR_LANG_NAMES.get(l, l), check=cur == l, action=(lambda l=l: g.save_settings({"language": l}, lambda _m: g.router.go("/settings/language", True)))) for l in (g.spec.get("languages") or ["system", "en"])] self.screen.items(grr_group(rows)) class GrrHapticsView(GrrSubView): title_key = "settings.haptics" def paint(self, res): g, t = self.g, self.g.t self.screen.items(grr_group([GrrRowItem(t("haptics.title"), toggle=bool(g.settings.get("haptics")), on_toggle=lambda v: g.save_settings({"haptics": v}))]) + [GrrNote(t("haptics.unsupported"))]) class GrrWidgetView(GrrSubView): title_key = "settings.widget" def paint(self, res): g, t = self.g, self.g.t url = g.client.base + "/widget" items = grr_group([GrrRowItem(t("widget.show"), toggle=bool(g.settings.get("widget")), on_toggle=lambda v: g.save_settings({"widget": v}))]) + [GrrNote(t("widget.note"))] items += grr_group([GrrRowItem(t("widget.open"), chevron="ext", action=lambda: g.open_url(url)), GrrRowItem(t("widget.copy"), sub=url, action=lambda: g.copy_text(url))]) self.screen.items(items) class GrrComputersView(GrrSubView): title_key = "settings.computer" def start(self): bots = self.g.store.bots self.batch([("GET", "/api/bots/%s/computer" % b["id"], None) for b in bots], self.paint) if bots else self.paint([]) def paint(self, res): g, t = self.g, self.g.t bots = g.store.bots rows = [GrrRowItem(b["name"], lead=(lambda ui_, bg, b=b: GrrAvatar.chip(ui_, b, bg) + [TuiSpan(" ", ui_.st("ink", bg))]), sub=t("computer.disk") + ": " + t("computer.disk_" + c["disk"]["state"]) + (" · " + t("computer.busy") if c.get("busy") else ""), chevron=True, action=(lambda b=b, c=c: self.manage(b, c))) for b, c in zip(bots, res)] self.screen.items((grr_group(rows) if rows else [GrrEmptyItem("cpu", t("computer.none"))]) + [GrrNote(t("computer.each"))]) def manage(self, b, c): g, t = self.g, self.g.t def act(kind, force=False): def landed(_r): g.toast(t("computer.done_" + kind)) def failed(err): if isinstance(err, GrrError) and err.code == "busy" and not force: g.dialog(title=t("computer.working_title"), text=t("computer.working_text"), actions=[{"label": t("ui.cancel"), "value": False}, {"label": t("computer.anyway"), "value": True, "primary": True}], done=lambda v, _t: act(kind, True) if v else None) else: g.error(err) g.call("POST", "/api/bots/%s/computer/%s" % (b["id"], kind), {"force": True} if force else {}, landed, failed) rows = grr_group([GrrRowItem(t("computer.update"), sub=t("computer.update_sub"), link=True, wrap=True, action=lambda: act("update")), GrrRowItem(t("computer.reset"), sub=t("computer.reset_sub"), danger=True, wrap=True, action=lambda: act("reset"))]) g.pick(b["name"], rows) class GrrConnectorsView(GrrSubView): title_key = "settings.connect" def start(self): self.get("/api/connectors", self.paint) def paint(self, res): g, t = self.g, self.g.t rows = [GrrRowItem(t("conn." + c["name"]), sub=t("conn." + c["name"] + "_sub") + " · " + ", ".join(c.get("tools", [])), wrap=True, toggle=bool(c.get("enabled")), on_toggle=(lambda v, c=c: g.call("PUT", "/api/connectors/" + c["name"], {"enabled": v}))) for c in res.get("connectors", [])] self.screen.items(grr_group(rows) + [GrrNote(t("conn.note"))]) class GrrAdvancedView(GrrSubView): title_key = "settings.advanced" def start(self): self.get("/api/health", self.paint) def paint(self, h): g, t, ui = self.g, self.g.t, self.ui tiers = [{"label": t("tier." + x), "checked": g.settings.get("tier") == x, "action": (lambda x=x: g.save_settings({"tier": x}, lambda _m: self.start()))} for x in (g.spec.get("tiers") or ["auto", "fast", "expert"])] items = [GrrLabel(t("advanced.tier")), GrrChipsItem([{"label": t("tier." + g.settings.get("tier", "auto")), "icon": "sparkle", "action": lambda: g.menu(tiers)}]), GrrNote(t("advanced.tier_note"))] items += grr_group([GrrRowItem(t("advanced.replace_key"), icon="key", chevron=True, action=self.replace), GrrRowItem(t("advanced.export"), icon="download", sub=t("advanced.export_sub"), chevron=True, action=self.export)]) items += [GrrLabel(t("advanced.health")), GrrKvItem([(t("advanced.pressure"), t("pressure." + h.get("pressure", "calm"))), (t("advanced.turns"), "%s / %s" % (h.get("turns_active"), h.get("turns_cap"))), (t("advanced.db"), "%.1f MB" % (h.get("db_bytes", 0) / 1048576.0)), (t("advanced.uptime"), "%d min" % round(h.get("uptime", 0) / 60)), (t("advanced.version"), h.get("version", ""))])] self.screen.items(items) def replace(self): g, t = self.g, self.g.t def done(value, texts): if value != "save": return def work(): return g.client.request("POST", "/api/auth/login", {"api_key": texts[0]}, quiet=True) def landed(res, err): if err: g.error(err) else: g.session.add(res["token"], res["account"]) g.client.token = res["token"] g.stop_stream() g.signed_in = False g.enter() g.app.offload(work, landed) g.dialog(title=t("advanced.replace_key"), text=t("advanced.replace_text"), fields=[{"value": "", "placeholder": t("login.key_placeholder"), "secret": True}], actions=[{"label": t("ui.cancel"), "value": "cancel"}, {"label": t("ui.save"), "value": "save", "primary": True}], done=done) def export(self): g = self.g def work(): data, _cut, _type = g.client.fetch("/api/export", limit=GRR_BODY_MAX) path = os.path.join(os.getcwd(), "grr-export.json") with open(path, "wb") as fh: fh.write(data) return path g.app.offload(work, lambda path, err: g.error(err) if err else g.toast("Saved " + path)) class GrrBuilderView(GrrSubView): title_key = "builder.title" def start(self): self.get("/api/builder", self.paint) def paint(self, b): g, t = self.g, self.g.t if not b.get("admin"): self.screen.items([GrrEmptyItem("key", t("builder.not_admin"))]) return c = b["config"] builder = g.spec.get("builder") or {} def save(patch): self.call("PUT", "/api/builder/config", patch, lambda _r: self.start()) def chip(key, ids, label): return {"label": label(c[key]), "icon": "sparkle", "action": lambda: g.menu([{"label": label(i), "checked": c[key] == i, "action": (lambda i=i: save({key: i}))} for i in ids])} def number(key, label): return GrrRowItem(label, value=str(c[key]), chevron=True, action=lambda: g.edit(label, str(c[key]), lambda v: save({key: float(v)}) if re.match(r"^[0-9.]+$", v.strip()) else g.toast(t("apierr.invalid"), "bad"))) pct = lambda v: t("builder.unknown") if v is None else "%s%%" % v items = [GrrLabel(t("builder.state")), GrrKvItem([(t("builder.engine"), t("builder.engine_" + b["engine"])), (t("builder.weekly"), pct(b.get("weekly_pct"))), (t("builder.five_hour"), pct(b.get("five_hour_pct"))), (t("builder.ladder"), "%s / %s / %s %%" % (b["ladder"]["notice"], b["ladder"]["ask"], b["ladder"]["floor"]))])] items += grr_group([GrrRowItem(t("builder.enabled"), sub=t("builder.enabled_sub"), toggle=bool(c.get("enabled")), on_toggle=lambda v: save({"enabled": v}))]) items += [GrrLabel(t("builder.model")), GrrChipsItem([chip("model", builder.get("models", []), lambda x: x)]), GrrLabel(t("builder.effort")), GrrChipsItem([chip("effort", builder.get("efforts", []), lambda x: x)]), GrrLabel(t("builder.permission")), GrrChipsItem([chip("permission", builder.get("permissions", []), lambda x: t("builder.perm_" + x))]), GrrLabel(t("builder.tools")), GrrChipsItem([chip("tools", list((builder.get("tool_presets") or {}).keys()), lambda x: t("builder.tools_" + x))])] items += grr_group([number("budget_usd", t("builder.budget")), number("timeout", t("builder.timeout"))]) items += [GrrLabel(t("builder.notes")), GrrTextCard(c.get("notes", ""), placeholder=t("builder.notes_ph"), action=lambda: g.edit(t("builder.notes"), c.get("notes", ""), lambda v: save({"notes": v}), True, 6, g.ui.cap("BUILDER_NOTES_MAX_CHARS", 4000)))] items += [GrrSpacer(1)] + grr_group([GrrRowItem(t("builder.sync"), sub=t("builder.sync_sub"), icon="refresh", chevron=True, action=lambda: self.call("POST", "/api/builder/sync", {}, lambda r: g.toast(t("builder.synced", mode=r.get("synced")))))]) items.append(GrrNote(t("builder.note"))) self.screen.items(items) class GrrFeedbackView(GrrSubView): title_key = "settings.feedback" def foot(self): return [GrrButtonItem(self.g.t("feedback.send"), self.send)] def start(self): self.text = "" self.bot_id = "" self.wants = False self.paint(None) def paint(self, res): g, t = self.g, self.g.t bots = [{"label": t("feedback.none"), "checked": not self.bot_id, "action": lambda: self.set("bot_id", "")}] bots += [{"label": b["name"], "checked": self.bot_id == b["id"], "action": (lambda b=b: self.set("bot_id", b["id"]))} for b in g.store.bots] bot = g.store.bot(self.bot_id) if self.bot_id else None items = [GrrTextCard(self.text, placeholder=t("feedback.placeholder"), action=lambda: g.edit(t("settings.feedback"), self.text, lambda v: self.set("text", v), True, 7, 4000)), GrrNote(t("feedback.note")), GrrLabel(t("feedback.which_bot")), GrrChipsItem([{"label": bot["name"] if bot else t("feedback.none"), "icon": "user", "action": lambda: g.menu(bots)}])] items += grr_group([GrrRowItem(t("feedback.want_response"), toggle=self.wants, on_toggle=lambda v: setattr(self, "wants", v))]) self.screen.items(items) def set(self, key, value): setattr(self, key, value) self.paint(None) def send(self): g, t = self.g, self.g.t if not self.text.strip(): g.toast(t("feedback.empty"), "bad") return self.call("POST", "/api/feedback", {"text": self.text, "bot_id": self.bot_id, "wants_response": self.wants}, lambda _r: (g.toast(t("feedback.thanks")), g.router.back("/settings"))) class GrrTimezoneView(GrrSubView): title_key = "settings.timezone" def start(self): try: import zoneinfo zones = sorted(zoneinfo.available_timezones()) except Exception: zones = ["UTC", "Europe/Amsterdam", "Europe/London", "America/New_York", "Asia/Tokyo"] if "UTC" not in zones: zones.insert(0, "UTC") self.zones = zones self.q = "" self.paint(None) def paint(self, res): g, t = self.g, self.g.t cur = g.settings.get("timezone") shown = [z for z in self.zones if self.q in z.lower()][:80] items = [GrrChipsItem([{"label": self.q or t("ui.search"), "icon": "search", "caret": False, "action": lambda: g.edit(t("ui.search"), self.q, lambda v: self.search(v))}])] items += grr_group([GrrRowItem(z, check=z == cur, action=(lambda z=z: g.save_settings({"timezone": z, "auto_tz": False}, lambda _m: g.router.back("/settings")))) for z in shown]) self.screen.items(items) def search(self, value): self.q = value.strip().lower() self.paint(None) # ---- templates class GrrTemplateView(GrrView): fallback = "/" def open(self): self.standard("", close=True, back="/") self.get("/api/templates/" + self.params["id"], self.loaded) def loaded(self, tpl): self.tpl = tpl self.paint() def paint(self): g, t, tpl = self.g, self.g.t, self.tpl snap = tpl["snapshot"] go = lambda part: (lambda: g.router.go("/template/%s/%s" % (tpl["id"], part))) items = grr_hero_items(self.ui, tpl["name"], tpl.get("description", ""), snap) items += [GrrLabel(t("template.details"))] items += grr_group([GrrRowItem(t("profile.instructions"), chevron=True, action=go("instructions")), GrrRowItem(t("profile.memories"), value=str(len(snap.get("memories", []))), chevron=True, action=go("memories")), GrrRowItem(t("profile.skills"), value=str(len(snap.get("skills", []))), chevron=True, action=go("skills"))]) items.append(GrrNote(t("template.scrub_note"))) items.append(GrrSpacer(1)) if tpl.get("state") == "published": items.append(GrrButtonItem(t("template.copy_link"), lambda: g.copy_text("%s/t/%s" % (g.client.base, tpl["slug"])))) items.append(GrrButtonItem(t("template.unpublish"), lambda: self.set_state("unpublish"), "secondary")) else: items.append(GrrButtonItem(t("template.publish"), lambda: self.set_state("publish"))) self.screen.items(items) def set_state(self, verb): self.call("POST", "/api/templates/%s/%s" % (self.tpl["id"], verb), {}, lambda res: (self.tpl.update(res), self.paint())) def grr_hero_items(ui, name, desc, snap): class Hero(GrrItem): def build(self, ui_, width, selected): out = [] for row in GrrAvatar.rows(ui_, {"shape": snap.get("shape"), "color": snap.get("color")}, "bg", hero=True): out.append(grr_line([TuiSpan(" " * ((width - tui_span_width(row)) // 2), ui_.st())] + row, width, ui_.st())) out.append([TuiSpan(grr_clip(name, width - 4).center(width), ui_.st("ink", "bg", bold=True))]) for line in grr_wrap(desc, width - 8)[:5]: out.append([TuiSpan(line.center(width), ui_.st("muted", "bg"))]) return out return [GrrSpacer(1), Hero()] class GrrTemplatePartView(GrrView): fallback = "/" def open(self): self.standard(self.g.t("profile." + self.params["part"]), back="/template/" + self.params["id"]) self.get("/api/templates/" + self.params["id"], self.loaded) def loaded(self, tpl): g, t = self.g, self.g.t snap, part = tpl["snapshot"], self.params["part"] show = lambda title, text: (lambda: g.dialog(title=title, text=text, w=72)) if part == "instructions": items = [GrrLabel(t("profile.instruction")), GrrTextCard(snap.get("instructions") or t("template.none"))] elif part == "memories": items = grr_group([GrrRowItem(m, wrap=True, chevron=True, action=show(t("profile.memory"), m)) for m in snap.get("memories", [])]) or [GrrEmptyItem("inbox", t("template.none"))] else: items = grr_group([GrrRowItem(s["name"], sub=s.get("description", ""), chevron=True, action=show(s["name"], s.get("content", ""))) for s in snap.get("skills", [])]) or [GrrEmptyItem("skills", t("template.none"))] self.screen.items(items) class GrrPublicTemplateView(GrrView): fallback = "/" def open(self): self.standard("grr", back=False) self.get("/api/t/" + urllib.parse.quote(self.params["slug"]), self.loaded, lambda err: self.fail_page(err)) def loaded(self, tpl): g, t = self.g, self.g.t snap = tpl["snapshot"] items = grr_hero_items(self.ui, tpl["name"], tpl.get("description", ""), snap) + [GrrLabel(t("template.details"))] items += grr_group([GrrRowItem(t("profile.instructions"), sub=(snap.get("instructions") or "")[:160]), GrrRowItem(t("profile.memories"), value=str(len(snap.get("memories", [])))), GrrRowItem(t("profile.skills"), value=str(len(snap.get("skills", [])))), GrrRowItem(t("profile.routines"), value=str(len(snap.get("routines") or [])))]) items += [GrrSpacer(1), GrrButtonItem(t("template.add"), lambda: self.call("POST", "/api/templates/import", {"slug": tpl["slug"]}, lambda bot: g.store.refresh(lambda _e: g.router.go("/chat/" + bot["chat"]["id"]))))] self.screen.items(items) GRR_TEXT_MIMES = ("text/", "application/json", "application/xml", "application/javascript", "application/x-sh", "application/yaml", "application/toml") class GrrShellExtras: """Helpers every view reaches through the shell: files, links, the clipboard (OSC 52).""" @staticmethod def copy_text(shell, text): data = base64.b64encode(text.encode("utf-8")).decode("ascii") try: os.write(shell.app._stdout_fd if shell.app._stdout_fd is not None else sys.stdout.fileno(), ("\x1b]52;c;%s\x07" % data).encode("ascii")) except OSError: pass shell.toast(shell.t("chat.copied")) @staticmethod def open_url(shell, url): opener = shutil.which("xdg-open") or shutil.which("open") if opener and re.match(r"^https?://", url, re.I): try: subprocess.Popen([opener, url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) return except OSError: pass shell.dialog(title=shell.t("ui.share"), text=url, w=76) @staticmethod def open_file(shell, f): view = GrrFileViewer(shell, f) view.open() class GrrFileViewer: """The file viewer: text in a scrolling dialog with line numbers, other files saved to a temporary path and opened by the desktop (or named).""" def __init__(self, shell, f): self.g = shell self.f = f def open(self): g, f = self.g, self.f mime = f.get("mime") or "" external = bool(f.get("url")) path = f["url"] if external else "/api/files/" + str(f["id"]) def work(): if external: return None return g.client.fetch(path, limit=GRR_VIEWER_MAX_BYTES) def landed(res, err): if err: g.error(err) return self.show(res) g.app.offload(work, landed) def show(self, res): g, t, f = self.g, self.g.t, self.f name = f.get("name") or str(f.get("id")) data, cut, ctype = res if res else (b"", False, "") mime = (f.get("mime") or ctype or "").split(";")[0] is_text = mime.startswith(GRR_TEXT_MIMES) or (not mime and b"\x00" not in data[:2048]) actions = [{"label": t("ui.close"), "value": "close", "primary": True}, {"label": t("ui.download"), "value": "save"}] if is_text and data: text = data.decode("utf-8", "replace") numbered = "\n".join("%4d %s" % (i + 1, line) for i, line in enumerate(text.split("\n")[:5000])) body = TuiLog(h=max(6, g.app.rows - 12), style=g.ui.st("ink", "code-bg"), focusable=True) for line in numbered.split("\n"): body.append(line, g.ui.st("ink", "code-bg")) g.dialog(title=name, text=(t("files.cut", shown="2 MB", total="") if cut else ""), w=min(g.app.cols - 2, 110), body=body, actions=actions, done=lambda v, _t: self.save(data) if v == "save" else None) else: g.dialog(title=name, text="%s · %s" % (mime or "file", GrrFormat.bytes(f.get("size", len(data)))), actions=[{"label": t("ui.close"), "value": "close"}, {"label": t("viewer.save"), "value": "save", "primary": True}], done=lambda v, _t: self.save(data, True) if v == "save" else None) def save(self, data, open_after=False): g, f = self.g, self.f name = re.sub(r"[^A-Za-z0-9._-]", "_", f.get("name") or str(f.get("id")))[:80] or "file" def work(): body = data if not body and f.get("id"): body = g.client.fetch("/api/files/%s?download=1" % f["id"], limit=GRR_BODY_MAX)[0] path = os.path.join(tempfile.gettempdir(), "grr-" + name) with open(path, "wb") as fh: fh.write(body) return path def landed(path, err): if err: g.error(err) return g.toast("Saved " + path) opener = shutil.which("xdg-open") or shutil.which("open") if open_after and opener: try: subprocess.Popen([opener, path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) except OSError: pass g.app.offload(work, landed) GrrShell.copy_text = lambda self, text: GrrShellExtras.copy_text(self, text) GrrShell.open_url = lambda self, url: GrrShellExtras.open_url(self, url) GrrShell.open_file = lambda self, f: GrrShellExtras.open_file(self, f) # --------------------------------------------------------------------------- # Part F: chat. Message items, the composer, the working row, live process # cards and the remote terminal of a bot's computer. # --------------------------------------------------------------------------- def grr_bubble_styles(ui, side): bg = "bubble-user" if side == "out" else "bubble-bot" base = ui.st("ink", "bg") return {"base": base, "bubble": ui.st("ink", bg), "meta": ui.st("muted", bg), "tick": ui.st("muted", bg), "name": ui.st("muted", bg, bold=True), "b": ui.st("ink", bg, bold=True), "i": ui.st("soft", bg), "c": ui.st("soft", "code-bg"), "code": ui.st("accent", "code-bg"), "h": ui.st("accent", bg, bold=True), "bullet": ui.st("accent", bg, bold=True), "quote": ui.st("muted", bg), "bar": ui.st("accent", bg)} class GrrControlsItem(GrrItem): """An item with a row of small controls that Left and Right walk through. Subclasses fill `controls` as (label, callable) and draw.""" def __init__(self): super().__init__() self.controls = [] def nav(self, direction): if direction in ("left", "right") and len(self.controls) > 1: nxt = max(0, min(len(self.controls) - 1, self.stop + (1 if direction == "right" else -1))) if nxt != self.stop: self.stop = nxt self.touch() return True return False def activate(self): if self.controls: self.controls[min(self.stop, len(self.controls) - 1)][1]() class GrrMessageItem(GrrControlsItem): """A text message: your bubble on the right, a bot's on the left with its steps and an action bar (copy, good, bad, try again).""" selectable = True def __init__(self, view, m, last=False): super().__init__() self.view = view self.m = m self.last = last self.open_steps = False self.key = m["id"] self.rebuild_controls() def rebuild_controls(self): g, t = self.view.g, self.view.g.t m = self.m self.controls = [] if m.get("role") == "user": return steps = (m.get("meta") or {}).get("steps") or [] if steps: self.controls.append(("steps", self.toggle_steps)) self.controls.append(("copy", lambda: g.copy_text(grr_text(m.get("content"))))) self.controls.append(("good", lambda: self.view.rate(m, "up"))) self.controls.append(("bad", lambda: self.view.rate(m, "down"))) if self.last: self.controls.append(("again", self.view.regenerate)) self.stops = len(self.controls) def set_last(self, last): if last != self.last: self.last = last self.rebuild_controls() self.touch() def toggle_steps(self): self.open_steps = not self.open_steps self.touch() def build(self, ui, width, selected): v, m = self.view, self.m meta = m.get("meta") or {} g, t = v.g, v.g.t mar = grr_margin(width) inner = width - 2 * mar page = ui.st("ink", "bg") out = [] user = m.get("role") == "user" side = "out" if user else "in" styles = grr_bubble_styles(ui, side) sender = "" if not user and v.chat and v.chat.get("kind") == "group": bot = v.bot_of(m.get("sender")) sender = bot["name"] if bot else "" files = meta.get("files") or [] for fid in files if user else []: chip = " %s %s " % (ui.icon("image"), t("chat.attachment")) out.append(grr_line([TuiSpan(" " * (width - mar - len(chip)), page), TuiSpan(chip, ui.st("soft", "bubble-user"))], width, page)) self.hit(len(out) - 1, 0, width, (lambda fid=fid: g.open_file({"id": fid, "name": "", "mime": "image/*"}))) text = grr_text(m.get("content")) if user and meta.get("routine"): text = t("chat.routine") + "\n" + text if text: if m.get("kind") == "text" and not user and meta.get("error"): styles["bubble"] = ui.st("danger", "bubble-bot") rows = tui_chat_bubble_rows(text, inner, side, styles, sender=sender, markdown=not user, boxed=True) for row in rows: out.append(grr_line([TuiSpan(" " * mar, page)] + row + [TuiSpan(" " * mar, page)], width, page)) if not user: steps = meta.get("steps") or [] if steps: label = "%s %s" % ("▾" if self.open_steps else "▸", t("chat.steps", n=len(steps))) out.append(self.control_row(ui, width, [(label, "steps")], selected, len(out))) if self.open_steps: for s in steps: mark = ui.icon("check") if s.get("ok") else "!" line = " %s %s (%ss)" % (mark, grr_clip(s.get("summary", ""), inner - 12), s.get("secs", "")) out.append(grr_line([TuiSpan(" " * mar + line, ui.st("success" if s.get("ok") else "danger", "bg"))], width, page)) if meta.get("smile") if isinstance(meta.get("smile"), str) else None: out.append(grr_line([TuiSpan(" " * (mar + 1) + grr_clip(meta["smile"], inner - 2), ui.st("faint", "bg"))], width, page)) if (self.last or selected) and m.get("kind") == "text": glyph = {"copy": "copy", "good": "thumbup", "bad": "thumbdown", "again": "refresh"} bar = [(ui.icon(glyph[name]), name) for name, _fn in self.controls if name in glyph] out.append(self.control_row(ui, width, bar, selected, len(out))) return out def control_row(self, ui, width, parts, selected, row): mar = grr_margin(width) page = ui.st("ink", "bg") spans, x = [TuiSpan(" " * (mar + 1), page)], mar + 1 names = [n for n, _f in self.controls] for text, name in parts: idx = names.index(name) if name in names else -1 on = selected and idx == self.stop label = " %s " % text spans.append(TuiSpan(label, ui.st("ink" if on else "muted", "rule-strong" if on else "bg", bold=on))) spans.append(TuiSpan(" ", page)) fn = self.controls[idx][1] if idx >= 0 else None if fn: self.hit(row, x, x + len(label), fn) x += len(label) + 1 return grr_line(spans, width, page) def grr_card_rows(ui, width, lines, bg="card"): """Wrap card body lines (span lists) in the rounded card used by every message card.""" return [GrrCard.top(ui, width, bg)] + [GrrCard.line(ui, width, line, bg) for line in lines] + [GrrCard.bottom(ui, width, bg)] class GrrApprovalItem(GrrControlsItem): """A card that asks the owner to allow or deny what a bot wants to do.""" selectable = True def __init__(self, view, m): super().__init__() self.view = view self.m = m self.key = m["id"] self.setup() def setup(self): g, t, m = self.view.g, self.view.g.t, self.m meta = m.get("meta") or {} self.controls = [] if meta.get("state") == "pending": def decide(d): g.call("POST", "/api/messages/%s/decision" % m["id"], {"decision": d}) self.controls = [(t("chat.allow_once"), lambda: decide("allow")), (t("chat.deny"), lambda: decide("deny"))] if meta.get("tool") == "browser": self.controls.append((t("chat.always_allow"), lambda: decide("always"))) self.stops = max(1, len(self.controls)) self.selectable = bool(self.controls) def build(self, ui, width, selected): v, m = self.view, self.m t = v.g.t meta = m.get("meta") or {} bot = v.bot_of(m.get("sender")) or (v.bots()[0] if v.bots() else {"name": "Bot"}) cw = GrrCard.inner(width) lines = [[TuiSpan(" " + grr_clip(t("chat.needs_approval", name=bot["name"]), cw), ui.st("ink", "card", bold=True))]] for line in grr_wrap(grr_text(m.get("content")), cw - 1)[:8]: lines.append([TuiSpan(" " + line, ui.st("soft", "card"))]) lines.append([TuiSpan(" ", ui.st("ink", "card"))]) if meta.get("state") == "pending": spans, x = [TuiSpan(" ", ui.st("ink", "card"))], 2 + grr_margin(width) for pos, (label, fn) in enumerate(self.controls): on = selected and pos == self.stop text = " %s " % label primary = pos == 0 spans.append(TuiSpan(text, ui.st("accent-ink" if primary and not on else "ink", ("ink" if on and primary else ("rule-strong" if on else ("accent" if primary else "rule-strong"))), bold=True))) spans.append(TuiSpan(" ", ui.st("ink", "card"))) self.hit(len(lines) + 1, x, x + len(text), fn) x += len(text) + 1 lines.append(spans) else: state = "auto" if meta.get("auto") else grr_text(meta.get("state")) ok = meta.get("state") == "allowed" lines.append([TuiSpan(" %s %s" % (ui.icon("check" if ok else "close"), t("chat.state_" + state)), ui.st("success" if ok else "danger", "card"))]) return grr_card_rows(ui, width, lines) class GrrQuestionItem(GrrControlsItem): """A question card with lettered options; answering is a click or Enter.""" selectable = True def __init__(self, view, m): super().__init__() self.view = view self.m = m self.key = m["id"] meta = m.get("meta") or {} self.answer = meta.get("answer") self.options = list(meta.get("options") or []) t = view.g.t if self.answer is None: self.controls = [(o, (lambda o=o: view.answer(m, o))) for o in self.options] + [(t("chat.skip"), lambda: view.answer(m, "(skipped)"))] self.stops = max(1, len(self.controls)) self.selectable = self.answer is None def nav(self, direction): if direction in ("up", "down") and len(self.controls) > 1: nxt = self.stop + (1 if direction == "down" else -1) if 0 <= nxt < len(self.controls): self.stop = nxt self.touch() return True return False def build(self, ui, width, selected): t = self.view.g.t cw = GrrCard.inner(width) lines = [[TuiSpan(" " + grr_clip(grr_text(self.m.get("content")), cw), ui.st("ink", "card", bold=True))]] if self.answer is None: for pos, option in enumerate(self.options): on = selected and pos == self.stop bg = "rule-strong" if on else "sunken" row = [TuiSpan(" ", ui.st("ink", "card")), TuiSpan(" %s " % chr(65 + pos), ui.st("muted", bg)), TuiSpan(" " + grr_clip(option, cw - 6), ui.st("ink", bg))] lines.append(grr_line(row, cw, ui.st("ink", bg))) self.hit(len(lines), 0, width, self.controls[pos][1]) skip = selected and self.stop == len(self.options) lines.append([TuiSpan(" %s %s" % (ui.icon("close"), t("chat.skip")), ui.st("ink" if skip else "muted", "rule-strong" if skip else "card"))]) self.hit(len(lines), 0, width, self.controls[-1][1]) lines.append([TuiSpan(" " + t("chat.answer_below"), ui.st("faint", "card"))]) else: lines.append([TuiSpan(" ", ui.st("ink", "card")), TuiSpan(" %s %s " % (grr_clip(self.answer, cw - 6), ui.icon("check")), ui.st("ink", "sunken"))]) return grr_card_rows(ui, width, lines) class GrrFileMsgItem(GrrItem): """A file a bot shares: name, size, caption. Enter opens the viewer.""" selectable = True def __init__(self, view, m): super().__init__() self.view = view self.m = m self.key = m["id"] self.action = self.open def open(self): meta = self.m.get("meta") or {} self.view.g.open_file({"id": meta.get("file_id"), "name": meta.get("name"), "mime": meta.get("mime"), "kind": meta.get("file_kind"), "caption": grr_text(self.m.get("content"))}) def build(self, ui, width, selected): meta = self.m.get("meta") or {} bg = "rule-strong" if selected else "card" cw = GrrCard.inner(width) mime = meta.get("mime") or "" icon = "image" if mime.startswith("image/") else ("mic" if mime.startswith("audio/") else "file") lines = [[TuiSpan(" %s %s" % (ui.icon(icon), grr_clip(grr_text(meta.get("name")), cw - 4)), ui.st("ink", bg, bold=True))], [TuiSpan(" %s · %s" % (mime, GrrFormat.bytes(meta.get("size", 0))), ui.st("muted", bg))]] if self.m.get("content"): lines.append([TuiSpan(" " + grr_clip(self.m["content"], cw - 4), ui.st("soft", bg))]) return grr_card_rows(ui, width, [grr_line(l, cw, ui.st("ink", bg)) for l in lines], bg) class GrrFilesMsgItem(GrrControlsItem): """Several files in one message: a list the arrows walk through.""" selectable = True def __init__(self, view, m): super().__init__() self.view = view self.m = m self.key = m["id"] files = (m.get("meta") or {}).get("files") or [] self.controls = [(f.get("name", ""), (lambda f=f: view.g.open_file({"id": f.get("file_id"), "name": f.get("name"), "mime": f.get("mime"), "kind": f.get("file_kind")}))) for f in files] self.stops = max(1, len(files)) self.files = files def nav(self, direction): if direction in ("up", "down") and len(self.controls) > 1: nxt = self.stop + (1 if direction == "down" else -1) if 0 <= nxt < len(self.controls): self.stop = nxt self.touch() return True return False def build(self, ui, width, selected): cw = GrrCard.inner(width) lines = [] for pos, f in enumerate(self.files): on = selected and pos == self.stop bg = "rule-strong" if on else "card" row = [TuiSpan(" %s %s" % (ui.icon("image" if (f.get("mime") or "").startswith("image/") else "file"), grr_clip(f.get("name", ""), cw - 14)), ui.st("ink", bg, bold=on))] row = grr_line(row, cw - 10, ui.st("ink", bg)) + [TuiSpan(GrrFormat.bytes(f.get("size", 0)).rjust(10), ui.st("muted", bg))] lines.append(row) self.hit(pos + 1, 0, width, self.controls[pos][1]) if self.m.get("content"): lines.append([TuiSpan(" " + grr_clip(self.m["content"], cw - 2), ui.st("soft", "card"))]) return grr_card_rows(ui, width, lines) class GrrTemplateMsgItem(GrrControlsItem): selectable = True def __init__(self, view, m): super().__init__() self.view = view self.m = m self.key = m["id"] g, t = view.g, view.g.t meta = m.get("meta") or {} if meta.get("state") == "published": first = (t("template.copy_link"), lambda: g.copy_text("%s/t/%s" % (g.client.base, meta.get("slug")))) else: first = (t("template.publish"), lambda: g.call("POST", "/api/templates/%s/publish" % meta.get("template_id"), {})) self.controls = [first, (t("template.details"), lambda: g.router.go("/template/" + str(meta.get("template_id"))))] self.stops = 2 def build(self, ui, width, selected): v, m = self.view, self.m t = v.g.t meta = m.get("meta") or {} bot = v.bot_of(m.get("sender")) or (v.bots()[0] if v.bots() else None) live = meta.get("state") == "published" cw = GrrCard.inner(width) lines = [] for row in GrrAvatar.rows(ui, bot, "card"): lines.append([TuiSpan(" " * ((cw - tui_span_width(row)) // 2), ui.st("ink", "card"))] + row) badge = t("template.published") if live else t("template.unpublished") lines.append([TuiSpan(" " + grr_clip(grr_text(m.get("content")), cw - len(badge) - 4), ui.st("ink", "card", bold=True)), TuiSpan(" " + badge, ui.st("success" if live else "muted", "card"))]) for line in grr_wrap(grr_text(meta.get("description")), cw - 2)[:3]: lines.append([TuiSpan(" " + line, ui.st("muted", "card"))]) spans, x = [TuiSpan(" ", ui.st("ink", "card"))], 2 + grr_margin(width) for pos, (label, fn) in enumerate(self.controls): on = selected and pos == self.stop text = " %s " % label spans.append(TuiSpan(text, ui.st("accent-ink" if pos == 0 else "ink", "ink" if on and pos == 0 else ("rule-strong" if on or pos else "accent"), bold=True))) spans.append(TuiSpan(" ", ui.st("ink", "card"))) self.hit(len(lines) + 1, x, x + len(text), fn) x += len(text) + 1 lines.append(spans) return grr_card_rows(ui, width, lines) class GrrRemoteTerminal(TuiTerminal): """A terminal panel for a pty that lives on the server: bytes arrive over server-sent events and keys go back over POST. The widget, its VT screen and its key mapping are the stock TuiTerminal ones.""" def __init__(self, shell, stream_path, input_path, **kwargs): super().__init__(cmd=["true"], interactive=True, **kwargs) self.shell = shell self.stream_path = stream_path self.input_path = input_path self.stream = None self.sent = bytearray() self.flush_timer = None self.size_timer = None self.running = True self.connecting = True self.on_exit = None self.on_leave = None def spawn(self): return False def attach(self, start_body=None): self.stream = GrrStream(self.shell.client, self.stream_path, self.on_event, self.shell.app.run_on_loop, reconnect=False) self.stream.start() def detach(self): if self.stream is not None: self.stream.stop() self.stream = None def on_event(self, name, data): if name == "out": if data.get("skipped"): self.screen.feed(("\r\n\x1b[2m[%s]\x1b[0m\r\n" % self.shell.t("proc.skipped", n=data["skipped"])).encode("utf-8")) if data.get("d"): try: self.screen.feed(base64.b64decode(data["d"])) except (ValueError, TypeError): pass self.connecting = False elif name == "exit": self.running = False if callable(self.on_exit): self.on_exit() self.touch() def _apply_winsize(self): if self.size_timer is not None: self.shell.app.cancel_timer(self.size_timer) self.size_timer = self.shell.app.after(0.15, self.send_size) def send_size(self): self.size_timer = None body = {"cols": self.screen.cols, "rows": self.screen.rows} self.shell.call("POST", self.input_path, body, fail=lambda _e: None) def fds(self): return [] def poll_exit(self): return None def terminate(self): self.detach() def drain_pending(self): return None def on_key(self, event): if event.key in ("ctrl+]", "ctrl+}") and callable(self.on_leave): event.stop() self.on_leave() return if not self.running: return data = TuiTerminal.key_bytes(event) if data: event.stop() self.queue(data) def on_paste(self, event): event.stop() self.queue((event.text or "").replace("\n", "\r").encode("utf-8", "replace")) def send_text(self, text): self.queue(text.encode("utf-8", "replace")) def queue(self, data): self.sent += data if self.flush_timer is None: self.flush_timer = self.shell.app.after(0.025, self.flush) def flush(self): self.flush_timer = None chunk = bytes(self.sent[:4096]) del self.sent[:4096] if chunk: self.shell.call("POST", self.input_path, {"data": chunk.decode("utf-8", "replace")}, fail=lambda _e: None) if self.sent: self.flush_timer = self.shell.app.after(0.025, self.flush) class GrrProcessItem(GrrControlsItem): """The live card of a command a bot runs: the command, its state, the last rows of its screen, and ^C, Stop, Type and Expand. A running card follows the server's byte stream into its own VT screen; a finished one shows the stored transcript.""" selectable = True ROWS = 8 ROWS_TALL = 24 def __init__(self, view, m): super().__init__() self.view = view self.m = m self.key = m["id"] self.tall = False self.cols = 80 self.screen = TuiVtScreen(self.ROWS_TALL, 80) self.stream = None self.fed = False self.gen = 0 self.asked_gen = -1 self.asks = 0 self.typing = False self.rebuild_controls() self.sync() @property def running(self): return (self.m.get("meta") or {}).get("state") == "running" def pid(self): return (self.m.get("meta") or {}).get("pid") def rebuild_controls(self): g, t = self.view.g, self.view.g.t pid = self.pid() self.controls = [] if self.running: self.controls.append(("^C", lambda: g.call("POST", "/api/processes/%s/input" % pid, {"data": "\x03"}, fail=lambda _e: None))) self.controls.append((t("proc.stop"), lambda: g.call("POST", "/api/processes/%s/input" % pid, {"kill": True}))) self.controls.append(("Type", self.type_in)) self.controls.append((t("proc.expand"), self.toggle_tall)) self.stops = len(self.controls) def update(self, m): was = self.running self.m = m self.rebuild_controls() if was and not self.running: self.finish() self.touch() def sync(self): if self.running: self.follow() else: self.replay() def follow(self): if self.stream is not None: return v = self.view self.stream = GrrStream(v.g.client, "/api/processes/%s/stream?from=0" % self.pid(), self.on_event, v.g.app.run_on_loop, reconnect=False) self.stream.start() def on_event(self, name, data): if name == "out": if data.get("skipped"): self.screen.feed(("\r\n[%s]\r\n" % self.view.g.t("proc.skipped", n=data["skipped"])).encode("utf-8")) if data.get("d"): try: self.screen.feed(base64.b64decode(data["d"])) except (ValueError, TypeError): pass self.fed = True self.touch() self.gen += 1 gen = self.gen self.view.g.app.after(GRR_PROMPT_QUIET, lambda: self.check_prompt(gen)) elif name == "exit": self.stop_stream() PROMPT_TAIL = re.compile(r"(\?|:|[\[(][yYnN](es)?/[yYnN](o)?[\])]|password[^\n]*|passphrase[^\n]*)\s*$", re.I) def check_prompt(self, gen): """A command that went quiet on a prompt-like last row (sudo, ssh, a y/n) is waiting for the user: its terminal opens at once, so the prompt reaches the client instead of sitting unseen on the card.""" if gen != self.gen or gen == self.asked_gen or not self.running or self.typing or self.asks >= GRR_PROMPT_ASKS or self.view.g.view is not self.view: return screen = self.screen used = [r for r in range(screen.rows) if any(c is not None and c[0] != " " for c in screen.grid[r])] if not used: return row = "".join((c[0] if c is not None else " ") for c in screen.grid[used[-1]]).rstrip() if not self.PROMPT_TAIL.search(row): return self.asked_gen = gen self.asks += 1 self.type_in() def stop_stream(self): if self.stream is not None: self.stream.stop() self.stream = None def finish(self): self.stop_stream() if not self.fed: self.replay() def replay(self): self.screen = TuiVtScreen(self.ROWS_TALL, self.cols) self.screen.feed(((self.m.get("meta") or {}).get("transcript") or "").encode("utf-8", "replace")) def destroy(self): self.stop_stream() def toggle_tall(self): self.tall = not self.tall self.touch() def type_in(self): v = self.view g = v.g pid = self.pid() term = GrrRemoteTerminal(g, "/api/processes/%s/stream?from=0" % pid, "/api/processes/%s/input" % pid, w=min(g.app.cols - 6, 100), h=min(g.app.rows - 8, 28), border=True, style=g.ui.st("ink", "sunken"), border_style=g.ui.st("accent", "bg")) self.typing = True dlg = g.dialog(title=grr_text(self.m.get("content")), text="Esc leaves, Ctrl+C is sent to the command.", body=term, w=min(g.app.cols - 2, 104), actions=[{"label": g.t("ui.done"), "value": "done", "primary": True}], done=lambda _v, _t: (setattr(self, "typing", False), term.detach())) term.on_exit = lambda: g.close_dialog(dlg, "done") term.attach() g.app.set_focus(term) def build(self, ui, width, selected): t = self.view.g.t meta = self.m.get("meta") or {} state = meta.get("state", "running") cw = GrrCard.inner(width) exit_code = meta.get("exit") status = t("proc.state_" + state, code="?" if exit_code is None else exit_code) tone = "accent" if state == "running" else ("danger" if state == "exited" and exit_code else "muted") head = [TuiSpan(" %s " % ui.icon("terminal"), ui.st("accent", "card")), TuiSpan(grr_clip(grr_text(self.m.get("content")), cw - len(status) - 6), ui.st("ink", "card", bold=True))] head = grr_line(head, cw - len(status) - 1, ui.st("ink", "card")) + [TuiSpan(status + " ", ui.st(tone, "card"))] lines = [head] rows = self.ROWS_TALL if self.tall else self.ROWS screen = self.screen base = ui.st("ink", "sunken") used = [r for r in range(screen.rows) if any(c is not None and c[0] != " " for c in screen.grid[r])] last = used[-1] if used else -1 first = max(0, last - rows + 1) for r in range(first, first + rows): if r >= screen.rows: lines.append([TuiSpan(" " * cw, base)]) continue cells = [] for col in range(min(cw - 1, screen.cols)): cell = screen.grid[r][col] cells.append((cell[0] if cell else " ", TuiVtScreen.style_for(cell, base))) lines.append([TuiSpan(" ", ui.st("ink", "card"))] + grr_line(tui_runs(cells), cw - 1, base)) spans, x = [TuiSpan(" ", ui.st("ink", "card"))], 2 + grr_margin(width) for pos, (label, fn) in enumerate(self.controls): on = selected and pos == self.stop text = " %s " % label spans.append(TuiSpan(text, ui.st("danger" if label == t("proc.stop") else "ink", "rule-strong" if on else "sunken", bold=on))) spans.append(TuiSpan(" ", ui.st("ink", "card"))) self.hit(len(lines) + 1, x, x + len(text), fn) x += len(text) + 1 lines.append(spans) return grr_card_rows(ui, width, lines) class GrrComposerBox(TuiTextBox): """The chat input: PageUp and PageDown scroll the conversation when the message is a single line.""" def __init__(self, view, **kwargs): super().__init__(**kwargs) self.view = view def on_key(self, event): key = event.key or "" if key in ("pageup", "pagedown") and len(self.lines) <= 1: event.stop() self.view.log.scroll_by(-max(1, self.view.log._content_h() - 1) if key == "pageup" else max(1, self.view.log._content_h() - 1)) return if key in ("ctrl+up", "ctrl+down"): event.stop() self.view.log.scroll_by(-3 if key == "ctrl+up" else 3) return super().on_key(event) class GrrChatView(GrrView): """Bot chats, group chats and general chats (ask, imagine, build, private) with the live message stream, composer and side pane.""" fallback = "/" def open(self): g, ui, t = self.g, self.ui, self.g.t self.chat = None self.messages = [] self.items = {} self.attachments = [] self.mode = self.query.get("mode") or "ask" self.tier = g.settings.get("tier") or "auto" self.private = False self.has_more = False self.busy = False self.new_below = False self.prompts = {} self.spin = 0 self.pane = None self.root = TuiCols(w="1fr", h="100%", style=ui.st("ink", "bg")) self.col = TuiRows(w="1fr", h="100%", style=ui.st("ink", "bg")) self.bar = GrrBar(ui, style=ui.st("ink", "bg")) self.modebar = GrrBar(ui, style=ui.st("ink", "bg")) self.log = GrrPage(ui, stick=True, auto_select=False, style=ui.st("ink", "bg"), h="1fr") self.log.on_bottom = self.on_bottom self.working = TuiRich([], h=1, style=ui.st("ink", "bg")) self.attach_row = TuiRich([], h=1, style=ui.st("ink", "bg")) self.attach_row.visible = False self.box = GrrComposerBox(self, h=1, border=True, rounded=True, padding=(0, 1), w="1fr", style=ui.st("ink", "card"), border_style=ui.st("rule-strong", "bg"), focus_border_style=ui.st("accent", "bg"), send_on_enter=True, on_send=lambda _v: self.on_send(), max_chars=ui.cap("MESSAGE_MAX_CHARS", 20000), mentions=lambda prefix: [(("@%s" % b["name"].replace(" ", "")), b["name"].replace(" ", "")) for b in g.store.bots if b["name"].replace(" ", "").lower().startswith((prefix or "").lower())]) self.plus = TuiButton(ui.icon("plus"), brackets=False, border=False, w=3, h=1, style=ui.st("ink", "card", bold=True), on_press=lambda _e: self.attach_menu()) self.send_btn = TuiButton("", brackets=False, border=False, w=5, h=1, style=ui.st("accent-ink", "accent", bold=True), on_press=lambda _e: self.send_action()) self.composer = TuiCols(h=3, gap=1, padding=(1, 1, 0, 1), style=ui.st("ink", "bg")) self.composer.add(self.plus, self.box, self.send_btn) self.col.add(self.bar, self.modebar, self.log, self.working, self.attach_row, self.composer) self.root.add(self.col) self.box.on_change = lambda e: self.fit_box() self.modebar.visible = False chat_id = self.params["id"] self.sub(g.events, "message.new", self.on_new) self.sub(g.events, "message.update", self.on_update) self.sub(g.events, "status", self.on_status) self.sub(g.events, "chat.update", self.on_chat_update) self.sub(g.events, "link", lambda up: self.catch_up() if up and self.chat else None) self.sub(g.store, "changed", lambda _d: self.refresh_head()) self.every(0.15, self.tick) self.screen = None if chat_id == "new": self.refresh_head() self.paint_all() self.show(self.root, self.box) self.fit_box() self.set_send_mode() else: self.show(self.root, self.box) self.batch([("GET", "/api/chats/" + chat_id, None), ("GET", "/api/chats/%s/messages" % chat_id, None)], self.loaded) # ---- data def loaded(self, res): chat, page = res g = self.g self.chat = chat self.messages = page.get("messages", []) self.has_more = page.get("has_more", False) self.mode = chat.get("mode") or "ask" self.tier = chat.get("tier") or "auto" self.pane = GrrPane(self) if self.chat_is_bot() else None self.refresh_head() self.paint_all() self.fit_box() self.set_send_mode() if chat.get("unread"): g.app.after(1.2, lambda: None if self.closed else g.call("POST", "/api/chats/%s/read" % chat["id"], {}, fail=lambda _e: None)) draft = g.drafts.get(chat["id"]) if draft: self.box.lines = draft.split("\n") self.box.cy = len(self.box.lines) - 1 self.box.cx = len(self.box.lines[-1]) self.log.scroll_to_end() self.restore_pane() def destroy(self): g = self.g for dlg in list(self.prompts.values()): g.close_dialog(dlg, None, quiet=True) self.prompts = {} if self.chat is not None: text = self.box.value.strip() if text: if len(g.drafts) >= GRR_DRAFT_KEEP: g.drafts.pop(next(iter(g.drafts))) g.drafts[self.chat["id"]] = self.box.value else: g.drafts.pop(self.chat["id"], None) if self.chat.get("kind") == "private": g.client_call_quiet("DELETE", "/api/chats/" + self.chat["id"]) else: g.client_call_quiet("POST", "/api/chats/%s/read" % self.chat["id"], {}) for item in self.items.values(): if hasattr(item, "destroy"): item.destroy() if self.pane is not None: self.pane.destroy() super().destroy() def chat_is_bot(self): return bool(self.chat) and self.chat.get("kind") in ("bot", "group") and bool(self.bots()) def is_general(self): return not self.chat or self.chat.get("kind") in ("general", "private") def is_private(self): return self.private or (self.chat and self.chat.get("kind") == "private") def bots(self): if not self.chat: return [] return [b for b in (self.g.store.bot(i) for i in self.chat.get("bot_ids", [])) if b] def bot_of(self, bot_id): return self.g.store.bot(bot_id) if bot_id else None def placeholder(self): t = self.g.t if not self.is_general(): b = self.bots() return t("chat.ask_bot", name=(b[0]["name"] if b else "") if self.chat.get("kind") == "bot" else self.chat.get("title", "")) if self.is_private(): return t("chat.private_placeholder") return t("chat.mode_%s_ph" % self.mode) # ---- header and mode bar def refresh_head(self): g, ui, t = self.g, self.ui, self.g.t c = self.chat parts = [{"text": " %s " % ui.icon("back"), "fn": lambda: g.router.back("/"), "bg": "card", "bold": True}] if c and c.get("kind") == "bot": b = (self.bots() or [None])[0] chip = (GrrAvatar.chip(ui, b) if b else []) + [TuiSpan(" " + (b["name"] if b else c.get("title", "")) + " ", ui.st("ink", "card", bold=True))] parts.append({"text": "", "fill": True}) parts.append({"spans": [TuiSpan(" ", ui.st("ink", "card"))] + chip, "fn": (lambda: g.router.go("/bot/" + b["id"])) if b else None, "fill": False}) parts.append({"text": "", "fill": True}) elif c and c.get("kind") == "group": chip = [TuiSpan(" ", ui.st("ink", "card"))] for b in self.bots()[:3]: chip += [TuiSpan("●", TuiStyle(fg=b.get("color") or ui.palette.c("accent"), bg=ui.palette.c("card")))] chip.append(TuiSpan(" %s " % c.get("title", ""), ui.st("ink", "card", bold=True))) parts.append({"text": "", "fill": True}) parts.append({"spans": chip, "fn": self.members}) parts.append({"text": "", "fill": True}) else: parts.append({"text": (c.get("title") if c and c.get("title") else (t("chat.private") if self.is_private() else "")), "fill": True, "align": "center", "bold": True}) if self.is_general() and not c: parts.append({"text": " %s " % ui.icon("incognito"), "fn": self.toggle_private, "bg": "accent" if self.private else "card", "fg": "accent-ink" if self.private else "ink"}) if self.pane is not None: parts.append({"text": " %s " % ui.icon("panel"), "fn": self.pane.toggle, "bg": "accent" if self.pane.is_open else "card", "fg": "accent-ink" if self.pane.is_open else "ink"}) if c: parts.append({"text": " %s " % ui.icon("more"), "fn": self.menu, "bg": "card"}) self.bar.set(parts) self.refresh_modebar() self.box.placeholder = self.placeholder() def refresh_modebar(self): g, ui, t = self.g, self.ui, self.g.t general = self.is_general() self.modebar.visible = general if not general: return modes = g.spec.get("modes") or ["ask", "imagine", "build"] tiers = g.spec.get("tiers") or ["auto", "fast", "expert"] width = max(10, self.col.aw or g.app.cols) need = sum(len(t("chat.mode_" + m)) + 3 for m in modes) + len(t("tier." + self.tier)) + 8 parts = [] if need > width: parts.append({"text": " %s %s " % (t("chat.mode_" + self.mode), ui.icon("down")), "fn": lambda: g.menu([{"label": t("chat.mode_" + m), "checked": m == self.mode, "action": (lambda m=m: self.set_mode(m))} for m in modes], align_left=True), "bg": "card", "bold": True}) else: for m in modes: on = m == self.mode parts.append({"text": " %s " % t("chat.mode_" + m), "fn": (lambda m=m: self.set_mode(m)), "bg": "accent" if on else "bg", "fg": "accent-ink" if on else "muted", "bold": on}) parts.append({"text": "", "fill": True}) parts.append({"text": " %s %s %s " % (ui.icon("sparkle"), t("tier." + self.tier), ui.icon("down")), "fn": lambda: g.menu([{"label": t("tier." + x), "checked": x == self.tier, "action": (lambda x=x: self.set_tier(x))} for x in tiers]), "bg": "card"}) self.modebar.set(parts) def set_mode(self, mode): self.mode = mode self.refresh_head() self.paint_all() if self.chat: self.call("PATCH", "/api/chats/" + self.chat["id"], {"mode": mode}) def set_tier(self, tier): self.tier = tier self.refresh_modebar() if self.chat: self.call("PATCH", "/api/chats/" + self.chat["id"], {"tier": tier}) def toggle_private(self): self.private = not self.private self.refresh_head() self.paint_all() def members(self): g, t = self.g, self.g.t rows = [GrrRowItem(b["name"], lead=(lambda ui_, bg, b=b: GrrAvatar.chip(ui_, b, bg) + [TuiSpan(" ", ui_.st("ink", bg))]), chevron=True, action=(lambda b=b: g.router.go("/bot/" + b["id"]))) for b in self.bots()] g.pick(self.chat.get("title", ""), rows, text=t("chat.members_hint")) def menu(self): g, t, c = self.g, self.g.t, self.chat items = [] if c.get("kind") == "bot": b = (self.bots() or [None])[0] items += [{"label": t("chat.view_profile"), "icon": "user", "action": lambda: b and g.router.go("/bot/" + b["id"])}, {"label": t("profile.share_template"), "icon": "share", "action": self.share_template}, "-", {"label": t("chat.clear"), "icon": "refresh", "action": self.clear_chat}, {"label": t("profile.delete_bot"), "icon": "trash", "danger": True, "action": self.delete_bot}] else: if c.get("kind") == "group": items.append({"label": t("chat.members"), "icon": "group", "action": self.members}) items += [{"label": t("chat.unpin") if c.get("pinned") else t("chat.pin"), "icon": "pin", "action": lambda: self.call("PATCH", "/api/chats/" + c["id"], {"pinned": not c.get("pinned")})}, {"label": t("chat.rename"), "icon": "edit", "action": self.rename}, "-", {"label": t("ui.delete"), "icon": "trash", "danger": True, "action": self.delete_chat}] g.menu(items) def rename(self): g, t = self.g, self.g.t g.edit(t("chat.rename"), self.chat.get("title", ""), lambda v: self.call("PATCH", "/api/chats/" + self.chat["id"], {"title": v}, lambda c: (self.chat.update(c), self.refresh_head()))) def share_template(self): g, t = self.g, self.g.t g.confirm(t("profile.share_title"), t("profile.share_text"), t("ui.continue"), lambda: self.send_text(GRR_TEMPLATE_ASK)) def clear_chat(self): g, t = self.g, self.g.t def go(): self.call("DELETE", "/api/chats/" + self.chat["id"], None, lambda _r: (setattr(self, "messages", []), self.paint_all())) g.confirm(t("chat.clear"), t("chat.clear_text"), t("chat.clear"), go, True) def delete_bot(self): g, t = self.g, self.g.t b = (self.bots() or [None])[0] if b: g.confirm(t("profile.delete_bot"), t("profile.delete_text", name=b["name"]), t("ui.delete"), lambda: self.call("DELETE", "/api/bots/" + b["id"], None, lambda _r: g.store.refresh(lambda _e: g.router.go("/bots", True))), True) def delete_chat(self): g, t = self.g, self.g.t def go(): cid = self.chat["id"] self.chat["kind"] = "x" self.call("DELETE", "/api/chats/" + cid, None, lambda _r: g.router.go("/", True)) g.confirm(t("chat.delete_title"), t("chat.delete_text"), t("ui.delete"), go, True) # ---- messages def make_item(self, m): kind = m.get("kind") if m.get("role") == "user": return GrrMessageItem(self, m) if kind == "approval": return GrrApprovalItem(self, m) if kind == "question": return GrrQuestionItem(self, m) if kind == "process": return GrrProcessItem(self, m) if kind == "file": return GrrFileMsgItem(self, m) if kind == "files": return GrrFilesMsgItem(self, m) if kind == "template": return GrrTemplateMsgItem(self, m) return GrrMessageItem(self, m) def newest_boundary(self): n = (self.chat or {}).get("unread") or 0 if not n: return -1 count = 0 for i in range(len(self.messages) - 1, -1, -1): if self.messages[i].get("role") != "user": count += 1 if count == n: return i return -1 def paint_all(self): g, ui, t = self.g, self.ui, self.g.t for item in self.items.values(): if hasattr(item, "destroy"): item.destroy() self.items = {} entries = [] if not self.messages: entries.append(self.empty_item()) else: if self.has_more: entries.append(GrrButtonItem(t("chat.older"), self.older, "ghost", inline=True)) prev, new_at = None, self.newest_boundary() for i, m in enumerate(self.messages): if i == new_at: entries.append(GrrDividerItem(t("chat.new"), accent=True)) if prev is None or m.get("ts", 0) - prev.get("ts", 0) > 1800: entries.append(GrrDividerItem(ui.fmt.stamp(m.get("ts", 0)))) item = self.make_item(m) self.items[m["id"]] = item entries.append(item) prev = m self.mark_last() self.entries = entries self.log.set_items(entries) for m in self.messages: self.prompt_approval(m) def prompt_approval(self, m): """A pending approval is asked at once as a dialog on the loop, never left as a card to find; the answer is the card's own decision call. Esc leaves the card as it was, a resolved card closes the dialog.""" meta = m.get("meta") or {} mid = m.get("id") if m.get("kind") != "approval" or meta.get("state") != "pending" or mid in self.prompts or len(self.prompts) >= GRR_PROMPT_MAX: return g, t = self.g, self.g.t bot = self.bot_of(m.get("sender")) or (self.bots()[0] if self.bots() else {"name": "Bot"}) def done(value, _texts): self.prompts.pop(mid, None) if value: g.call("POST", "/api/messages/%s/decision" % mid, {"decision": value}) actions = [{"label": t("chat.deny"), "value": "deny"}] if meta.get("tool") == "browser": actions.append({"label": t("chat.always_allow"), "value": "always"}) actions.append({"label": t("chat.allow_once"), "value": "allow", "primary": True}) self.prompts[mid] = g.dialog(title=t("chat.needs_approval", name=bot["name"]), text="\n".join(grr_wrap(grr_text(m.get("content")), 60)[:12]), actions=actions, done=done) self.log.scroll_to_end() def empty_item(self): g, ui, t = self.g, self.ui, self.g.t if self.is_general(): priv = self.is_private() icon = "incognito" if priv else {"imagine": "image", "build": "terminal"}.get(self.mode, "sparkle") title = t("chat.private") if priv else t("chat.empty_" + self.mode) sub = t("chat.private_text") if priv else t("chat.empty_%s_sub" % self.mode) return GrrEmptyItem(icon, title, sub) bot = (self.bots() or [None])[0] class Intro(GrrItem): def build(inner, ui_, width, selected): out = [[TuiSpan(" " * width, ui_.st())] for _ in range(2)] if bot: for row in GrrAvatar.rows(ui_, bot, "bg", hero=True): out.append(grr_line([TuiSpan(" " * ((width - tui_span_width(row)) // 2), ui_.st())] + row, width, ui_.st())) out.append([TuiSpan(grr_clip(self.chat.get("title", ""), width - 4).center(width), ui_.st("ink", "bg", bold=True))]) for line in grr_wrap(bot["description"] if bot else t("chat.group_empty"), width - 8)[:4]: out.append([TuiSpan(line.center(width), ui_.st("muted", "bg"))]) return out return Intro() def mark_last(self): last_id = None for m in reversed(self.messages): if m.get("role") != "user" and m.get("kind") == "text": last_id = m["id"] break if m.get("role") == "user": break for mid, item in self.items.items(): if isinstance(item, GrrMessageItem): item.set_last(mid == last_id) def on_new(self, m): if not self.chat or m.get("chat_id") != self.chat["id"] or m["id"] in self.items: return if not self.messages: self.messages.append(m) self.paint_all() return prev = self.messages[-1] self.messages.append(m) if len(self.messages) > GRR_MESSAGE_KEEP: drop = self.messages.pop(0) gone = self.items.pop(drop["id"], None) if gone is not None and hasattr(gone, "destroy"): gone.destroy() self.has_more = True if m.get("ts", 0) - prev.get("ts", 0) > 1800: self.log.append(GrrDividerItem(self.ui.fmt.stamp(m.get("ts", 0)))) item = self.make_item(m) self.items[m["id"]] = item self.log.append(item) self.mark_last() self.prompt_approval(m) if m.get("role") == "user" or self.log._follow: self.log.scroll_to_end() else: self.new_below = True if m.get("role") != "user": self.g.client_call_quiet("POST", "/api/chats/%s/read" % self.chat["id"], {}) def on_update(self, m): if not self.chat or m.get("chat_id") != self.chat["id"]: return for i, old in enumerate(self.messages): if old["id"] == m["id"]: self.messages[i] = m break else: return item = self.items.get(m["id"]) dlg = self.prompts.pop(m["id"], None) if dlg is not None and (m.get("meta") or {}).get("state") != "pending": self.g.close_dialog(dlg, None, quiet=True) elif dlg is not None: self.prompts[m["id"]] = dlg if isinstance(item, GrrProcessItem): item.update(m) return if item is None: return fresh = self.make_item(m) self.items[m["id"]] = fresh entries = [fresh if e is item else e for e in self.log.items] self.log.set_items(entries) self.mark_last() def on_status(self, s): if self.chat and s.get("chat_id") == self.chat["id"]: self.set_send_mode() def on_chat_update(self, d): if self.chat and d and d.get("id") == self.chat["id"]: self.get("/api/chats/" + self.chat["id"], lambda c: (self.chat.update(c), self.set_send_mode(), self.refresh_head())) def on_bottom(self, at_bottom): if at_bottom: self.new_below = False def catch_up(self): def landed(page): have = set(self.items) for m in page.get("messages", []): if m["id"] not in have: self.on_new(m) self.get("/api/chats/%s/messages" % self.chat["id"], landed) def older(self): if not self.messages: return def landed(page): self.messages = page.get("messages", []) + self.messages self.has_more = page.get("has_more", False) self.paint_all() self.get("/api/chats/%s/messages?before=%s" % (self.chat["id"], self.messages[0]["id"]), landed) # ---- working row def active(self): return self.g.store.active_in(self.chat["id"]) if self.chat else [] def is_busy(self): return bool(self.chat and (self.chat.get("busy") or self.active())) FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" def tick(self): self.spin += 1 self.paint_working() def paint_working(self): ui, t = self.ui, self.g.t act = self.active() if not act and not (self.chat and self.chat.get("busy")): hint = " ⌄ %s (End)" % t("chat.to_bottom") if self.new_below else "" self.working.set([TuiSpan(hint, ui.st("accent", "bg"))] if hint else []) return s = act[0] if act else None bot = self.bot_of(s.get("bot_id")) if s else (self.bots() or [None])[0] name = bot["name"] if bot else "grr" frame = self.FRAMES[self.spin % len(self.FRAMES)] label = (s or {}).get("label") or "" text = t("chat.is_working", name=name) + (" · " + label[:60] if label else "") if not label: text = "%s %s%s" % (t("chat.is_working", name=name), "." * (1 + self.spin // 3 % 3), "") self.working.set([TuiSpan(" %s " % frame, ui.st("accent", "bg", bold=True))] + (GrrAvatar.chip(ui, bot) if bot else []) + [TuiSpan(" " + text, ui.st("muted", "bg"))]) # ---- composer def fit_box(self): rows = max(1, min(GRR_COMPOSER_MAX_ROWS, len(self.box.lines))) if self.box.h != rows: self.box.h = rows self.plus.h = rows self.send_btn.h = rows self.composer.h = rows + 3 self.set_send_mode() def set_send_mode(self): busy = self.is_busy() has = bool(self.box.value.strip()) or bool(self.attachments) self.send_btn.label = " %s " % (self.ui.icon("stop") if busy else (self.ui.icon("send") if has else self.ui.icon("wave"))) self.send_mode = "stop" if busy else ("send" if has else "voice") self.paint_attach() def send_action(self): g, t = self.g, self.g.t if self.send_mode == "stop": if self.chat: self.call("POST", "/api/chats/%s/stop" % self.chat["id"], {}) elif self.send_mode == "voice": g.toast("Voice needs a microphone: use the web app for spoken conversations.") else: self.on_send() def on_send(self): if self.is_busy() and not self.box.value.strip(): return self.send_text(self.box.value.strip()) def paint_attach(self): ui = self.ui self.attach_row.visible = bool(self.attachments) if self.attachments: spans = [TuiSpan(" ", ui.st())] for a in self.attachments: spans.append(TuiSpan(" %s %s%s " % (ui.icon("file"), a["name"], "…" if a.get("uploading") else ""), ui.st("soft", "card"))) spans.append(TuiSpan(" ", ui.st())) self.attach_row.set(spans) def attach_menu(self): g, t = self.g, self.g.t g.menu([{"icon": "image", "label": t("chat.gallery"), "action": self.pick_file}, {"icon": "file", "label": t("chat.files"), "action": self.pick_file}, "-", {"icon": "skills", "label": t("chat.skills"), "action": self.skills_sheet}, {"icon": "plug", "label": t("chat.connectors"), "action": lambda: g.router.go("/settings/connectors")}], align_left=True) def pick_file(self): g, t = self.g, self.g.t g.edit(t("chat.files"), "", self.add_file, text="Path of a file on this machine") def add_file(self, path): g, t = self.g, self.g.t path = os.path.expanduser(path.strip()) if not path: return try: if os.path.getsize(path) > g.ui.cap("UPLOAD_MAX_BYTES", 20971520): g.toast(t("err.too_big", max=GrrFormat.bytes(g.ui.cap("UPLOAD_MAX_BYTES", 20971520))), "bad") return with open(path, "rb") as fh: data = fh.read() except OSError as exc: g.toast(str(exc), "bad") return chip = {"name": os.path.basename(path), "uploading": True} self.attachments.append(chip) self.set_send_mode() chat = self.chat def landed(item, err): if err or self.closed: if chip in self.attachments: self.attachments.remove(chip) if err: g.error(err) else: chip.update({"id": item["id"], "uploading": False}) self.set_send_mode() g.app.offload(lambda: g.client.upload(chip["name"], data, chat["id"] if chat else "", chat["bot_ids"][0] if chat and chat.get("kind") == "bot" else ""), landed) def skills_sheet(self): g, t = self.g, self.g.t bots = g.store.bots if not bots: return def landed(res): rows = [] for b, r in zip(bots, res): for s in r.get("skills", []): rows.append(GrrRowItem(s["name"], sub=s.get("description", ""), lead=(lambda ui_, bg, b=b: GrrAvatar.chip(ui_, b, bg) + [TuiSpan(" ", ui_.st("ink", bg))]), action=(lambda b=b, s=s: self.insert_skill(b, s)))) if rows: g.pick(t("chat.skills"), rows) else: g.dialog(title=t("chat.no_skills"), text=t("chat.no_skills_sub")) self.batch([("GET", "/api/bots/%s/skills" % b["id"], None) for b in bots], landed) def insert_skill(self, bot, skill): text = (self.box.value + " @" + bot["name"].replace(" ", "") + " " + self.g.t("chat.use_skill", name=skill["name"])).strip() self.box.lines = text.split("\n") self.box.cy = len(self.box.lines) - 1 self.box.cx = len(self.box.lines[-1]) self.box.touch() self.g.app.set_focus(self.box) def ensure_chat(self, then): if self.chat: then(self.chat) return if getattr(self, "creating", False): return self.creating = True body = {"kind": "private" if self.private else "general", "mode": self.mode, "tier": self.tier} if self.query.get("project"): body["project_id"] = self.query["project"] def landed(chat): self.creating = False self.chat = chat self.params["id"] = chat["id"] self.g.router.rewrite("/chat/" + chat["id"]) self.g.store.soon() self.refresh_head() then(chat) self.call("POST", "/api/chats", body, landed, lambda e: (setattr(self, "creating", False), self.g.error(e))) def send_text(self, text): g, t = self.g, self.g.t if not text and not self.attachments: return if any(a.get("uploading") for a in self.attachments): g.toast(t("chat.uploading")) return files = [a["id"] for a in self.attachments if a.get("id")] kept = self.attachments def go(chat): body = {"text": text, "files": files} if self.is_general(): body.update({"mode": self.mode, "tier": self.tier}) self.box.lines = [""] self.box.cx = self.box.cy = 0 self.box.touch() self.attachments = [] def landed(res): self.on_new(dict({"chat_id": chat["id"]}, **res["message"])) self.chat["busy"] = True self.set_send_mode() self.log.scroll_to_end() def failed(err): self.attachments = kept if isinstance(err, GrrError) and err.code in ("busy", "chat_busy"): g.toast(err.text()) else: g.error(err) if not self.box.value: self.box.lines = text.split("\n") self.box.cy = len(self.box.lines) - 1 self.box.cx = len(self.box.lines[-1]) self.set_send_mode() self.call("POST", "/api/chats/%s/messages" % chat["id"], body, landed, failed) self.ensure_chat(go) def answer(self, m, text): self.call("POST", "/api/messages/%s/answer" % m["id"], {"text": text}) def rate(self, m, kind): g, t = self.g, self.g.t body = {"text": ("[good] " if kind == "up" else "[bad] ") + grr_text(m.get("content"))[:500], "bot_id": m.get("sender")} self.call("POST", "/api/feedback", body, lambda _r: g.toast(t("chat.thanks"))) def regenerate(self): for m in reversed(self.messages): if m.get("role") == "user" and m.get("content"): self.send_text(m["content"]) return def hotkey(self, key): if key == "ctrl+p" and self.pane is not None: self.pane.toggle() return True return False # ---- pane def restore_pane(self): if self.pane is not None: self.pane.restore() def on_escape(self): if self.pane is not None and self.pane.is_open and self.pane.layer_mode(): self.pane.hide() return True g = self.g g.router.back("/") return True def restyled(self): for node in self.root.walk(): if hasattr(node, "ui"): node.ui = self.g.ui class GrrComputerView(GrrView): """Take control of a bot's computer: its shell, live over the stream. Ctrl+] hands control back, like `computer take` does in the REPL.""" def open(self): g, ui, t = self.g, self.ui, self.g.t self.started = False self.root = TuiRows(w="1fr", h="100%", style=ui.st("ink", "bg")) self.bar = GrrBar(ui, style=ui.st("ink", "bg")) self.root.add(self.bar) self.term = None self.show(self.root) self.get("/api/bots/" + self.params["id"], self.loaded) def loaded(self, bot): g, ui, t = self.g, self.ui, self.g.t self.bot = bot path = "/api/bots/%s/computer" % bot["id"] self.term = GrrRemoteTerminal(g, path + "/stream?from=0", path + "/input", w="100%", h="1fr", border=False, style=ui.st("ink", "sunken")) self.root.add(self.term) self.term.on_leave = self.done self.foot = GrrBar(ui, style=ui.st("ink", "bg")) self.root.add(self.foot) self.foot.set([{"text": " Ctrl+] %s " % t("computer.done"), "fn": self.done, "bg": "card"}, {"text": "", "fill": True}, {"text": " ^C ", "fn": lambda: self.term.queue(b"\x03"), "bg": "card"}, {"text": " ^L ", "fn": lambda: self.term.queue(b"\x0c"), "bg": "card"}, {"text": " ^D ", "fn": lambda: self.term.queue(b"\x04"), "bg": "card"}]) self.bar.set([{"text": " %s " % ui.icon("back"), "fn": self.done, "bg": "card", "bold": True}, {"spans": [TuiSpan(" ", ui.st("ink", "card"))] + GrrAvatar.chip(ui, bot) + [TuiSpan(" %s " % bot["name"], ui.st("ink", "card", bold=True))]}, {"text": "", "fill": True}, {"text": " %s " % ui.icon("help"), "fn": self.help, "bg": "card"}]) body = {"start": True, "cols": 80, "rows": 24} self.call("POST", path + "/input", body, lambda _r: self.attached(), lambda e: g.error(e)) g.app.set_focus(self.term) def attached(self): self.started = True self.term.attach() self.term.send_size() def on_key_root(self, event): return False def help(self): g, t = self.g, self.g.t lines = [t("computer.help_scroll_t"), "", t("computer.help_keyboard_t"), "", t("computer.help_done_t"), "", "Ctrl+] " + t("computer.done")] g.dialog(title=t("computer.help_title"), text="\n".join(lines), w=70) def done(self): g = self.g if self.started: g.client_call_quiet("POST", "/api/bots/%s/computer/input" % self.bot["id"], {"release": True}) if self.term is not None: self.term.detach() g.router.back("/bot/%s?tab=computer" % self.params["id"]) def destroy(self): if self.term is not None: self.term.detach() if self.started: self.g.client_call_quiet("POST", "/api/bots/%s/computer/input" % self.bot["id"], {"release": True}) super().destroy() def on_escape(self): return True class GrrScreenView(GrrView): """The graphical screen of a bot is a VNC session; a terminal cannot draw it. This view starts and stops it and says where to see it.""" def open(self): g, t = self.g, self.g.t self.standard(t("screen.title"), back="/bot/%s?tab=computer" % self.params["id"]) self.sub(g.events, "preview.ready", lambda _d: None) self.reload() def reload(self): self.get("/api/bots/%s/desktop" % self.params["id"], self.paint) def paint(self, d): g, t = self.g, self.g.t bid = self.params["id"] url = "%s/#/bot/%s/screen" % (g.client.base, bid) items = [GrrEmptyItem("monitor", t("screen.title"), "running" if d.get("running") else t("screen.starting").rstrip(".")), GrrKvItem([("Display", "%sx%s" % (d.get("width"), d.get("height"))), ("Screens", "%s / %s" % (d.get("clients", 0), d.get("max"))), ("Browser", "yes" if d.get("browser") else t("screen.no_browser"))])] if d.get("running"): items.append(GrrButtonItem(t("screen.stop"), lambda: g.confirm(t("screen.stop"), t("screen.stop_text"), t("screen.stop"), lambda: self.call("POST", "/api/bots/%s/desktop/stop" % bid, {}, lambda _r: self.reload()), True), "secondary", inline=True)) else: items.append(GrrButtonItem(t("profile.take_control"), lambda: self.call("POST", "/api/bots/%s/desktop/start" % bid, {}, lambda _r: self.reload()), inline=True)) items.append(GrrButtonItem("Open in browser", lambda: g.open_url(url), "secondary", inline=True, icon="external")) items.append(GrrNote("The graphical screen is a VNC view. It needs a graphical client: open it in a browser. The shell of the same computer is under Terminal.", center=True)) self.screen.items(items) # --------------------------------------------------------------------------- # Part G: the side pane of a bot chat (Preview, Files, Deploy), the route # table and the entry point behind `tai.py --tui`. # --------------------------------------------------------------------------- class GrrPane: """Preview and Files beside the chat, or over it on a narrow terminal. The tabs and what is enabled come from the server's spec.""" def __init__(self, view): self.v = view g = view.g ui = view.ui self.g = g self.cfg = g.spec.get("pane") or {} self.tabs = [t for t in (self.cfg.get("tabs") or [{"id": "preview"}, {"id": "files"}, {"id": "deploy", "enabled": False}])] self.tab = self.cfg.get("default_tab") or "preview" self.is_open = False bots = view.bots() self.bot = bots[0] self.root = TuiRows(w=self.width(), h="100%", style=ui.st("ink", "bg")) self.head = GrrBar(ui, style=ui.st("ink", "bg")) self.page = GrrPage(ui, style=ui.st("ink", "bg"), h="1fr") self.root.add(self.head, self.page) self.state = g.pane_state.setdefault(view.chat["id"], {}) self.files = GrrPaneFiles(self) self.preview = GrrPanePreview(self) self.deploy = GrrPaneDeploy(self) self.watchers = [] self.watchers.append(g.events.on("files.changed", lambda d: self.on_event("files", d))) self.watchers.append(g.events.on("port.opened", lambda d: self.on_event("preview", d))) self.watchers.append(g.events.on("port.closed", lambda d: self.on_event("preview", d))) self.watchers.append(g.events.on("preview.ready", lambda d: self.on_event("preview", d))) self.watchers.append(g.events.on("preview.failed", lambda d: self.on_event("preview", d))) self.keeper = None def width(self): cols = self.g.app.cols if self.layer_mode(): return "1fr" return max(GRR_PANE_MIN_W, min(GRR_PANE_MAX_W, cols * 46 // 100)) def layer_mode(self): return self.g.app.cols < GRR_PANE_MIN_COLS def destroy(self): for off in self.watchers: off() if self.keeper is not None: self.g.app.cancel_timer(self.keeper) self.preview.hide() def restore(self): if self.state.get("open") and not self.layer_mode(): self.show(self.state.get("tab") or self.tab) def toggle(self): if self.is_open: self.hide() else: self.show(self.state.get("tab") or self.tab) def show(self, tab): v = self.v self.is_open = True self.state["open"] = True self.root.w = self.width() if self.root.parent is None: v.root.add(self.root) if self.layer_mode(): v.col.visible = False self.v.refresh_head() self.pick(tab if any(t["id"] == tab for t in self.tabs) else self.tab) self.g.app.set_focus(self.page) def hide(self): v = self.v if not self.is_open: return self.is_open = False self.state["open"] = False if self.root.parent is not None: v.root.remove(self.root) v.col.visible = True self.preview.hide() v.refresh_head() self.g.app.set_focus(v.box) def pick(self, tab): self.tab = tab self.state["tab"] = tab if tab != "preview": self.preview.hide() self.paint_head() self.current().show() def current(self): return {"preview": self.preview, "files": self.files, "deploy": self.deploy}.get(self.tab, self.preview) def paint_head(self): g, ui, t = self.g, self.v.ui, self.g.t parts = [] for tab in self.tabs: on = tab["id"] == self.tab parts.append({"text": " %s " % t("pane.tab_" + tab["id"]), "fn": (lambda i=tab["id"]: self.pick(i)), "bg": "accent" if on else "bg", "fg": "accent-ink" if on else "muted", "bold": on}) parts.append({"text": "", "fill": True}) bots = self.v.bots() if len(bots) > 1: parts.append({"text": " %s %s " % (ui.icon("user"), grr_clip(self.bot["name"], 12)), "bg": "card", "fn": lambda: g.menu([{"label": b["name"], "checked": b["id"] == self.bot["id"], "action": (lambda b=b: self.choose(b))} for b in bots])}) parts.append({"text": " %s " % ui.icon("close"), "fn": self.hide, "bg": "card"}) self.head.set(parts) def choose(self, bot): self.bot = bot self.state["bot"] = bot["id"] self.files.reset() self.preview.reset() self.paint_head() self.current().show() def on_event(self, kind, data): if not self.is_open or (data or {}).get("bot_id", self.bot["id"]) != self.bot["id"]: return if kind == "files" and self.tab == "files": self.files.changed() elif kind == "preview" and self.tab == "preview": self.preview.load(True) class GrrPaneTab: def __init__(self, pane): self.pane = pane @property def g(self): return self.pane.g @property def bot(self): return self.pane.bot def base(self, name): return "/api/bots/%s/%s" % (self.bot["id"], name) def items(self, items): self.pane.page.set_items(items) def reset(self): pass def hide(self): pass class GrrPaneFiles(GrrPaneTab): """The bot's workspace, read only: folders, a name filter, a viewer.""" def __init__(self, pane): super().__init__(pane) self.path = "" self.q = "" self.file = None self.entries = None self.total = 0 self.truncated = False self.gen = 0 def base(self, name): return "/api/bots/%s/workspace/%s" % (self.bot["id"], name) def reset(self): self.path, self.q, self.file, self.entries = "", "", None, None self.gen += 1 def show(self): if self.file is not None: self.view_file(self.file) else: self.load() def changed(self): if self.file is None: self.load(True) def load(self, quiet=False): g, t = self.g, self.g.t self.gen += 1 gen = self.gen if self.q: path = self.base("search") + "?q=" + urllib.parse.quote(self.q) else: path = self.base("tree") + "?path=" + urllib.parse.quote(self.path) def landed(res, err): if gen != self.gen: return if err: if not quiet: g.error(err) self.items([GrrEmptyItem("warn", t("files.load_failed"))]) return self.entries = res.get("entries") or res.get("results") or [] self.total = res.get("total", len(self.entries)) self.truncated = bool(res.get("truncated")) self.paint() g.app.offload(lambda: g.client.get(path), landed) def paint(self): g, t, ui = self.g, self.g.t, self.g.ui crumb = "/" + self.path if self.path else "/" chips = [{"label": crumb, "icon": "folder", "caret": False, "action": self.up}, {"label": self.q or t("files.filter"), "icon": "search", "caret": False, "action": self.ask}, {"label": t("files.refresh"), "icon": "refresh", "caret": False, "action": lambda: self.load()}] items = [GrrChipsItem(chips)] rows = [] for e in self.entries or []: name = e.get("path") or ((self.path + "/" if self.path else "") + e["name"]) is_dir = e.get("type") == "dir" sub = (t("files.cache") if e.get("cache") else "") if is_dir else GrrFormat.bytes(e.get("size", 0)) rows.append(GrrRowItem(e.get("path") or e["name"] if self.q else e["name"], icon="folder" if is_dir else "file", sub=sub, chevron=is_dir, action=(lambda n=name, d=is_dir: self.enter(n) if d else self.open_file(n)))) if self.path and not self.q: rows.insert(0, GrrRowItem("..", icon="back", action=self.up)) if rows: items += grr_group(rows) else: items.append(GrrEmptyItem("folder", t("files.none") if self.q else t("files.empty"))) if self.truncated: items.append(GrrNote(t("files.cut", shown=len(self.entries or []), total=self.total))) self.items(items) def enter(self, name): self.path = name self.q = "" self.load() def up(self): if self.q: self.q = "" else: self.path = self.path.rsplit("/", 1)[0] if "/" in self.path else "" self.load() def ask(self): g, t = self.g, self.g.t g.edit(t("files.filter"), self.q, lambda v: (setattr(self, "q", v.strip()), self.load())) def open_file(self, path): g, t = self.g, self.g.t self.gen += 1 gen = self.gen def landed(res, err): if gen != self.gen: return if err: g.error(err) return self.file = res self.view_file(res) g.app.offload(lambda: g.client.get(self.base("file") + "?path=" + urllib.parse.quote(path)), landed) def view_file(self, info): g, t, ui = self.g, self.g.t, self.g.ui items = [GrrChipsItem([{"label": t("files.back_list"), "icon": "back", "caret": False, "action": self.close_file}, {"label": t("files.copy_path"), "icon": "copy", "caret": False, "action": lambda: g.copy_text(info.get("path", ""))}, {"label": t("files.download"), "icon": "download", "caret": False, "action": lambda: self.download(info)}]), GrrNote(info.get("path", ""), tone="muted")] text = info.get("text") if isinstance(text, str): lines = text.split("\n") shown = lines[:2000] width = len(str(len(shown))) body = "\n".join("%*d %s" % (width, i + 1, line) for i, line in enumerate(shown)) items.append(GrrTextCard(body, mono=True)) if len(lines) > len(shown): items.append(GrrNote(t("files.cut", shown=len(shown), total=len(lines)))) else: kind = info.get("kind") items.append(GrrNote({"binary": t("files.binary"), "large": t("files.large", max=GrrFormat.bytes(g.ui.cap("VIEWER_MAX_BYTES", 2097152))), "gone": t("files.gone")}.get(kind, t("files.binary")), center=True)) self.items(items) def close_file(self): self.file = None self.load() def download(self, info): g = self.g path = self.base("raw") + "?path=" + urllib.parse.quote(info.get("path", "")) + "&download=1" def work(): data = g.client.fetch(path, limit=GRR_BODY_MAX)[0] dest = os.path.join(tempfile.gettempdir(), "grr-" + re.sub(r"[^A-Za-z0-9._-]", "_", info.get("name", "file"))) with open(dest, "wb") as fh: fh.write(data) return dest g.app.offload(work, lambda dest, err: g.error(err) if err else g.toast("Saved " + dest)) class GrrPanePreview(GrrPaneTab): """What the bot built: folders with an index.html and servers listening inside its computer. A terminal cannot frame a page, so it shows the address and opens it in the desktop browser.""" def __init__(self, pane): super().__init__(pane) self.info = None self.target = "" self.public = False self.shown = False def reset(self): self.info = None self.target = "" def show(self): self.shown = True self.load() if self.pane.keeper is None: ttl = max(5, (self.g.ui.cap("PORT_WATCH_TTL", 120)) / 2.0) self.pane.keeper = self.g.app.every(ttl, lambda: self.load(True) if self.shown else None) def hide(self): self.shown = False if self.pane.keeper is not None: self.g.app.cancel_timer(self.pane.keeper) self.pane.keeper = None def load(self, quick=False): g = self.g path = self.base("preview") + ("?ports=0" if quick else "") def landed(res, err): if err: self.info = {"folders": [], "ports": [], "active": None, "proxy": "local", "domain": "", "failed": True} g.error(err) else: self.info = res self.paint() active = (self.info or {}).get("active") if self.shown and active and active.get("state") == "starting": g.app.after(2.0, lambda: self.load(True) if self.shown else None) g.app.offload(lambda: g.client.get(path), landed) def options(self): t = self.g.t info = self.info or {} out = [{"id": "folder:" + f["path"], "label": "/" + f["path"] if f["path"] else t("preview.root"), "kind": "folder", "path": f["path"]} for f in info.get("folders", [])] out += [{"id": "port:%s" % p["port"], "label": t("preview.port", port=p["port"]) + (" (%s)" % p["process"] if p.get("process") else ""), "kind": "port", "port": p["port"]} for p in info.get("ports", [])] return out def paint(self): g, t, ui = self.g, self.g.t, self.g.ui info = self.info if info is None: self.items([GrrEmptyItem("globe", "...")]) return if info.get("proxy") == "none": self.items([GrrEmptyItem("warn", t("preview.no_proxy", domain=info.get("domain", "")))]) return active = info.get("active") items = [] if active: url = active.get("url") or active.get("address") or "" state = active.get("state", "") items += [GrrEmptyItem("globe", url or t("preview.starting", host=active.get("host", "")), state)] items.append(GrrKvItem([("State", state), ("Access", t("preview.public_badge") if active.get("access") == "public" or active.get("public") else "private")])) if url: items.append(GrrButtonItem(t("preview.external"), lambda: g.open_url(url), inline=True, icon="external")) items.append(GrrButtonItem(t("ui.share"), lambda: g.copy_text(url), "secondary", inline=True, icon="copy")) items.append(GrrButtonItem(t("preview.stop"), self.stop, "secondary", inline=True)) else: options = self.options() if not options: state = info.get("container") items.append(GrrEmptyItem("globe", t("preview.container_off", state=state) if state and state != "running" else t("preview.no_folders"), t("preview.pick_hint"))) else: rows = [GrrRowItem(o["label"], icon="folder" if o["kind"] == "folder" else "bolt", check=o["id"] == (self.target or options[0]["id"]), action=(lambda o=o: self.set_target(o["id"]))) for o in options] items += [GrrLabel(t("preview.pick_hint"))] + grr_group(rows) items += grr_group([GrrRowItem(t("preview.public"), sub=t("preview.public_hint"), toggle=self.public, on_toggle=lambda v: setattr(self, "public", v))]) items.append(GrrButtonItem(t("preview.open"), self.open, inline=True, icon="play")) items.append(GrrRowItem(t("preview.port_placeholder"), icon="plus", link=True, action=self.ask_port)) items.append(GrrNote(t("preview.port_opened", port="N", process="") if False else "")) self.items([i for i in items if not (isinstance(i, GrrNote) and not i.text)]) def set_target(self, target): self.target = target self.paint() def ask_port(self): g, t = self.g, self.g.t def go(value): if value.strip().isdigit(): self.post({"kind": "port", "port": int(value.strip())}) g.edit(t("preview.port_placeholder"), "", go) def open(self): options = self.options() if not options: return choice = next((o for o in options if o["id"] == self.target), options[0]) self.post({"kind": "port", "port": choice["port"]} if choice["kind"] == "port" else {"kind": "folder", "path": choice["path"]}) def post(self, target): g = self.g body = dict({"access": "public" if self.public else ((self.g.spec.get("pane") or {}).get("preview", {}).get("access_default") or "login")}, **target) def landed(res, err): if err: g.error(err) else: self.info = res self.paint() active = res.get("active") if self.shown and active and active.get("state") == "starting": g.app.after(2.0, lambda: self.load(True) if self.shown else None) g.app.offload(lambda: g.client.post(self.base("preview"), body), landed) def stop(self): g = self.g def landed(res, err): if err: g.error(err) else: self.info = res self.paint() g.app.offload(lambda: g.client.delete(self.base("preview")), landed) class GrrPaneDeploy(GrrPaneTab): """Shown with every control disabled, like the web app: deploy has no server behind it yet.""" def show(self): t = self.g.t self.items(grr_group([GrrRowItem(t("deploy.address"), value="-"), GrrRowItem(t("deploy.access"), value=t("deploy.access_me")), GrrRowItem(t("deploy.cover"), value="-")]) + [GrrButtonItem(t("deploy.button"), lambda: None, "secondary", inline=True, disabled=True), GrrNote(t("deploy.note"), center=True)]) GRR_CONTROL_JOBS = 64 GRR_CONTROL_LINE = 65536 GRR_CONTROL_WAIT = 10 GRR_CONTROL_WAIT_MAX = 60 GRR_CONTROL_KEYS_MAX = 200 def grr_control_path(): """The control socket of the running grr TUI: TAI_TUI_SOCK or <TAI_HOME>/tui.sock.""" home = os.environ.get("TAI_HOME") or os.path.join(os.path.expanduser("~"), ".tai") return os.environ.get("TAI_TUI_SOCK") or os.path.join(home, "tui.sock") class GrrControl: """Lets agents drive the running grr TUI over a local unix socket (mode 0600), one JSON line in and one out. An operation is a job on the UI loop: `screen`, `key`, `type`, `click`, `goto` and `result`. Every one can be async: the call returns a job id at once and `result` collects it (with a wait). `screen` may wait for text to appear (`until`). At most GRR_CONTROL_JOBS finished jobs are kept.""" def __init__(self, shell, path=None): self.shell = shell self.path = path or grr_control_path() self.sock = None self.jobs = collections.OrderedDict() self.seq = 0 self.lock = threading.Lock() self.thread = None self.stopped = False def start(self): if os.path.exists(self.path): probe = socket.socket(socket.AF_UNIX) probe.settimeout(1) try: probe.connect(self.path) probe.close() return False except OSError: os.unlink(self.path) os.makedirs(os.path.dirname(self.path), exist_ok=True) self.sock = socket.socket(socket.AF_UNIX) self.sock.bind(self.path) os.chmod(self.path, 0o600) self.sock.listen(8) self.thread = threading.Thread(target=self.accept, name="grr-control", daemon=True) self.thread.start() return True def stop(self): self.stopped = True if self.sock is not None: try: self.sock.close() except OSError: pass try: os.unlink(self.path) except OSError: pass self.sock = None def accept(self): while not self.stopped: try: conn, _addr = self.sock.accept() except OSError: return threading.Thread(target=self.serve, args=(conn,), daemon=True).start() def serve(self, conn): try: conn.settimeout(GRR_CONTROL_WAIT_MAX + 5) data = b"" while b"\n" not in data and len(data) < GRR_CONTROL_LINE: chunk = conn.recv(4096) if not chunk: break data += chunk try: request = json.loads(data.decode("utf-8")) reply = self.handle(request) except (ValueError, TypeError) as exc: reply = {"state": "error", "error": "bad request: %s" % exc} conn.sendall((json.dumps(reply) + "\n").encode("utf-8")) except OSError: pass finally: conn.close() def handle(self, request): op = str(request.get("op") or "") wait = max(0.0, min(float(request.get("wait", GRR_CONTROL_WAIT)), GRR_CONTROL_WAIT_MAX)) if op == "result": with self.lock: job = self.jobs.get(int(request.get("id", 0))) if job is None: return {"state": "error", "error": "no such job"} else: if not hasattr(self, "op_" + op): return {"state": "error", "error": "unknown op %r" % op} job = self.submit(op, request) if request.get("async") and op != "result": return {"id": job["id"], "state": job["state"]} job["event"].wait(wait) return self.view(job) def view(self, job): out = {"id": job["id"], "state": job["state"]} if job["state"] == "done": out["result"] = job["result"] elif job["state"] == "error": out["error"] = job["error"] return out def submit(self, op, request): with self.lock: self.seq += 1 job = {"id": self.seq, "op": op, "state": "running", "event": threading.Event(), "result": None, "error": ""} self.jobs[job["id"]] = job while len(self.jobs) > GRR_CONTROL_JOBS: self.jobs.popitem(last=False) self.shell.app.run_on_loop(lambda: self.run(job, request)) return job def finish(self, job, result=None, error=""): job["result"], job["error"] = result, error job["state"] = "error" if error else "done" job["event"].set() def run(self, job, request): try: outcome = getattr(self, "op_" + job["op"])(job, request) except Exception as exc: self.finish(job, error="%s: %s" % (type(exc).__name__, exc)) return if outcome is not self.PENDING: self.finish(job, outcome) PENDING = object() def screen_info(self): app, shell = self.shell.app, self.shell app.layout() focus = app.focused return {"route": shell.router.current, "cols": app.cols, "rows": app.rows, "signed_in": shell.signed_in, "focus": type(focus).__name__ if focus is not None else "", "screen": app.render_text()} def op_screen(self, job, request): until = request.get("until") if not until: return self.screen_info() deadline = time.time() + max(0.0, min(float(request.get("timeout", GRR_CONTROL_WAIT)), GRR_CONTROL_WAIT_MAX)) def look(): info = self.screen_info() if until in info["screen"]: self.finish(job, dict(info, matched=True)) elif time.time() >= deadline: self.finish(job, dict(info, matched=False)) else: self.shell.app.after(0.2, look) look() return self.PENDING @staticmethod def key_event(name): parts = name.split("+") base = parts[-1] mods = parts[:-1] char = {"space": " ", "enter": "\n", "tab": "\t"}.get(base, base if len(base) == 1 else "") if mods and len(base) == 1 and "ctrl" in mods: char = chr(ord(base.lower()) & 0x1f) if base.isalpha() else "" return TuiKey(key=name.lower() if len(base) > 1 or mods else base, char=char, modifiers=mods) def op_key(self, job, request): keys = request.get("keys") or [] if not isinstance(keys, list) or len(keys) > GRR_CONTROL_KEYS_MAX: raise ValueError("keys must be a list of at most %d names" % GRR_CONTROL_KEYS_MAX) for name in keys: self.shell.app.dispatch(self.key_event(str(name))) self.shell.app.wake() return {"sent": len(keys)} def op_type(self, job, request): text = str(request.get("text") or "")[:GRR_CONTROL_KEYS_MAX * 20] for ch in text: name = "enter" if ch == "\n" else ("space" if ch == " " else ch) self.shell.app.dispatch(TuiKey(key=name, char="\n" if ch == "\n" else ch, modifiers=[])) self.shell.app.wake() return {"typed": len(text)} def op_click(self, job, request): x, y = int(request["x"]), int(request["y"]) app = self.shell.app app.layout() app.dispatch(TuiClick(x=x, y=y, button=0, modifiers=[], pressed=True)) app.dispatch(TuiClick(x=x, y=y, button=0, modifiers=[], pressed=False)) app.wake() return {"clicked": [x, y]} def op_goto(self, job, request): route = str(request.get("route") or "/") if not route.startswith("/"): raise ValueError("a route starts with /") self.shell.router.go(route) return {"route": self.shell.router.current} def grr_control_call(payload, path=None, timeout=GRR_CONTROL_WAIT_MAX + 10): """Client side: one request to the running TUI, its reply as a dict.""" path = path or grr_control_path() sock = socket.socket(socket.AF_UNIX) sock.settimeout(timeout) try: sock.connect(path) sock.sendall((json.dumps(payload) + "\n").encode("utf-8")) data = b"" while not data.endswith(b"\n"): chunk = sock.recv(65536) if not chunk: break data += chunk except OSError as exc: return {"state": "error", "error": "no running grr TUI answers on %s (%s)" % (path, exc)} finally: sock.close() try: return json.loads(data.decode("utf-8")) except ValueError: return {"state": "error", "error": "unreadable reply"} GRR_ROUTES = ( ("/bots", "GrrBotsView"), ("/bot/:id", "GrrProfileView"), ("/bot/:id/instructions", "GrrInstructionsView"), ("/bot/:id/memories", "GrrMemoriesView"), ("/memory/:id", "GrrMemoryView"), ("/bot/:id/skills", "GrrSkillsView"), ("/skill/:id", "GrrSkillView"), ("/bot/:id/computer", "GrrComputerView"), ("/bot/:id/screen", "GrrScreenView"), ("/chat/:id", "GrrChatView"), ("/automations", "GrrAutomationsView"), ("/automations/new", "GrrNewAutomationView"), ("/automation/:id", "GrrAutomationView"), ("/library", "GrrLibraryView"), ("/projects", "GrrProjectsView"), ("/project/:id", "GrrProjectView"), ("/search", "GrrSearchView"), ("/settings", "GrrSettingsView"), ("/settings/account", "GrrAccountView"), ("/settings/usage", "GrrUsageView"), ("/settings/rules", "GrrRulesView"), ("/settings/rule/:id", "GrrRuleView"), ("/settings/appearance", "GrrAppearanceView"), ("/settings/language", "GrrLanguageView"), ("/settings/haptics", "GrrHapticsView"), ("/settings/widget", "GrrWidgetView"), ("/settings/computer", "GrrComputersView"), ("/settings/connectors", "GrrConnectorsView"), ("/settings/advanced", "GrrAdvancedView"), ("/settings/builder", "GrrBuilderView"), ("/settings/feedback", "GrrFeedbackView"), ("/settings/timezone", "GrrTimezoneView"), ("/template/:id", "GrrTemplateView"), ("/template/:id/:part", "GrrTemplatePartView"), ("/public/:slug", "GrrPublicTemplateView"), ) def grr_shell_call_quiet(self, method, path, body=None): """Fire and forget a server call from the loop (read marks, releases).""" self.app.offload(lambda: self.client.request(method, path, body, quiet=True), lambda _r, _e: None) def grr_router_rewrite(self, raw): """Replace the current route in the stack without opening it again.""" if self.stack: self.stack[-1] = raw GrrShell.client_call_quiet = grr_shell_call_quiet GrrRouter.rewrite = grr_router_rewrite def grr_tui(): """Run the fullscreen grr client. The theme comes from the account's settings (system, day, night and the Black or Blue variant).""" app = GrrTuiApp() app.rows, app.cols = app._winsize() shell = GrrShell(app) control = GrrControl(shell) try: control.start() except OSError as exc: print("tui control socket unavailable: %s" % exc, file=sys.stderr) try: app.mount(shell.root) shell.boot() return app.run() except TuiError as exc: print("tui unavailable: %s" % exc, file=sys.stderr) return 2 finally: control.stop() shell.stop_stream() # --------------------------------------------------------------------------- # MCP (Model Context Protocol), client side and server side. See mcp.md. MCP_PROTOCOL_VERSION = "2025-11-25" MCP_CLIENT_NAME = "tai" MCP_TOOL_PREFIX = "mcp_" MCP_RESOLVER_TIMEOUT = 300 MCP_RESOLVER_STEPS = 12 MCP_OAUTH_TIMEOUT = 300 MCP_RESOLVER_BRIEF = """You must resolve the GitHub repo %s/%s into an MCP server run contract. Use web_fetch to read, in this order, until one tells you how to run the server: https://api.github.com/repos/%s/%s/contents/mcp.json https://api.github.com/repos/%s/%s/contents/.mcp.json https://api.github.com/repos/%s/%s/contents/server.json https://api.github.com/repos/%s/%s/contents/package.json https://api.github.com/repos/%s/%s/contents/README.md https://raw.githubusercontent.com/%s/%s/HEAD/README.md (github api file blobs are base64 under content, decode them yourself.) Prefer an explicit stdio command (command + args + env) from the repo docs; npx -y <pkg> for npm packages and uvx <pkg> for python packages are acceptable when the README shows them. If the repo is only usable as a remote http endpoint, return its url instead. Reply with EXACTLY one fenced block and nothing else outside it: ```json {"command": "npx", "args": ["-y", "pkg"], "env": {}} ``` or {"url": "https://..."} for remote servers, or {"error": "short reason", "evidence": "what you checked"} when nothing runnable exists. Never invent package names or urls you did not read.""" MCP_CONNECT_TIMEOUT = 20 MCP_CALL_TIMEOUT = 60 MCP_RESULT_MAX = 65536 MCP_HTTP_MAX_BODY = 10485760 MCP_MAX_SERVERS = 32 MCP_MAX_TOOLS = 100 MCP_SESSION_IDLE = 3600 MCP_CHAT_TIMEOUT = 600 MCP_DEFAULT_PORT = 8765 MCP_SERVER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$") MCP_VAR_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}") MCP_GITHUB_RE = re.compile(r"github\.com/([^/\s?#]+)/([^/\s?#]+?)(?:\.git)?(?:[/?#]|$)", re.IGNORECASE) class McpError(Exception): pass class McpJsonRpcError(McpError): def __init__(self, code, message): super(McpJsonRpcError, self).__init__("json-rpc %s: %s" % (code, message)) self.code = code self.message = message class McpHttpError(McpError): def __init__(self, code, www_auth=""): super(McpHttpError, self).__init__("http %s" % code) self.code = code self.www_auth = www_auth or "" def mcp_sanitize_name(text): cleaned = re.sub(r"[^a-z0-9_]", "_", (text or "").lower()).strip("_") return cleaned or "server" def mcp_github_target(source): match = MCP_GITHUB_RE.search(source or "") if not match: return None return match.group(1), match.group(2) def mcp_expand_vars(text, lookup): """Expand ${VAR} and ${VAR:-default} via lookup(name) -> value or None. Returns (expanded, missing): names with neither a value nor a default keep their literal and land in missing.""" missing = [] def fill(match): name, default = match.group(1), match.group(2) value = lookup(name) if value is None: if default is not None: return default missing.append(name) return match.group(0) return value return MCP_VAR_RE.sub(fill, text or ""), missing def mcp_rewrite_placeholders(text): """Rewrite foreign placeholder spellings to ${NAME}. Returns (text, noted).""" noted = [] if "${env:" in (text or ""): text = re.sub(r"\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}", r"${\1}", text) noted.append("rewrote ${env:NAME} to ${NAME}") if "${input:" in (text or ""): text = re.sub(r"\$\{input:([^}]+)\}", r"${\1}", text) noted.append("rewrote ${input:id} to ${id} (resolves against vault secrets)") return text, noted def mcp_entry_from_mapping(name, raw): """Normalize one raw server mapping to a stored entry. Raises McpError.""" if not isinstance(raw, dict): raise McpError("server '%s' is not an object" % name) entry = {"name": name, "transport": None, "command": "", "args": [], "env": {}, "url": "", "headers": {}, "cwd": "", "allow": [], "deny": [], "enabled": None, "source": "config"} notes = [] command = raw.get("command") or "" url = raw.get("url") or "" if command and url: raise McpError("server '%s' sets both command and url" % name) if command: if not isinstance(command, str): raise McpError("server '%s' command must be a string" % name) entry["transport"] = "stdio" entry["command"] = command args = raw.get("args", []) if not isinstance(args, list) or any(not isinstance(item, str) for item in args): raise McpError("server '%s' args must be a string list" % name) entry["args"] = list(args) env = raw.get("env") or raw.get("env_vars") or {} if not isinstance(env, dict) or any(not isinstance(k, str) or not isinstance(v, str) for k, v in env.items()): raise McpError("server '%s' env must be a string map" % name) entry["env"] = dict(env) cwd = raw.get("cwd") or raw.get("working_directory") or "" if cwd and not isinstance(cwd, str): raise McpError("server '%s' cwd must be a string" % name) entry["cwd"] = cwd elif url: if not isinstance(url, str): raise McpError("server '%s' url must be a string" % name) probe = (url.replace("${", "x").split() or [""])[0] if not re.match(r"https?://[^/\s]+", probe): raise McpError("server '%s' url must be http(s)" % name) if not raw.get("type"): notes.append("entry has url without type, treating as http") entry["transport"] = "http" entry["url"] = url headers = raw.get("headers") or raw.get("http_headers") or {} if not isinstance(headers, dict): raise McpError("server '%s' headers must be a string map" % name) entry["headers"] = dict((str(k), str(v)) for k, v in headers.items()) indirect = raw.get("env_http_headers") or {} if not isinstance(indirect, dict): raise McpError("server '%s' env_http_headers must be a string map" % name) for key, var in indirect.items(): if not isinstance(var, str): raise McpError("server '%s' env_http_headers values must be var names" % name) entry["headers"][str(key)] = "${%s}" % var else: raise McpError("server '%s' needs command or url" % name) for key in ("enabled_tools", "disabled_tools"): items = raw.get(key) or [] if not isinstance(items, list) or any(not isinstance(item, str) for item in items): raise McpError("server '%s' %s must be a string list" % (name, key)) entry["allow"] = list(raw.get("enabled_tools") or []) entry["deny"] = list(raw.get("disabled_tools") or []) if raw.get("enabled") is not None: entry["enabled"] = bool(raw.get("enabled")) if raw.get("timeout") is not None or raw.get("startup_timeout") is not None: notes.append("timeout fields ignored, tai caps apply (connect %ds, call %ds)" % (MCP_CONNECT_TIMEOUT, MCP_CALL_TIMEOUT)) return entry, notes def mcp_normalize(source, name=None): """Normalize pasted JSON/TOML config text to (entry, notes). Raises McpError.""" text = (source or "").strip() if not text: raise McpError("nothing to normalize") if mcp_github_target(text) and "\n" not in text.strip() and len(text.strip()) < 200 and "{" not in text and "[" not in text: raise McpError("github URL needs the resolver, not the normalizer") entries = None if text.startswith("{"): try: data = json.loads(text) except ValueError as exc: raise McpError("invalid JSON: %s" % short_error(exc)) if not isinstance(data, dict): raise McpError("config JSON must be an object") wrapped = data.get("mcpServers", data.get("servers")) if isinstance(wrapped, dict): entries = wrapped elif "command" in data or "url" in data: entries = {name or "server": data} else: raise McpError("JSON needs mcpServers/servers or a bare command/url entry") else: try: import tomllib except ImportError: raise McpError("TOML needs Python 3.11+") try: data = tomllib.loads(text) except (ValueError, UnicodeDecodeError) as exc: raise McpError("config is neither JSON nor TOML: %s" % short_error(exc)) wrapped = data.get("mcp_servers", data.get("servers")) if isinstance(wrapped, dict): entries = wrapped else: raise McpError("TOML needs [mcp_servers.NAME] tables") if name is not None: if name not in entries: raise McpError("no server '%s' in config (%s)" % (name, ", ".join(sorted(entries)) or "empty")) entries = {name: entries[name]} if len(entries) != 1: raise McpError("config names %d servers (%s), pass name to pick one" % (len(entries), ", ".join(sorted(entries)))) single, raw = next(iter(entries.items())) if not MCP_SERVER_RE.match(single): raise McpError("server name '%s' must match [A-Za-z0-9][A-Za-z0-9_.-]{0,63}" % single) entry, notes = mcp_entry_from_mapping(single, raw) entry["source"] = "config-toml" if not text.startswith("{") else "config-json" return entry, notes class McpStdioTransport(object): """One MCP server child over newline-delimited JSON-RPC on pipes.""" def __init__(self): self.proc = None self.buf = b"" def spawn(self, argv, env, cwd, timeout=MCP_CONNECT_TIMEOUT): full_env = dict(os.environ) full_env.update(env or {}) try: self.proc = subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=full_env, cwd=cwd or None) except OSError as exc: raise McpError("spawn failed: %s" % short_error(exc)) try: os.set_blocking(self.proc.stdout.fileno(), False) except (OSError, ValueError): pass return self def _read_line(self, deadline): while b"\n" not in self.buf: if self.proc.poll() is not None: raise McpError("server exited during read (code %s)" % self.proc.returncode) remaining = deadline - time.time() if remaining <= 0: raise McpError("read timed out") try: ready, _, _ = select.select([self.proc.stdout.fileno()], [], [], min(remaining, 1.0)) except (OSError, ValueError): raise McpError("stdio servers need POSIX select") if not ready: continue try: chunk = os.read(self.proc.stdout.fileno(), 65536) except BlockingIOError: continue except OSError as exc: raise McpError("read failed: %s" % short_error(exc)) if not chunk: raise McpError("server closed stdout") self.buf += chunk if len(self.buf) > MCP_HTTP_MAX_BODY: raise McpError("server line exceeds %d bytes" % MCP_HTTP_MAX_BODY) line, self.buf = self.buf.split(b"\n", 1) return line.decode("utf-8", "replace") def send(self, message, timeout=MCP_CALL_TIMEOUT): raw = (json.dumps(message) + "\n").encode("utf-8") try: self.proc.stdin.write(raw) self.proc.stdin.flush() except (OSError, ValueError) as exc: raise McpError("write failed: %s" % short_error(exc)) if "id" not in message: return None deadline = time.time() + timeout while True: try: data = json.loads(self._read_line(deadline)) except ValueError: continue if not isinstance(data, dict) or data.get("id") != message["id"]: continue return data def close(self): proc, self.proc = self.proc, None self.buf = b"" if proc is None: return try: if proc.stdin is not None: proc.stdin.close() except OSError: pass try: proc.wait(timeout=2) except subprocess.TimeoutExpired: pass if proc.poll() is None: cap_proc_kill(proc) cap_proc_reap(proc) class McpHttpTransport(object): """Streamable HTTP leg: POST per request, single JSON or SSE reply.""" def __init__(self, url, headers=None): self.url = url self.headers = dict(headers or {}) self.session = "" def _post(self, message, timeout): body = json.dumps(message).encode("utf-8") heads = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"} heads.update(self.headers) if self.session: heads["Mcp-Session-Id"] = self.session request = urllib.request.Request(self.url, data=body, headers=heads, method="POST") try: response = urllib.request.urlopen(request, timeout=timeout) except urllib.error.HTTPError as exc: raise McpHttpError(exc.code, (exc.headers or {}).get("WWW-Authenticate", "")) except (urllib.error.URLError, OSError) as exc: raise McpError("http failed: %s" % short_error(exc)) raw = response.read(MCP_HTTP_MAX_BODY + 1) if len(raw) > MCP_HTTP_MAX_BODY: raise McpError("http body exceeds %d bytes" % MCP_HTTP_MAX_BODY) session = response.headers.get("Mcp-Session-Id") or "" if session: self.session = session if not raw: return None kind = (response.headers.get("Content-Type") or "").split(";")[0].strip() if kind == "text/event-stream": return self._parse_sse(raw.decode("utf-8", "replace")) try: return json.loads(raw.decode("utf-8", "replace")) except ValueError: raise McpError("http reply is not JSON") @staticmethod def _parse_sse(text): """Parse SSE frames, return the last data: JSON payload.""" payload = None for frame in text.split("\n\n"): data = [line[5:].strip() for line in frame.split("\n") if line.startswith("data:")] if not data: continue try: payload = json.loads("\n".join(data)) except ValueError: continue if payload is None: raise McpError("SSE reply held no JSON") return payload def send(self, message, timeout=MCP_CALL_TIMEOUT): data = self._post(message, timeout) if "id" not in message: return None if data is None: raise McpError("empty http reply") if not isinstance(data, dict) or data.get("id") != message["id"]: raise McpError("http reply id mismatch") return data def close(self): self.session = "" class McpClient(object): """Minimal MCP client over one transport: handshake, tools, call, ping.""" def __init__(self, transport): self.transport = transport self.next_id = 0 self.server_version = "" self.server_info = {} def _request(self, method, params=None, timeout=MCP_CALL_TIMEOUT): self.next_id += 1 message = {"jsonrpc": "2.0", "id": self.next_id, "method": method} if params is not None: message["params"] = params try: reply = self.transport.send(message, timeout) except McpError: raise if not isinstance(reply, dict): raise McpError("reply is not an object") if "error" in reply and isinstance(reply["error"], dict): raise McpJsonRpcError(reply["error"].get("code", -32603), reply["error"].get("message", "error")) if "result" not in reply: raise McpError("reply has neither result nor error") return reply["result"] def _notify(self, method, params=None): message = {"jsonrpc": "2.0", "method": method} if params is not None: message["params"] = params self.transport.send(message) def initialize(self): try: result = self._request("initialize", {"protocolVersion": MCP_PROTOCOL_VERSION, "capabilities": {}, "clientInfo": {"name": MCP_CLIENT_NAME, "version": VERSION}}, timeout=MCP_CONNECT_TIMEOUT) except McpHttpError: raise except McpError as exc: raise McpError("initialize failed: %s" % exc) if not isinstance(result, dict) or not isinstance(result.get("protocolVersion"), str): raise McpError("initialize: bad handshake reply") if not isinstance(result.get("capabilities"), dict) or "tools" not in result["capabilities"]: raise McpError("initialize: server offers no tools capability") self.server_version = result["protocolVersion"] self.server_info = result.get("serverInfo") or {} self._notify("notifications/initialized") return result def list_tools(self): tools = [] cursor = None for _page in range(20): params = {} if cursor is None else {"cursor": cursor} result = self._request("tools/list", params) if not isinstance(result, dict) or not isinstance(result.get("tools"), list): raise McpError("tools/list: bad reply") tools.extend(item for item in result["tools"] if isinstance(item, dict) and item.get("name")) cursor = result.get("nextCursor") if not cursor: break return tools def call_tool(self, name, args=None): try: return self._request("tools/call", {"name": name, "arguments": args or {}}) except McpHttpError: raise except McpError as exc: try: self._notify("notifications/cancelled", {"requestId": self.next_id, "reason": "timeout"}) except McpError: pass raise McpError("call %s failed: %s" % (name, exc)) def ping(self): self._request("ping", {}) def close(self): self.transport.close() def mcp_tool_hash(name, description, schema): blob = "%s\n%s\n%s" % (name, description or "", json.dumps(schema or {}, sort_keys=True)) return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16] def mcp_bridge_name(server, tool): return MCP_TOOL_PREFIX + mcp_sanitize_name(server) + "_" + mcp_sanitize_name(tool) class McpOAuthCallback(http.server.BaseHTTPRequestHandler): """Loopback landing page for the OAuth authorization redirect.""" server_version = "tai-mcp-oauth" def do_GET(self): params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) self.server.oauth_result = {"code": params.get("code", [""])[0], "state": params.get("state", [""])[0], "error": params.get("error", [""])[0]} body = b"<html><body><h1>Login complete, you can close this window.</h1></body></html>" self.send_response(200) self.send_header("Content-Type", "text/html") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, *args): pass class McpOAuth(object): """OAuth 2.1 + MCP authorization: discovery, DCR, PKCE loopback, refresh. Stdlib only.""" def __init__(self, store): self.store = store @staticmethod def secret_name(server, kind): core = server if len(server) <= 40 else server[:31] + "-" + hashlib.sha1(server.encode("utf-8")).hexdigest()[:8] return "mcp-oauth-%s-%s" % (core, kind) def load_tokens(self, server, profile): access = self.store.load_secret(self.secret_name(server, "access"), profile) refresh = self.store.load_secret(self.secret_name(server, "refresh"), profile) return access, refresh def save_tokens(self, server, access, refresh, client_id, profile): meta = {"server": server, "kind": "mcp-oauth", "client_id": client_id or ""} self.store.save_secret(self.secret_name(server, "access"), access, meta=meta, profile=profile) if refresh: self.store.save_secret(self.secret_name(server, "refresh"), refresh, meta=meta, profile=profile) else: self.store.delete_secret(self.secret_name(server, "refresh"), profile) def drop_tokens(self, server, profile): for kind in ("access", "refresh"): self.store.delete_secret(self.secret_name(server, kind), profile) def client_id(self, server, profile): return self.store.secret_meta(self.secret_name(server, "access"), profile).get("client_id", "") def expand_url(self, row, profile): def lookup(name): if name in os.environ and os.environ[name]: return os.environ[name] return self.store.load_secret(name, profile) url, missing = mcp_expand_vars(row["url"], lookup) if missing: raise McpError("unresolved secrets: %s (set env or store_secret)" % ", ".join(missing)) return url def inject_headers(self, headers, server, profile): """Add the OAuth bearer header unless one is already configured. Returns a copy.""" if any(key.lower() == "authorization" for key in headers): return dict(headers) access, _refresh = self.load_tokens(server, profile) if not access: return dict(headers) heads = dict(headers) heads["Authorization"] = "Bearer " + access return heads @staticmethod def json_get(url): request = urllib.request.Request(url, headers={"Accept": "application/json"}) try: response = urllib.request.urlopen(request, timeout=MCP_CONNECT_TIMEOUT) except urllib.error.HTTPError as exc: raise McpError("metadata fetch failed: http %s" % exc.code) except (urllib.error.URLError, OSError) as exc: raise McpError("metadata fetch failed: %s" % short_error(exc)) try: data = json.loads(response.read(MCP_HTTP_MAX_BODY + 1).decode("utf-8", "replace")) except ValueError: raise McpError("metadata reply is not JSON") if not isinstance(data, dict): raise McpError("metadata reply is not an object") return data @staticmethod def form_post(url, fields): body = urllib.parse.urlencode(fields).encode("utf-8") request = urllib.request.Request(url, data=body, headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}) try: response = urllib.request.urlopen(request, timeout=MCP_CONNECT_TIMEOUT) except urllib.error.HTTPError as exc: raise McpError("token endpoint refused: http %s" % exc.code) except (urllib.error.URLError, OSError) as exc: raise McpError("token endpoint failed: %s" % short_error(exc)) try: data = json.loads(response.read(MCP_HTTP_MAX_BODY + 1).decode("utf-8", "replace")) except ValueError: raise McpError("token reply is not JSON") if not isinstance(data, dict): raise McpError("token reply is not an object") if data.get("error"): raise McpError("token endpoint said %s: %s" % (data.get("error"), data.get("error_description", ""))) return data @staticmethod def resource_url(www_auth): match = re.search(r'resource_metadata="([^"]+)"', www_auth or "") return match.group(1) if match else "" @staticmethod def wellknown(issuer, name): parts = urllib.parse.urlsplit(issuer) base = urllib.parse.urlunsplit((parts.scheme, parts.netloc, "", "", "")) return base + "/.well-known/" + name + (parts.path or "") def discover(self, url, www_auth=""): """Find the authorization server: protected-resource metadata, then server metadata.""" origin = urllib.parse.urlsplit(url) if origin.scheme not in ("http", "https") or not origin.netloc: raise McpError("not an http(s) url") resource = self.resource_url(www_auth) issuers, scope = [], "" if resource: try: doc = self.json_get(resource) except McpError as exc: raise McpError("protected-resource metadata failed: %s" % exc) issuers = [item for item in doc.get("authorization_servers", []) if isinstance(item, str)] scopes = [item for item in doc.get("scopes_supported", []) if isinstance(item, str)] scope = " ".join(scopes) if not issuers: issuers = [urllib.parse.urlunsplit((origin.scheme, origin.netloc, "", "", ""))] errors = [] for issuer in issuers: try: doc = self.json_get(self.wellknown(issuer, "oauth-authorization-server")) except McpError as exc: errors.append("%s: %s" % (issuer, exc)) continue if not doc.get("authorization_endpoint") or not doc.get("token_endpoint"): errors.append("%s: metadata lacks endpoints" % issuer) continue if not doc.get("registration_endpoint"): errors.append("%s: no dynamic registration endpoint" % issuer) continue return {"authorization_endpoint": doc["authorization_endpoint"], "token_endpoint": doc["token_endpoint"], "registration_endpoint": doc["registration_endpoint"], "scope": scope} raise McpError("no usable authorization server (%s)" % "; ".join(errors)) @staticmethod def register(reg_endpoint, redirect_uri): body = json.dumps({"client_name": "tai-mcp", "redirect_uris": [redirect_uri], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none"}).encode("utf-8") request = urllib.request.Request(reg_endpoint, data=body, headers={"Content-Type": "application/json", "Accept": "application/json"}) try: response = urllib.request.urlopen(request, timeout=MCP_CONNECT_TIMEOUT) except urllib.error.HTTPError as exc: raise McpError("client registration refused: http %s" % exc.code) except (urllib.error.URLError, OSError) as exc: raise McpError("client registration failed: %s" % short_error(exc)) try: data = json.loads(response.read(MCP_HTTP_MAX_BODY + 1).decode("utf-8", "replace")) except ValueError: raise McpError("registration reply is not JSON") if not isinstance(data, dict) or not data.get("client_id"): raise McpError("registration reply has no client_id") return data["client_id"] @staticmethod def pkce(): verifier = secrets.token_urlsafe(64)[:128] digest = hashlib.sha256(verifier.encode("ascii")).digest() challenge = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") return verifier, challenge @staticmethod def authorize_url(meta, client_id, redirect_uri, scope, state, challenge, resource): fields = {"response_type": "code", "client_id": client_id, "redirect_uri": redirect_uri, "state": state, "code_challenge": challenge, "code_challenge_method": "S256", "resource": resource} if scope: fields["scope"] = scope return meta["authorization_endpoint"] + ("&" if "?" in meta["authorization_endpoint"] else "?") + urllib.parse.urlencode(fields) @staticmethod def start_listener(): """Bind the loopback redirect target. Returns (server, redirect_uri).""" server = http.server.HTTPServer(("127.0.0.1", 0), McpOAuthCallback) server.timeout = 1.0 server.oauth_result = None return server, "http://127.0.0.1:%d/callback" % server.server_address[1] @staticmethod def wait_callback(server, state, timeout=MCP_OAUTH_TIMEOUT): """Serve the loopback redirect until the code arrives or time runs out.""" deadline = time.time() + timeout try: while time.time() < deadline: server.handle_request() if server.oauth_result: result = server.oauth_result if result["error"]: raise McpError("authorization failed: %s" % result["error"]) if result["state"] != state: raise McpError("authorization state mismatch") if not result["code"]: raise McpError("authorization returned no code") return result["code"] finally: server.server_close() raise McpError("timed out after %ds waiting for the browser (rerun mcp_login to retry)" % timeout) def exchange(self, token_endpoint, code, redirect_uri, client_id, verifier, resource): data = self.form_post(token_endpoint, {"grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri, "client_id": client_id, "code_verifier": verifier, "resource": resource}) if not data.get("access_token"): raise McpError("token reply has no access_token") return data["access_token"], data.get("refresh_token", "") def refresh_flow(self, row, profile): """Refresh the access token with the stored refresh token. Returns (access, note).""" _access, refresh = self.load_tokens(row["name"], profile) if not refresh: raise McpError("no refresh token stored") client = self.client_id(row["name"], profile) if not client: raise McpError("no client_id stored, rerun mcp_login") url = self.expand_url(row, profile) meta = self.discover(url) data = self.form_post(meta["token_endpoint"], {"grant_type": "refresh_token", "refresh_token": refresh, "client_id": client, "resource": url}) if not data.get("access_token"): raise McpError("refresh reply has no access_token") self.save_tokens(row["name"], data["access_token"], data.get("refresh_token", "") or refresh, client, profile) return data["access_token"], "refreshed" def login(self, row, profile, opener=None): """Full interactive login: probe, discover, register, browser, exchange, store.""" if row["transport"] != "http": raise McpError("local stdio servers need no login") url = self.expand_url(row, profile) probe = McpClient(McpHttpTransport(url, {})) try: try: probe.initialize() except McpHttpError as exc: if exc.code != 401: raise McpError("server unreachable: http %s" % exc.code) www_auth = exc.www_auth else: raise McpError("server needs no login (anonymous handshake worked)") finally: probe.close() meta = self.discover(url, www_auth) server, redirect_uri = self.start_listener() try: client = self.register(meta["registration_endpoint"], redirect_uri) except McpError: server.server_close() raise verifier, challenge = self.pkce() state = secrets.token_urlsafe(16) auth_url = self.authorize_url(meta, client, redirect_uri, meta["scope"], state, challenge, url) print("mcp login for '%s': approve in your browser" % row["name"]) print(auth_url) opened = False try: opened = (opener or webbrowser.open)(auth_url) except Exception: opened = False if not opened: print("(no browser opened, visit the URL above yourself)") code = self.wait_callback(server, state) access, refresh = self.exchange(meta["token_endpoint"], code, redirect_uri, client, verifier, url) self.save_tokens(row["name"], access, refresh, client, profile) note = "logged in to '%s' (tokens sealed in vault" % row["name"] return note + ")" if refresh else note + ", no refresh token issued, rerun login when it expires)" class McpBridge(object): """Client-side bridge: vault servers in, lazy tool schemas and calls out.""" def __init__(self, app): self.app = app @property def store(self): return self.app.store def lookup_var(self, name, profile): if name in os.environ and os.environ[name]: return os.environ[name] try: return self.store.load_secret(name, profile) except (sqlite3.Error, OSError): return None def expand_entry(self, row, profile): """Expand placeholders in a stored server row. Returns (ready, missing).""" missing = [] ready = dict(row) lookup = lambda name: self.lookup_var(name, profile) if row["transport"] == "stdio": argv = [row["command"]] + row["args"] done = [] for part in argv: text, miss = mcp_expand_vars(part, lookup) missing += miss done.append(text) ready["argv"] = done env = {} for key, value in row["env"].items(): text, miss = mcp_expand_vars(value, lookup) missing += miss env[key] = text ready["env"] = env else: url, miss = mcp_expand_vars(row["url"], lookup) missing += miss ready["url"] = url heads = {} for key, value in row["headers"].items(): text, miss = mcp_expand_vars(value, lookup) missing += miss heads[key] = text ready["headers"] = heads return ready, sorted(set(missing)) def connect(self, row, profile): ready, missing = self.expand_entry(row, profile) if missing: raise McpError("unresolved secrets: %s (set env or store_secret)" % ", ".join(missing)) if row["transport"] == "stdio": transport = McpStdioTransport().spawn(ready["argv"], ready["env"], row["cwd"]) else: heads = McpOAuth(self.store).inject_headers(ready["headers"], row["name"], profile) transport = McpHttpTransport(ready["url"], heads) client = McpClient(transport) try: client.initialize() except McpError: client.close() raise return client def cached_tools(self, server): return self.store.mcp_cached_tools(server) def schemas_for(self, profile): """Lazy schemas for enabled servers from the vault cache (no spawn).""" found = [] for row in self.store.mcp_enabled_servers(profile): tools = [item for item in self.cached_tools(row["name"]) if self.tool_allowed(row, item["tool"])] for item in tools[:MCP_MAX_TOOLS]: params = item["schema"] or {"type": "object", "properties": {}} if not isinstance(params, dict) or params.get("type") != "object": params = {"type": "object", "properties": {}} desc = item["description"] or "MCP tool %s" % item["tool"] found.append(tool_schema(mcp_bridge_name(row["name"], item["tool"]), "MCP %s/%s: %s" % (row["name"], item["tool"], desc), params.get("properties") or {}, params.get("required") or [])) return found @staticmethod def tool_allowed(row, tool): if row["deny"] and tool in row["deny"]: return False if row["allow"] and tool not in row["allow"]: return False return True def resolve(self, name): """Split a bridged tool name back to (server, tool).""" if not name.startswith(MCP_TOOL_PREFIX): return None rest = name[len(MCP_TOOL_PREFIX):] for row in self.store.mcp_list_servers(): prefix = mcp_sanitize_name(row["name"]) + "_" if rest.startswith(prefix): return row["name"], rest[len(prefix):] return None def check_drift(self, server, fresh, profile): """Compare fresh tools/list against pins. Returns (ok, note).""" pins = self.store.mcp_tool_pins(server) cached = dict((item["tool"], item) for item in self.cached_tools(server)) changed, added = [], [] for item in fresh: tool = item.get("name", "") digest = mcp_tool_hash(tool, item.get("description", ""), item.get("inputSchema")) if tool not in pins: added.append(tool) elif pins[tool] != digest: changed.append(tool) if not added and not changed: return True, "" detail = "MCP server '%s' changed tools" % server if added: detail += "; new: %s" % ", ".join(sorted(added)[:10]) if changed: detail += "; redefined: %s" % ", ".join(sorted(changed)[:10]) if not self.app.ask_approval(detail + " (approve to accept and re-pin)"): return False, "tool list changed and was not approved" self.store.mcp_store_tools(server, fresh) return True, "re-pinned %d tools" % len(fresh) def result_text(self, result): parts = [] for item in (result or {}).get("content", []) or []: if not isinstance(item, dict): continue kind = item.get("type", "") if kind == "text": parts.append(item.get("text", "")) elif kind in ("image", "audio"): parts.append("[%s %d bytes b64]" % (kind, len(item.get("data", "")))) elif kind == "resource_link": parts.append("[resource %s]" % item.get("uri", "")) else: parts.append("[%s]" % (kind or "content")) structured = (result or {}).get("structuredContent") if structured is not None: parts.append(json.dumps(structured)[:4000]) return "\n".join(parts) def call(self, server, tool, args, profile): """Full call path: enable check, connect, drift check, approval, call, cap, audit.""" if not isinstance(args, dict): return "error: MCP tool args must be an object" row = self.store.mcp_get_server(server) if row is None: return "error: unknown MCP server '%s'" % server if not self.store.mcp_server_enabled(server, profile): return "error: MCP server '%s' is disabled for profile '%s'" % (server, profile) if not self.tool_allowed(row, tool): return "error: MCP tool '%s' is not enabled on '%s'" % (tool, server) started = time.time() try: return self._call_inner(row, server, tool, args, profile, started) except McpHttpError as exc: if exc.code != 401 or row["transport"] != "http": return "error: %s" % exc oauth = McpOAuth(self.store) _access, refresh = oauth.load_tokens(server, profile) if not refresh: if any(key.lower() == "authorization" for key in row["headers"]): return "error: MCP server '%s' refused its configured credentials (http 401)" % server return "error: MCP server '%s' needs login: run mcp_login '%s' (http 401)" % (server, server) try: oauth.refresh_flow(row, profile) except McpError as rexc: return "error: MCP server '%s' token refresh failed (%s): run mcp_login '%s'" % (server, rexc, server) try: return self._call_inner(row, server, tool, args, profile, started) except McpHttpError as exc2: if exc2.code != 401: return "error: %s" % exc2 return "error: MCP server '%s' still unauthorized after refresh: run mcp_login '%s'" % (server, server) def _call_inner(self, row, server, tool, args, profile, started): """One attempt: connect, drift check, approval, call, cap, audit. Lets 401s bubble.""" try: client = self.connect(row, profile) except McpHttpError: raise except McpError as exc: return "error: %s" % exc try: try: fresh = client.list_tools() except McpHttpError: raise except McpError as exc: return "error: tools/list failed: %s" % exc names = [item.get("name", "") for item in fresh] if tool not in names: return "error: MCP server '%s' has no tool '%s'" % (server, tool) ok, note = self.check_drift(server, fresh, profile) if not ok: return "error: %s" % note digest = hashlib.sha256(json.dumps(args, sort_keys=True).encode("utf-8")).hexdigest()[:16] if not self.app.ask_approval("mcp call %s/%s (args %s)" % (server, tool, digest)): return "denied by user" try: result = client.call_tool(tool, args) except McpHttpError: self.audit(server, tool, digest, "retry-401", started, profile) raise except McpError as exc: self.audit(server, tool, digest, "error", started, profile) return "error: %s" % exc text = self.result_text(result) if (result or {}).get("isError"): self.audit(server, tool, digest, "isError", started, profile) return "error: %s" % (text or "tool failed") if len(text) > MCP_RESULT_MAX: mem_id = self.spill(server, tool, text, profile) self.audit(server, tool, digest, "spilled", started, profile) return "output spilled to record %s (%d bytes); first %d bytes follow:\n%s" % ( mem_id, len(text), MCP_RESULT_MAX, text[:MCP_RESULT_MAX]) self.audit(server, tool, digest, "ok", started, profile) return text finally: client.close() def spill(self, server, tool, text, profile): try: record_id = self.store.add_record("output", "mcp %s/%s output" % (server, tool), text, ["mcp"], profile) self.store.add_chunks("record", record_id, chunk_lines(text), profile) return record_id except (sqlite3.Error, OSError, ValueError): return "mem:?" def audit(self, server, tool, digest, outcome, started, profile): try: actor = self.app.tools.audit_actor() if getattr(self.app, "tools", None) else "agent" self.store.audit_event(actor, "mcp-call", "%s/%s" % (server, tool), "%s args=%s %.1fs" % (outcome, digest, time.time() - started), tags=["mcp"], profile=profile) except (sqlite3.Error, OSError): pass class McpServeSession(object): def __init__(self, agent, profile): self.agent = agent self.profile = profile now = time.time() self.created = now self.last_seen = now def mcp_serve_tools(): """Native schemas plus chat/completions as MCP tool dicts.""" tools = [{"name": schema["function"]["name"], "description": schema["function"]["description"], "inputSchema": schema["function"]["parameters"]} for schema in TOOL_SCHEMAS] tools.append({"name": "chat", "description": "Full tai agent turn with tools, on the session agent. History is kept per MCP session.", "inputSchema": {"type": "object", "properties": {"message": {"type": "string"}, "profile": {"type": "string"}, "max_steps": {"type": "integer"}}, "required": ["message"]}}) tools.append({"name": "completions", "description": "One stateless model completion with the current model, no tools.", "inputSchema": {"type": "object", "properties": {"messages": {"type": "array", "items": {"type": "object"}}}, "required": ["messages"]}}) return tools class McpServeContext(object): def __init__(self, config, store, key): self.config = config self.store = store self.key = key self.sessions = {} def authed(self, headers): presented = (headers.get("Authorization") or headers.get("authorization") or "") if presented.lower().startswith("bearer "): presented = presented[7:] return bool(self.key) and hmac.compare_digest(presented.strip(), self.key) def prune(self): now = time.time() for sid in [key for key, session in self.sessions.items() if now - session.last_seen > MCP_SESSION_IDLE]: del self.sessions[sid] def agent_for(self, sid, profile): session = self.sessions.get(sid) if session is None or session.agent is None: agent = Agent(self.config, self.store, yolo=True, auto=True) agent.quiet = True if session is None: session = self.sessions[sid] = McpServeSession(agent, profile or "default") else: session.agent = agent session.profile = profile or session.profile or "default" session.last_seen = time.time() return session.agent def mcp_serve_handler(ctx): tools = mcp_serve_tools() class Handler(http.server.BaseHTTPRequestHandler): server_version = "tai-mcp/" + VERSION def log_message(self, *args): sys.stderr.write("mcp %s %s\n" % (self.command, self.path.split("?")[0])) def _body(self): try: length = int(self.headers.get("Content-Length") or 0) except ValueError: length = 0 if length > MCP_HTTP_MAX_BODY: return None return self.rfile.read(length) if length else b"" def _send_json(self, payload, session=""): raw = json.dumps(payload).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(raw))) if session: self.send_header("Mcp-Session-Id", session) self.end_headers() self.wfile.write(raw) def _reply(self, answer, session=""): if answer is None: self.send_response(202) self.send_header("Content-Length", "0") self.end_headers() return self._send_json(answer, session) def do_GET(self): self.send_response(405) self.send_header("Content-Length", "0") self.end_headers() def do_DELETE(self): if self.path.split("?")[0] != "/mcp" or not ctx.authed(self.headers): self.send_response(401 if not ctx.authed(self.headers) else 404) self.send_header("Content-Length", "0") self.end_headers() return sid = self.headers.get("Mcp-Session-Id") or "" ctx.sessions.pop(sid, None) self.send_response(200) self.send_header("Content-Length", "0") self.end_headers() def do_POST(self): ctx.prune() if self.path.split("?")[0] != "/mcp": self.send_response(404) self.send_header("Content-Length", "0") self.end_headers() return if not ctx.authed(self.headers): LOG.event("WARNING", "serve", "denied", "rejected unauthenticated request", meta={"path": self.path.split("?")[0]}) self.send_response(401) self.send_header("Content-Length", "0") self.end_headers() return raw = self._body() if raw is None: self.send_response(413) self.send_header("Content-Length", "0") self.end_headers() return try: message = json.loads(raw.decode("utf-8") or "{}") except ValueError: self.send_response(400) self.send_header("Content-Length", "0") self.end_headers() return if not isinstance(message, dict) or message.get("jsonrpc") != "2.0": self.send_response(400) self.send_header("Content-Length", "0") self.end_headers() return sid = self.headers.get("Mcp-Session-Id") or "" method = message.get("method", "") if method != "initialize" and sid not in ctx.sessions: self._reply({"jsonrpc": "2.0", "id": message.get("id"), "error": {"code": -32603, "message": "unknown session, initialize first"}}) return answer, session = self.dispatch_message(message, sid) self._reply(answer, session) def dispatch_message(self, message, sid): started = time.time() answer, session = self._dispatch_message_inner(message, sid) method = message.get("method", "") tool = "" if method == "tools/call": tool = (message.get("params") or {}).get("name") or "" if answer is None: outcome = "ok" elif isinstance(answer.get("error"), dict): outcome = "error" elif isinstance(answer.get("result"), dict) and answer["result"].get("isError"): outcome = "error" else: outcome = "ok" try: row = ctx.sessions.get(session or sid) profile = row.profile if row is not None else "" except Exception: profile = "" LOG.event("INFO", "serve", "request", "served %s in %dms (%s)" % (method or "?", int((time.time() - started) * 1000), outcome), profile=profile, meta={"method": method, "tool": tool, "session": session or sid, "exec_ms": int((time.time() - started) * 1000), "outcome": outcome}) return answer, session def _dispatch_message_inner(self, message, sid): method = message.get("method", "") msg_id = message.get("id") params = message.get("params") or {} def fail(code, text): if msg_id is None: return None, "" return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": text}}, sid def ok(result): if msg_id is None: return None, "" return {"jsonrpc": "2.0", "id": msg_id, "result": result}, sid if method == "initialize": fresh = uuid.uuid4().hex ctx.sessions[fresh] = McpServeSession(None, "") return ({"jsonrpc": "2.0", "id": msg_id, "result": {"protocolVersion": MCP_PROTOCOL_VERSION, "capabilities": {"tools": {}}, "serverInfo": {"name": MCP_CLIENT_NAME, "version": VERSION}}}, fresh) if method == "notifications/initialized": return None, "" if method == "ping": return ok({}) if method == "tools/list": return ok({"tools": tools}) if method != "tools/call": return fail(-32601, "unknown method %s" % method) name = params.get("name", "") args = params.get("arguments") or {} if not isinstance(args, dict): return fail(-32602, "arguments must be an object") if name == "chat": return self.run_chat(msg_id, sid, args) if name == "completions": return self.run_completions(msg_id, sid, args) agent = ctx.agent_for(sid, "") try: text = agent.tools.dispatch(name, args) except Denied as exc: return ok({"content": [{"type": "text", "text": "denied: %s" % exc}], "isError": True}) except Exception as exc: return ok({"content": [{"type": "text", "text": "error: %s" % short_error(exc)}], "isError": True}) text = text if isinstance(text, str) else json.dumps(text) failed = text.startswith("error:") or text.startswith("denied") result = {"content": [{"type": "text", "text": text}]} if failed: result["isError"] = True return ok(result) def run_chat(self, msg_id, sid, args): message = args.get("message", "") if not isinstance(message, str) or not message.strip(): return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": -32602, "message": "chat needs a message string"}}, sid try: steps = int(args.get("max_steps", 0) or 0) except (TypeError, ValueError): steps = 0 agent = ctx.agent_for(sid, args.get("profile") or "") agent.deadline = time.time() + MCP_CHAT_TIMEOUT try: text = agent.run_turn(message, capture=True, max_steps=steps or MAX_STEPS) except BackendError as exc: return {"jsonrpc": "2.0", "id": msg_id, "result": {"content": [{"type": "text", "text": "error: %s" % short_error(exc)}], "isError": True}}, sid except Exception as exc: return {"jsonrpc": "2.0", "id": msg_id, "result": {"content": [{"type": "text", "text": "error: %s" % short_error(exc)}], "isError": True}}, sid finally: agent.deadline = None return {"jsonrpc": "2.0", "id": msg_id, "result": {"content": [{"type": "text", "text": text}]}}, sid def run_completions(self, msg_id, sid, args): messages = args.get("messages") if not isinstance(messages, list) or not messages: return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": -32602, "message": "completions needs a messages array"}}, sid cleaned = [] for item in messages: if not isinstance(item, dict) or item.get("role") not in ("system", "user", "assistant"): return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": -32602, "message": "bad message entry (role/content required)"}}, sid if not isinstance(item.get("content"), str): return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": -32602, "message": "bad message entry (role/content required)"}}, sid cleaned.append({"role": item["role"], "content": item["content"]}) try: reply = ChatClient(ctx.config).complete(cleaned) except BackendError as exc: return {"jsonrpc": "2.0", "id": msg_id, "result": {"content": [{"type": "text", "text": "error: %s" % short_error(exc)}], "isError": True}}, sid text = reply if isinstance(reply, str) else json.dumps(reply) return {"jsonrpc": "2.0", "id": msg_id, "result": {"content": [{"type": "text", "text": text}]}}, sid return Handler def mcp_serve_main(args, key): try: port = int(getattr(args, "mcp_port", None) or os.environ.get("TAI_MCP_PORT") or MCP_DEFAULT_PORT) except ValueError: print("error: bad port, use 1-65535", file=sys.stderr) return 2 if not 1 <= port <= 65535: print("error: bad port, use 1-65535", file=sys.stderr) return 2 host = getattr(args, "mcp_bind", None) or os.environ.get("TAI_MCP_BIND") or "127.0.0.1" agent = boot(args) ctx = McpServeContext(agent.config, agent.store, key) server = http.server.HTTPServer((host, port), mcp_serve_handler(ctx)) server.timeout = 30 print(paint("serving MCP on http://%s:%d/mcp (%d tools + chat + completions)" % (host, port, len(TOOL_SCHEMAS)), Ansi.GREEN)) print(paint("key is the authorization: served tools run yolo, no approvals asked", Ansi.YELLOW)) try: server.serve_forever() except KeyboardInterrupt: pass server.server_close() return 0 # CAP (CLI Agent Protocol), agent side and orchestrator side. See cap.md. # Phase 1: events, errors, manifest registry. Later phases add --cap-speak # plus the PTY/stream-json/ACP/A2A drivers and orchestration on top. # --------------------------------------------------------------------------- CAP_VERSION = "cap-protocol/v1" CAP_MANIFEST_SCHEMA = "https://cap-protocol.org/schema/manifest/v1.json" CAP_PROFILE_CODING = "cap-protocol/v1/profile/coding" CAP_MANIFEST_REVISION = "2026-05-18" CAP_PROBE_TIMEOUT = 15 CAP_ERROR_CODES = { -32001: "cap_manifest_missing", -32002: "cap_session_config_missing", -32003: "cap_unsupported_capability", -32004: "cap_budget_exceeded", -32005: "cap_invalid_answer", -32006: "cap_unknown_permission", -32007: "cap_queued_input_unsupported", -32008: "cap_cancel_unsupported", -32009: "cap_binding_unavailable", -32010: "cap_profile_unsupported", -32020: "cap_scope_violation", -32021: "cap_path_not_found", -32022: "cap_io_error", -32023: "cap_terminal_unavailable", -32024: "cap_terminal_killed", } CAP_ERROR_SYMBOLS = {symbol: numeric for numeric, symbol in CAP_ERROR_CODES.items()} CAP_ERROR_STRINGS = ( "rate_limit", "auth_error", "tool_timeout", "context_overflow", "model_unavailable", "pty_died", "parse_failed", "git_dirty", "test_failed", "merge_conflict", "language_server_down", ) class CapError(Exception): """A CAP wire failure: always carries the spec symbol and, when the symbol is registered, its JSON-RPC numeric twin.""" def __init__(self, symbol, message=""): super().__init__(message or symbol) self.symbol = symbol self.numeric = CAP_ERROR_SYMBOLS.get(symbol, 0) class CapEvent: """Core event constructors and validators (spec §7 plus the coding profile's cap.artifact and cap.fs.change). Internal construction mistakes raise ValueError; wire failures raise CapError.""" FROM_AGENT = "agent_to_orchestrator" TO_AGENT = "orchestrator_to_agent" KINDS = ( "cap.session.ready", "cap.text_chunk", "cap.tool_call.start", "cap.tool_call.delta", "cap.tool_call.end", "cap.plan", "cap.thought", "cap.ask_user", "cap.permission.request", "cap.usage", "cap.error", "cap.artifact", "cap.fs.change", "cap.pty.raw_bytes", "cap.user_input.inject", "cap.cancel", "cap.session.config", "cap.ask_user.answer", "cap.permission.response", ) DIRECTIONS = { "cap.session.ready": FROM_AGENT, "cap.text_chunk": FROM_AGENT, "cap.tool_call.start": FROM_AGENT, "cap.tool_call.delta": FROM_AGENT, "cap.tool_call.end": FROM_AGENT, "cap.plan": FROM_AGENT, "cap.thought": FROM_AGENT, "cap.ask_user": FROM_AGENT, "cap.permission.request": FROM_AGENT, "cap.usage": FROM_AGENT, "cap.error": FROM_AGENT, "cap.artifact": FROM_AGENT, "cap.fs.change": FROM_AGENT, "cap.pty.raw_bytes": FROM_AGENT, "cap.user_input.inject": TO_AGENT, "cap.cancel": TO_AGENT, "cap.session.config": TO_AGENT, "cap.ask_user.answer": TO_AGENT, "cap.permission.response": TO_AGENT, } REQUIRED = { "cap.session.ready": (), "cap.text_chunk": ("message_id", "text", "channel"), "cap.tool_call.start": ("call_id", "name", "input"), "cap.tool_call.delta": ("call_id", "output_chunk"), "cap.tool_call.end": ("call_id", "output", "is_error", "duration_ms"), "cap.plan": ("entries",), "cap.thought": ("message_id", "text"), "cap.ask_user": ("ask_id", "prompt", "form"), "cap.ask_user.answer": ("ask_id", "value"), "cap.permission.request": ("req_id", "tool", "intent", "scope", "risk_level"), "cap.permission.response": ("req_id", "decision"), "cap.user_input.inject": ("content",), "cap.cancel": ("scope", "reason"), "cap.usage": ("model_id",), "cap.session.config": (), "cap.error": ("code", "message", "retryable"), "cap.pty.raw_bytes": ("bytes_b64",), "cap.artifact": ("artifact_id", "mime_type", "name", "size"), "cap.fs.change": ("subscription_id", "path", "event"), } ENUMS = { ("cap.text_chunk", "channel"): ("assistant", "thought", "system"), ("cap.cancel", "scope"): ("current_turn", "session"), ("cap.permission.request", "scope"): ("read", "write", "execute", "network"), ("cap.permission.request", "risk_level"): ("low", "medium", "high"), ("cap.permission.response", "decision"): ("allow_once", "allow_always", "deny"), ("cap.session.config", "permission_mode"): ("none", "confirm", "interactive"), ("cap.usage", "stop_reason"): ("end_turn", "max_tokens", "tool_use", "stop_sequence", "cancelled", "error"), ("cap.fs.change", "event"): ("created", "modified", "deleted", "renamed"), } @staticmethod def make(kind, meta=None, **fields): if kind not in CapEvent.KINDS: raise ValueError("unknown CAP event kind: %r" % kind) missing = [name for name in CapEvent.REQUIRED.get(kind, ()) if name not in fields] if missing: raise ValueError("%s missing required fields: %s" % (kind, ", ".join(missing))) for (enum_kind, field), allowed in CapEvent.ENUMS.items(): if enum_kind == kind and field in fields and fields[field] not in allowed: raise ValueError("%s.%s must be one of %s, got %r" % (kind, field, "|".join(allowed), fields[field])) event = {"kind": kind} event.update(fields) if meta is not None: event["_meta"] = meta return event @staticmethod def validate(event): if not isinstance(event, dict) or event.get("kind") not in CapEvent.KINDS: raise ValueError("not a CAP event: %r" % (event,)) kind = event["kind"] missing = [name for name in CapEvent.REQUIRED.get(kind, ()) if name not in event] if missing: raise ValueError("%s missing required fields: %s" % (kind, ", ".join(missing))) return kind @staticmethod def direction(kind): return CapEvent.DIRECTIONS[kind] class CapBinding: """Binding priority and selection (spec §6). grpc is the only binding tai cannot drive: the standard library has no HTTP/2 or protobuf, so selection falls through past it.""" ORDER = ("grpc", "stream_json", "acp_stdio", "a2a", "pty") UNSUPPORTED = ("grpc",) @staticmethod def declared(manifest): fast = manifest.get("fast_path", {}) or {} found = [] if fast.get("grpc"): found.append("grpc") if fast.get("stream_json"): found.append("stream_json") if fast.get("acp_stdio"): found.append("acp_stdio") if fast.get("a2a_serve_at"): found.append("a2a") found.append("pty") return found @staticmethod def select(manifest): for name in CapBinding.ORDER: if name in CapBinding.declared(manifest) and name not in CapBinding.UNSUPPORTED: return name return "pty" class CapManifest: """One parsed agent manifest (spec §5): required-field validation, §5.3 defaults, and the regex-flavor gate. Tai deliberately accepts Python named groups, which every shipped example manifest uses, and rejects only lookaround plus backreferences.""" REQUIRED = ( "agent.name", "agent.binary", "agent.profiles", "startup.command", "startup.ready_when", "pty.cols", "pty.rows", "capabilities.streaming_output", ) DEFAULTS = { "pty.bracketed_paste": False, "pty.sigint_cancels_turn": "hard", "pty.queued_input_supported": False, "capabilities.multi_session": False, "capabilities.streaming_tool_output": False, "cost.metered": False, "parse.idle": ["^>\\s*$", "^❯\\s*$"], "parse.error_lines": [], } PATTERN_FIELDS = ( "agent.version_match", "probe.version_pattern", "startup.ready_when.pattern", "parse.tool_call_start", "parse.tool_call_end", "parse.plan_section", "parse.thought_section", "parse.ask_yes_no", "parse.ask_options", ) PATTERN_LIST_FIELDS = ("parse.idle", "parse.error_lines") LOOKAROUND_RE = re.compile(r"\(\?<?[=!]") BACKREF_RE = re.compile(r"\\[1-9]") def __init__(self, data, source=""): if not isinstance(data, dict): raise ValueError("CAP manifest from %s is not a table" % (source or "unknown source")) self.data = data self.source = source missing = [path for path in self.REQUIRED if self.get(path) is None] if missing: raise ValueError("CAP manifest from %s missing required fields: %s" % (source or "unknown source", ", ".join(missing))) self.check_patterns(source) @classmethod def from_toml(cls, text, source=""): try: import tomllib except ImportError: raise ValueError("CAP manifests need Python 3.11 or newer (tomllib)") try: data = tomllib.loads(text) except Exception as exc: raise ValueError("CAP manifest from %s does not parse: %s" % (source or "unknown source", exc)) return cls(data, source) def get(self, dotted, default=None): node = self.data for part in dotted.split("."): if not isinstance(node, dict) or part not in node: if dotted in self.DEFAULTS and default is None: return self.DEFAULTS[dotted] return default node = node[part] return node @property def name(self): return self.get("agent.name", "") @property def binary(self): return self.get("agent.binary", "") def ask_user(self, form): return bool((self.get("capabilities.ask_user", {}) or {}).get(form, False)) def check_patterns(self, source=""): patterns = [] for path in self.PATTERN_FIELDS: value = self.get(path) if value is not None: patterns.append((path, value)) for path in self.PATTERN_LIST_FIELDS: for value in self.get(path, []) or []: patterns.append((path, value)) for path, value in patterns: if not isinstance(value, str): raise ValueError("CAP manifest %s.%s must be a string" % (source or "manifest", path)) if self.LOOKAROUND_RE.search(value): raise ValueError("CAP manifest %s.%s uses lookaround, outside the §5.4 subset" % (source or "manifest", path)) if self.BACKREF_RE.search(value.replace("\\\\", "")): raise ValueError("CAP manifest %s.%s uses a backreference, outside the §5.4 subset" % (source or "manifest", path)) try: re.compile(value) except re.error as exc: raise ValueError("CAP manifest %s.%s does not compile: %s" % (source or "manifest", path, exc)) def binding(self): return CapBinding.select(self) class CapRegistry: """Manifest resolution in spec order: binary sibling, user-local, system-wide, probe. The first source that EXISTS wins; an existing source that fails to parse raises instead of falling through.""" SYSTEM_DIR = "/usr/share/cap-agents" def __init__(self, user_dir=None, system_dir=None): self.user_dir = user_dir or os.path.join(os.path.expanduser("~"), ".config", "cap", "agents") self.system_dir = system_dir or self.SYSTEM_DIR def resolve(self, name, binary=None): tried = [] command = binary or name sibling = self._sibling_manifest(command) if sibling is not None: return self._load_file(sibling, name) for folder in (self.user_dir, self.system_dir): path = os.path.join(folder, "%s.toml" % name) tried.append(path) if os.path.isfile(path): return self._load_file(path, name) probe_argv = self._probe_argv(command) if probe_argv is not None: tried.append(" ".join(probe_argv)) return self._load_probe(probe_argv, name) raise CapError("cap_manifest_missing", "no manifest for %r (looked in %s)" % (name, ", ".join(tried) or "nothing")) def _load_file(self, path, name): with open(path, "r", encoding="utf-8") as handle: manifest = CapManifest.from_toml(handle.read(), source=path) if manifest.name != name: raise ValueError("CAP manifest %s names %r, expected %r" % (path, manifest.name, name)) return manifest def _load_probe(self, argv, name): try: done = subprocess.run(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=CAP_PROBE_TIMEOUT) except (OSError, subprocess.SubprocessError) as exc: raise CapError("cap_manifest_missing", "probe %s failed: %s" % (" ".join(argv), exc)) if done.returncode != 0: raise CapError("cap_manifest_missing", "probe %s exited %d" % (" ".join(argv), done.returncode)) try: text = done.stdout.decode("utf-8") except UnicodeDecodeError as exc: raise CapError("cap_manifest_missing", "probe %s is not UTF-8: %s" % (" ".join(argv), exc)) manifest = CapManifest.from_toml(text, source=" ".join(argv)) if manifest.name != name: raise ValueError("CAP probe %s names %r, expected %r" % (" ".join(argv), manifest.name, name)) return manifest @staticmethod def _sibling_manifest(command): resolved = shutil.which(command) if not resolved: return None sibling = os.path.join(os.path.dirname(os.path.abspath(resolved)), "cap-agent.toml") return sibling if os.path.isfile(sibling) else None @staticmethod def _probe_argv(command): if not shutil.which(command): return None return [command, "--cap-manifest"] CAP_SESSION_CONFIG_ENV = "CAP_SESSION_CONFIG" CAP_SPEAK_MAX_TURNS = 50 CAP_SPEAK_DIFF_MAX = 65536 class CapSessionConfig: """The cap.session.config the orchestrator passes the agent side. Bulk path is CAP_SESSION_CONFIG (JSON, set by the driver); --cap-* flags override single fields. Applied in two steps: apply_pre_boot() before boot (working directory), apply() after.""" MODES = ("none", "confirm", "interactive") def __init__(self, data=None): self.data = dict(data or {}) @classmethod def from_env(cls): raw = os.environ.get(CAP_SESSION_CONFIG_ENV, "") if not raw.strip(): return cls({}) try: data = json.loads(raw) except ValueError as exc: raise ValueError("%s is not JSON: %s" % (CAP_SESSION_CONFIG_ENV, exc)) if not isinstance(data, dict): raise ValueError("%s must be a JSON object" % CAP_SESSION_CONFIG_ENV) return cls(data) @classmethod def from_args(cls, args): config = cls.from_env() overrides = ( ("cwd", getattr(args, "cap_cwd", None)), ("model", getattr(args, "cap_model", None)), ("permission_mode", getattr(args, "cap_permission_mode", None)), ("max_turns", getattr(args, "cap_max_turns", None)), ) for key, value in overrides: if value is not None: config.data[key] = value return config def get(self, key, default=None): return self.data.get(key, default) @property def cwd(self): return self.data.get("cwd") or "" @property def permission_mode(self): mode = self.data.get("permission_mode") or "interactive" return mode if mode in self.MODES else "interactive" @property def max_turns(self): try: value = int(self.data.get("max_turns") or 0) except (TypeError, ValueError): return CAP_SPEAK_MAX_TURNS return value if value > 0 else CAP_SPEAK_MAX_TURNS def apply_pre_boot(self): if self.cwd and os.path.abspath(self.cwd) != os.getcwd(): os.chdir(self.cwd) def apply(self, agent): """Wire the config into a booted agent. Returns notes about accepted-but-unmapped fields for the stderr log.""" agent.quiet = True notes = [] system_prompt = self.data.get("system_prompt") or "" if system_prompt: agent.system_message = system_prompt + "\n" + agent.system_message if self.data.get("model"): notes.append("model %r noted, tai picks its own backend" % self.data["model"]) if self.data.get("budget_usd") is not None: notes.append("budget_usd %s noted, tai backends are unmetered" % self.data["budget_usd"]) if self.data.get("profile_config"): notes.append("profile_config noted, tai has no tool allow-list to enforce it with") return notes class CapStreamJson: """stream-json wire shapes (Claude SDK compatible): constructors for outbound frames plus the inbound user-frame parser. Unknown inbound frame types raise ValueError; receivers MUST ignore unknown outbound extension fields and the tai `artifact` frame type.""" @staticmethod def system_init(session_id, model): return {"type": "system", "subtype": "init", "session_id": session_id, "model": model} @staticmethod def assistant(message_id, text="", calls=()): content = [] if text: content.append({"type": "text", "text": text}) for call in calls: try: payload = json.loads(call.get("arguments") or "{}") except ValueError: payload = {"raw": call.get("arguments") or ""} if not isinstance(payload, dict): payload = {"raw": call.get("arguments") or ""} content.append({"type": "tool_use", "id": call.get("id") or "", "name": call.get("name") or "", "input": payload}) if not content: return None return {"type": "assistant", "message": {"role": "assistant", "id": message_id, "content": content}} @staticmethod def tool_results(results): return {"type": "user", "message": {"role": "user", "content": [ {"type": "tool_result", "tool_use_id": call_id, "content": text, "is_error": failed} for call_id, text, failed in results ]}} @staticmethod def result_ok(session_id, model, text, duration_ms, usage, extra=None): frame = {"type": "result", "subtype": "success", "session_id": session_id, "model": model, "text": text, "duration_ms": duration_ms, "usage": usage} if extra: frame.update(extra) return frame @staticmethod def result_error(session_id, model, message): return {"type": "result", "subtype": "error", "session_id": session_id, "model": model, "error": message} @staticmethod def artifact_frame(artifact): return {"type": "artifact", "artifact": artifact} @staticmethod def parse_user_text(line): try: frame = json.loads(line) except ValueError as exc: raise ValueError("not JSON: %s" % exc) if not isinstance(frame, dict) or frame.get("type") != "user": raise ValueError("expected a {type: user} frame") message = frame.get("message", "") if isinstance(message, str): text = message elif isinstance(message, dict): parts = [] for block in message.get("content", []) or []: if isinstance(block, dict) and block.get("type") == "text": parts.append(block.get("text") or "") elif isinstance(block, str): parts.append(block) text = "\n".join(part for part in parts if part) else: raise ValueError("user frame has no usable message") if not text.strip(): raise ValueError("user frame carries no text") return text class CapSpeakSession: """One --cap-speak agent session: stdin NDJSON user frames in, NDJSON assistant/tool/result frames out. Turns run through Agent.run_turn with stdout parked on stderr, so the wire stays pure NDJSON; the emitted frames are also the session transcript, saved at the end.""" def __init__(self, agent, config, stdin=None, stdout=None): self.agent = agent self.config = config self.stdin = stdin or sys.stdin self.stdout = stdout or sys.stdout self.session_id = "cap_%s" % uuid.uuid4().hex[:12] self.model = config.get("model") or "tai" self.frames = [] self.msg_seq = 0 self.art_seq = 0 self.last_diff = b"" self.turns = 0 def emit(self, frame): self.frames.append(frame) self.stdout.write(json.dumps(frame) + "\n") self.stdout.flush() def run(self): old_term = signal.signal(signal.SIGTERM, cap_interrupt) try: return self._run() except KeyboardInterrupt: self.emit(CapStreamJson.result_error(self.session_id, self.model, "cancelled")) return 130 finally: signal.signal(signal.SIGTERM, old_term) def _run(self): self.emit(CapStreamJson.system_init(self.session_id, self.model)) if self.config.get("session_resume_id"): self.run_resume() while True: line = self.stdin.readline() if line == "": break if not line.strip(): continue try: text = CapStreamJson.parse_user_text(line) except ValueError as exc: self.emit(CapStreamJson.result_error(self.session_id, self.model, "invalid frame: %s" % exc)) continue if self.turns >= self.config.max_turns: self.emit(CapStreamJson.result_error(self.session_id, self.model, "max turns (%d) reached" % self.config.max_turns)) break self.run_one(text) self.turns += 1 self.finish() return 0 def run_resume(self): before = len(self.agent.messages) with contextlib.redirect_stdout(sys.stderr): resume_turn(self.agent) if len(self.agent.messages) == before: return self.emit_transcript_diff(before, time.time(), time.time(), 0) def run_one(self, user_text): before = len(self.agent.messages) tokens_before = conversation_tokens(self.agent.messages) started = time.time() with contextlib.redirect_stdout(sys.stderr): try: final = self.agent.run_turn(user_text, capture=True) except Exception as exc: self.emit(CapStreamJson.result_error(self.session_id, self.model, "turn failed: %s" % short_error(exc))) return total = time.time() - started self.emit_transcript_diff(before, started, time.time(), conversation_tokens(self.agent.messages) - tokens_before, final=final, duration=total) def emit_transcript_diff(self, before, started, ended, tokens_new, final="", duration=0.0): pending_results = [] def flush_results(): if pending_results: self.emit(CapStreamJson.tool_results(pending_results)) del pending_results[:] for message in self.agent.messages[before:]: role = message.get("role") if role == "assistant": flush_results() self.msg_seq += 1 frame = CapStreamJson.assistant("msg_%d" % self.msg_seq, message.get("content") or "", message.get("tool_calls") or ()) if frame is not None: self.emit(frame) elif role == "tool": text = message.get("content") or "" failed = text == "denied by user" or text.startswith(("error:", "[error]", "[missing]")) pending_results.append((message.get("tool_call_id") or "", text, failed)) flush_results() usage = {"total_tokens": max(tokens_new, 0), "duration_ms": int(duration * 1000), "tool_calls": self.agent._turn_tools, "model": self.model} if self.config.get("budget_usd") is not None: usage["budget_usd"] = self.config.get("budget_usd") if final.startswith("backend error:"): self.emit(CapStreamJson.result_error(self.session_id, self.model, final)) else: self.emit(CapStreamJson.result_ok(self.session_id, self.model, final, int(duration * 1000), usage)) artifact = self.diff_artifact() if artifact is not None: self.emit(CapStreamJson.artifact_frame(artifact)) def diff_artifact(self): diff = self.current_diff() if not diff or diff == self.last_diff: return None self.last_diff = diff self.art_seq += 1 content = diff[:CAP_SPEAK_DIFF_MAX] return {"artifact_id": "art_%d" % self.art_seq, "mime_type": "application/vnd.cap.coding.diff.v1+text", "name": "turn-%d.diff" % (self.turns + 1), "size": len(diff), "content": content.decode("utf-8", "replace"), "truncated": len(diff) > CAP_SPEAK_DIFF_MAX} def current_diff(self): try: inside = subprocess.run(["git", "rev-parse", "--is-inside-work-tree"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10) except (OSError, subprocess.SubprocessError): inside = None if inside is not None and inside.returncode == 0 and inside.stdout.strip() == b"true": try: done = subprocess.run(["git", "diff", "HEAD", "--"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30) except (OSError, subprocess.SubprocessError): return b"" return done.stdout if done.returncode == 0 else b"" preview = getattr(self.agent.tools, "diff_preview", None) if not preview: return b"" lines = difflib.unified_diff((preview.get("old") or "").splitlines(True), (preview.get("new") or "").splitlines(True), fromfile=preview.get("path") or "file", tofile=preview.get("path") or "file") return "".join(lines).encode("utf-8") def finish(self): try: folder = os.path.join(self.agent.config.home, "cap-transcripts") os.makedirs(folder, exist_ok=True) path = os.path.join(folder, "%s.json" % self.session_id) with open(path, "w", encoding="utf-8") as handle: json.dump({"session_id": self.session_id, "agent": "tai", "model": self.model, "events": self.frames}, handle) size = os.path.getsize(path) except (OSError, ValueError) as exc: print("cap-speak: transcript not saved: %s" % short_error(exc), file=sys.stderr) return self.art_seq += 1 self.emit(CapStreamJson.artifact_frame({ "artifact_id": "art_%d" % self.art_seq, "mime_type": "application/vnd.cap.coding.transcript.v1+json", "name": "%s.transcript.json" % self.session_id, "size": size, "uri": "file://%s" % path})) def cap_interrupt(_signum, _frame): raise KeyboardInterrupt def cap_speak_main(args): try: config = CapSessionConfig.from_args(args) except ValueError as exc: print("cap-speak: %s" % exc, file=sys.stderr) return 2 try: config.apply_pre_boot() except OSError as exc: print("cap-speak: cannot use cwd %r: %s" % (config.cwd, short_error(exc)), file=sys.stderr) return 2 args.auto = args.auto or config.permission_mode != "none" args.yolo = args.yolo or config.permission_mode == "none" with contextlib.redirect_stdout(sys.stderr): try: agent = boot(args) except KeyboardInterrupt: print(paint("\ninterrupted", Ansi.YELLOW), file=sys.stderr) return 130 except SealError as exc: print("seal error: %s" % exc, file=sys.stderr) return 2 for note in config.apply(agent): print("cap-speak: %s" % note, file=sys.stderr) session = CapSpeakSession(agent, config) try: return session.run() finally: try: agent.store.close() except Exception: pass CAP_PTY_SETTLE_MS = 800 CAP_PTY_IDLE_MS = 200 CAP_PTY_SUBMIT_GAP_MS = 150 CAP_PTY_RESUBMITS_MAX = 3 CAP_PTY_SCROLLBACK = 10000 CAP_PTY_INIT_TIMEOUT = 60 class CapDriver: """Shared orchestrator-driver plumbing: session identity, event queue, id sequences, open questions, the send/cancel guards, and the spawn-then-wait-ready start flow. Bindings implement _spawn, send_text, poll, cancel and close; all drivers are synchronous and poll-based, one per session thread. State contract: done means the TURN completed and the session is alive (send_text starts a new turn); closed and failed are terminal (send_text raises).""" STATES = ("starting", "ready", "working", "input_required", "done", "failed", "closed") def __init__(self, manifest, urn="cap://agent-1", cwd=None, model=None, env=None): self.manifest = manifest self.urn = urn self.cwd = cwd or os.getcwd() self.model = model self.extra_env = dict(env or {}) self.proc = None self.state = "starting" self.events = [] self.warnings = [] self.open_asks = {} self.msg_seq = 0 self.call_seq = 0 self.ask_seq = 0 self.plan_seq = 0 @property def cancel_mode(self): mode = self.manifest.get("capabilities.mid_turn_cancel") if mode is None: mode = self.manifest.get("pty.sigint_cancels_turn", "hard") return mode if mode in ("graceful", "hard", "none") else "hard" @property def queued(self): value = self.manifest.get("capabilities.queued_input") if value is None: value = self.manifest.get("pty.queued_input_supported", False) return bool(value) @property def init_timeout(self): return self.manifest.get("startup.init_timeout_seconds") or CAP_PTY_INIT_TIMEOUT def drain(self): events, self.events = self.events, [] return events def _emit(self, event): self.events.append(event) def _session_env(self): env = dict(os.environ) env.update(self.extra_env) sid_env = self.manifest.get("startup.session_id_env") if sid_env: env[sid_env] = self.urn return env def _model_argv(self, argv): if not self.model: return argv model_arg = self.manifest.get("startup.model_arg") if model_arg: return argv + [model_arg, self.model] self.warnings.append("model %r has nowhere to go, manifest lacks model_arg" % self.model) return argv def start(self, timeout=None): """Spawn, then block until ready convicts or the init timeout hits. Returns boot events.""" self._spawn() return self._run_until_ready(timeout) def _run_until_ready(self, timeout=None): limit = timeout or self.init_timeout deadline = time.time() + max(1, limit) collected = [] while time.time() < deadline: collected += self.poll(0.1) if self.state == "ready": return collected if self.state == "failed": raise CapError("cap_binding_unavailable", "agent died before ready") self.close() self.state = "failed" raise CapError("cap_binding_unavailable", "agent never became ready within %ds" % limit) def _wait_ready(self): if self.proc is None and self.state == "starting": self._spawn() deadline = time.time() + max(1, self.init_timeout) kept = [] while time.time() < deadline: kept += self.poll(0.1) if self.state == "ready": self.events = kept + self.events return if self.state == "failed": raise CapError("cap_binding_unavailable", "agent died before ready") raise CapError("cap_binding_unavailable", "agent never became ready") def _pre_send(self): if self.state in ("failed", "closed"): raise CapError("cap_binding_unavailable", "session is over") if self.state == "starting": self._wait_ready() if self.proc is None: raise CapError("cap_binding_unavailable", "session never started") if self.state == "working" and not self.queued: raise CapError("cap_queued_input_unsupported", "agent does not queue mid-turn input") def answer(self, ask_id, value): if ask_id not in self.open_asks: raise CapError("cap_invalid_answer", "no open question %r" % ask_id) return self._answer_open(ask_id, self.open_asks.pop(ask_id), value) def _answer_open(self, ask_id, kind, value): raise CapError("cap_invalid_answer", "this binding takes no answers") @staticmethod def create(manifest, urn="cap://agent-1", cwd=None, model=None, env=None, raw_sink=None, acp_argv=None): """Build the driver for the manifest's selected binding.""" binding = manifest.binding() if binding == "pty": return CapPtyDriver(manifest, urn=urn, cwd=cwd, model=model, env=env, raw_sink=raw_sink) if binding == "stream_json": return CapStreamDriver(manifest, urn=urn, cwd=cwd, model=model, env=env) if binding == "acp_stdio": return CapAcpDriver(manifest, urn=urn, cwd=cwd, model=model, env=env, acp_argv=acp_argv) if binding == "a2a": return CapA2aDriver(manifest, urn=urn, cwd=cwd, model=model, env=env) raise CapError("cap_binding_unavailable", "no tai driver for binding %r" % binding) def _spawn(self): raise NotImplementedError def send_text(self, text): raise NotImplementedError def poll(self, timeout=0.0): raise NotImplementedError def cancel(self, scope="current_turn"): raise NotImplementedError def close(self): raise NotImplementedError class CapPtyDriver(CapDriver): """Orchestrator-side PTY binding (spec §6.1, Appendix A): spawn the agent under PtyProc, render through TuiVtScreen, match the manifest [parse] patterns against rendered rows, and convict turn ends with the reference settle heuristic (byte silence plus prompt glyph).""" def __init__(self, manifest, urn="cap://agent-1", cwd=None, model=None, env=None, raw_sink=None): super().__init__(manifest, urn=urn, cwd=cwd, model=model, env=env) self.raw_sink = raw_sink rows = manifest.get("pty.rows") or 24 cols = manifest.get("pty.cols") or 80 self.screen = TuiVtScreen(rows, cols) self.screen.history = collections.deque(maxlen=CAP_PTY_SCROLLBACK) self.idle = [re.compile(pattern) for pattern in manifest.get("parse.idle", []) or []] self.tool_start = self._compile(manifest.get("parse.tool_call_start")) self.tool_end = self._compile(manifest.get("parse.tool_call_end")) self.plan_mark = self._compile(manifest.get("parse.plan_section")) self.thought_mark = self._compile(manifest.get("parse.thought_section")) self.ask_yes_no = self._compile(manifest.get("parse.ask_yes_no")) self.ask_options = self._compile(manifest.get("parse.ask_options")) self.error_res = [re.compile(pattern) for pattern in manifest.get("parse.error_lines", []) or []] ready = (manifest.get("startup.ready_when") or {}).get("pattern") or "" self.ready_re = re.compile(ready) if ready else None self.prev_rows = [""] * rows self.open_tool = None self.prompt_sent = False self.submit_key = "" self.resubmits = 0 self.seen_line_events = set() self.last_bytes_at = 0.0 self.turn_msg_id = "msg_boot" self.ready_at = None @staticmethod def _compile(pattern): return re.compile(pattern) if pattern else None def _spawn(self): if not PtyProc.available(): raise CapError("cap_binding_unavailable", "PTY binding needs pty/termios/fcntl/select") argv = list(self.manifest.get("startup.command") or []) if not argv: raise CapError("cap_binding_unavailable", "manifest has no startup.command") self.proc = PtyProc(self._model_argv(argv), False, self.cwd, self._session_env(), self.screen.rows, self.screen.cols) if self.manifest.get("pty.bracketed_paste"): self._write("\x1b[?2004h") self.last_bytes_at = time.time() def send_text(self, text): """Inject a prompt: bracketed-paste wrap for big/multiline input, a submit gap, then CR (Enter in both canonical and raw modes).""" self._pre_send() if self.manifest.get("pty.bracketed_paste") and (len(text) > 1024 or "\n" in text): self._write("\x1b[200~" + text + "\x1b[201~") else: self._write(text) time.sleep(CAP_PTY_SUBMIT_GAP_MS / 1000.0) self._write("\r") self.prompt_sent = True self.submit_key = text.split("\n", 1)[0][:60] self.resubmits = 0 self.open_asks.clear() self.seen_line_events.clear() self.msg_seq += 1 self.turn_msg_id = "msg_%d" % self.msg_seq self.state = "working" def _answer_open(self, ask_id, kind, value): """Booleans/yes-no words become y/n for yes-no prompts, anything else is typed literally.""" if kind == "yes_no": self._write("y" if str(value).strip().lower() in ("y", "yes", "true", "1") else "n") time.sleep(CAP_PTY_SUBMIT_GAP_MS / 1000.0) self._write("\r") else: self.send_text(str(value)) return self.state = "working" def cancel(self, scope="current_turn"): if self.proc is None: raise CapError("cap_binding_unavailable", "session never started") if scope == "session": self.close() return mode = self.cancel_mode if mode == "none": raise CapError("cap_cancel_unsupported", "manifest says mid_turn_cancel is none") if mode == "graceful": self._write("\x03") else: self._terminate() self.state = "done" def resize(self, rows, cols): if self.proc is not None: try: self.proc.resize(rows, cols) except OSError: pass self.screen.resize(rows, cols) self.prev_rows = [""] * self.screen.rows def close(self): if self.proc is not None: try: self._terminate() finally: self.proc.close() self.proc = None if self.state != "failed": self.state = "closed" def poll(self, timeout=0.0): """Read once, parse, run the settle checks. Returns new events.""" if self.proc is None or self.state in ("failed", "closed"): return self.drain() try: chunk = self.proc.read(timeout) except OSError: chunk = None now = time.time() if chunk is None: self._on_eof() return self.drain() if chunk: self.last_bytes_at = now if self.raw_sink is not None: self.raw_sink(CapEvent.make("cap.pty.raw_bytes", bytes_b64=base64.b64encode(chunk).decode("ascii"))) self.screen.feed(chunk) replies = self.screen.take_replies() if replies: try: self.proc.write_bytes(replies) except OSError: pass self._parse_screen() self._check_settle(now) return self.drain() def _write(self, text): try: self.proc.write(text) except OSError as exc: raise CapError("cap_binding_unavailable", "pty write failed: %s" % short_error(exc)) def _terminate(self): try: os.killpg(self.proc.proc.pid, signal.SIGTERM) except (OSError, ProcessLookupError, AttributeError): try: self.proc.kill() except (OSError, ProcessLookupError): pass return deadline = time.time() + 5 while time.time() < deadline: if self.proc.proc.poll() is not None: return time.sleep(0.05) try: self.proc.kill() except (OSError, ProcessLookupError): pass def _on_eof(self): if self.state == "done": self.state = "closed" return if self.state not in ("failed",): self._emit(CapEvent.make("cap.error", code="pty_died", message="agent process exited unexpectedly", retryable=False, details={})) self.state = "failed" def _parse_screen(self): rows = self.screen.text_lines() prev = set(self.prev_rows) self.prev_rows = rows fresh = [text for text in rows if text and text not in prev] for text in fresh: self._match_line(text) spoken = [text for text in fresh if not self._is_idle(text)] if spoken: self._emit(CapEvent.make("cap.text_chunk", message_id=self.turn_msg_id, text="\n".join(spoken), channel="assistant")) def _is_idle(self, text): return any(item.search(text) for item in self.idle) def _match_line(self, text): for regex in self.error_res: if regex.search(text): lowered = text.lower() if "rate_limit" in lowered or "rate limit" in lowered: code = "rate_limit" elif "auth" in lowered or "login" in lowered: code = "auth_error" else: code = "error" self._emit(CapEvent.make("cap.error", code=code, message=text[:500], retryable=code == "rate_limit", details={})) return if self.tool_end is not None: match = self.tool_end.search(text) if match is not None and self._fresh("tool_end", text): groups = match.groupdict() status = groups.get("status", "") failed = status == "failed" if self.open_tool is not None: call_id = self.open_tool[0] self.open_tool = None else: self.call_seq += 1 call_id = "c%d" % self.call_seq self._emit(CapEvent.make("cap.tool_call.end", call_id=call_id, output="", is_error=failed, duration_ms=0)) return if self.tool_start is not None: match = self.tool_start.search(text) if match is not None and self._fresh("tool_start", text): groups = match.groupdict() name = groups.get("tool") or "tool" raw_args = groups.get("args") or "" try: args = json.loads(raw_args) if raw_args else {} except ValueError: args = {"raw": raw_args} if raw_args else {} if not isinstance(args, dict): args = {"raw": raw_args} self.call_seq += 1 call_id = "c%d" % self.call_seq self.open_tool = (call_id, name) self._emit(CapEvent.make("cap.tool_call.start", call_id=call_id, name=name, input=args)) return if self.ask_yes_no is not None and self.ask_yes_no.search(text) and self._fresh("ask", text): self._raise_ask("yes_no", text, {"type": "string", "oneOf": [ {"const": "yes", "title": "Yes"}, {"const": "no", "title": "No"}]}) return if self.ask_options is not None and self.ask_options.search(text) and self._fresh("ask", text): self._raise_ask("options", text, {"type": "string"}) return if self.plan_mark is not None and self.plan_mark.search(text) and self._fresh("plan", text): self.plan_seq += 1 self._emit(CapEvent.make("cap.plan", entries=[ {"id": "p%d" % self.plan_seq, "content": text[:500], "status": "in_progress"}])) return if self.thought_mark is not None and self.thought_mark.search(text) and self._fresh("thought", text): self._emit(CapEvent.make("cap.thought", message_id=self.turn_msg_id, text=text[:2000])) def _fresh(self, group, text): key = (group, text) if key in self.seen_line_events: return False self.seen_line_events.add(key) return True def _raise_ask(self, kind, prompt, form): self.ask_seq += 1 ask_id = "ask_%d" % self.ask_seq self.open_asks[ask_id] = kind self._emit(CapEvent.make("cap.ask_user", ask_id=ask_id, prompt=prompt[:2000], form=form, timeout_seconds=None)) self.state = "input_required" def _check_settle(self, now): silent_ms = (now - self.last_bytes_at) * 1000.0 rows = self.screen.text_lines() if self.state == "starting": if self.ready_re is not None and silent_ms >= CAP_PTY_IDLE_MS: if any(self.ready_re.search(text) for text in rows if text): self.ready_at = now self.state = "ready" self._emit(CapEvent.make("cap.session.ready", version=CAP_VERSION, session_id=self.urn)) self.seen_line_events.clear() return if self.state != "working" or not self.prompt_sent: return if silent_ms < CAP_PTY_SETTLE_MS: return rest = [text for text in rows if text] if not rest or not self._is_idle(rest[-1]): return if self.submit_key and any(self.submit_key in text for text in rows): if self.resubmits < CAP_PTY_RESUBMITS_MAX: self.resubmits += 1 self.last_bytes_at = now try: self.proc.write("\r") except OSError: pass return self.state = "done" def last_text(self): rest = [text for text in self.screen.text_lines() if text] return "\n".join(rest) CAP_STREAM_LINE_MAX = 1048576 CAP_STREAM_STDERR_KEEP = 50 class CapPipeReader: """Non-blocking NDJSON line pump over one fd, shared by the stream-json and ACP-stdio drivers. Overlong lines are dropped loudly via the dropped counter.""" def __init__(self, fd): self.fd = fd self.buf = bytearray() self.eof = False self.dropped = 0 def read_lines(self, timeout=0.0): if select is not None and not self.eof: try: ready, _, _ = select.select([self.fd], [], [], max(0.0, timeout)) except (OSError, ValueError): ready = [self.fd] if not ready: return self._split_lines() while not self.eof: try: chunk = os.read(self.fd, 65536) except BlockingIOError: break except OSError: self.eof = True break if not chunk: self.eof = True break self.buf += chunk if len(self.buf) > CAP_STREAM_LINE_MAX and b"\n" not in self.buf: self.buf = bytearray() self.dropped += 1 break return self._split_lines() def _split_lines(self): lines = [] while True: mark = self.buf.find(b"\n") if mark < 0: if len(self.buf) > CAP_STREAM_LINE_MAX: self.buf = bytearray() self.dropped += 1 break raw = bytes(self.buf[:mark]).decode("utf-8", "replace") del self.buf[:mark + 1] if raw.strip(): lines.append(raw) return lines def take_dropped(self): count, self.dropped = self.dropped, 0 return count def cap_proc_kill(proc): try: proc.terminate() try: proc.wait(timeout=5) except subprocess.TimeoutExpired: proc.kill() except OSError: pass def cap_proc_reap(proc, stderr_thread=None): cap_proc_kill(proc) for pipe in (proc.stdin, proc.stdout, proc.stderr): try: if pipe is not None: pipe.close() except OSError: pass if stderr_thread is not None: stderr_thread.join(timeout=5) def cap_stderr_drain(proc, keep=CAP_STREAM_STDERR_KEEP): lines = collections.deque(maxlen=keep) def _drain(): try: for line in proc.stderr: lines.append(line.decode("utf-8", "replace").rstrip()[:500]) except (OSError, ValueError): pass thread = threading.Thread(target=_drain, daemon=True) thread.start() return lines, thread class CapStreamDriver(CapDriver): """Orchestrator-side stream-json binding (spec §6.2, C.1): NDJSON over pipes, frames translated to core events. Cancelling a turn kills the one-shot process; the next send respawns lazily. The only thread is the stderr drain; stdout is pumped via poll().""" def __init__(self, manifest, urn="cap://agent-1", cwd=None, model=None, env=None): super().__init__(manifest, urn=urn, cwd=cwd, model=model, env=env) self.stderr_lines = collections.deque(maxlen=CAP_STREAM_STDERR_KEEP) self.ignored_frames = 0 self.turn_msg_id = "msg_boot" self.reader = None self._stderr_thread = None def _spawn(self): argv = self.manifest.get("fast_path.stream_json") if not argv: raise CapError("cap_binding_unavailable", "manifest has no fast_path.stream_json") try: self.proc = subprocess.Popen(list(self._model_argv(list(argv))), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=self.cwd, env=self._session_env()) except OSError as exc: raise CapError("cap_binding_unavailable", "cannot spawn %s: %s" % (argv[0], short_error(exc))) try: os.set_blocking(self.proc.stdout.fileno(), False) except OSError: pass self.reader = CapPipeReader(self.proc.stdout.fileno()) self.stderr_lines, self._stderr_thread = cap_stderr_drain(self.proc) def send_text(self, text): self._pre_send() line = (json.dumps({"type": "user", "message": text}) + "\n").encode("utf-8") try: self.proc.stdin.write(line) self.proc.stdin.flush() except (OSError, ValueError) as exc: raise CapError("cap_binding_unavailable", "agent input failed: %s" % short_error(exc)) self.msg_seq += 1 self.turn_msg_id = "msg_%d" % self.msg_seq self.open_asks.clear() self.state = "working" def poll(self, timeout=0.0): if self.proc is None or self.state in ("failed", "closed"): return self.drain() for line in self.reader.read_lines(timeout): self._map_frame(line) if self.reader.take_dropped(): self._emit(CapEvent.make("cap.error", code="parse_failed", message="line over %d bytes dropped" % CAP_STREAM_LINE_MAX, retryable=False, details={})) if self.reader.eof and not self.reader.buf and self.proc.poll() is not None: self._on_proc_gone() return self.drain() def cancel(self, scope="current_turn"): if self.proc is None: raise CapError("cap_binding_unavailable", "session never started") if scope == "session": self.close() return if self.cancel_mode == "none": raise CapError("cap_cancel_unsupported", "manifest says mid_turn_cancel is none") cap_proc_kill(self.proc) self.proc = None self.reader = None self.state = "starting" def close(self): if self.proc is not None: proc, self.proc = self.proc, None cap_proc_reap(proc, self._stderr_thread) self._stderr_thread = None self.reader = None if self.state != "failed": self.state = "closed" def _map_frame(self, line): try: frame = json.loads(line) except ValueError: self.ignored_frames += 1 return if not isinstance(frame, dict): self.ignored_frames += 1 return ftype = frame.get("type") if ftype == "system" and frame.get("subtype") == "init": if self.state == "starting": self.state = "ready" self._emit(CapEvent.make("cap.session.ready", version=CAP_VERSION, session_id=frame.get("session_id") or self.urn)) return if ftype == "assistant": self._map_assistant(frame) return if ftype == "user": self._map_tool_results(frame) return if ftype == "result": self._map_result(frame) return if ftype == "artifact" and isinstance(frame.get("artifact"), dict): try: artifact = frame["artifact"] self._emit(CapEvent.make("cap.artifact", artifact_id=artifact["artifact_id"], mime_type=artifact["mime_type"], name=artifact.get("name", ""), size=int(artifact.get("size", 0)), uri=artifact.get("uri"), content=artifact.get("content"))) except (KeyError, ValueError, TypeError): self.ignored_frames += 1 return self.ignored_frames += 1 def _map_assistant(self, frame): message = frame.get("message") or {} msg_id = message.get("id") or self.turn_msg_id for block in message.get("content") or []: if not isinstance(block, dict): continue btype = block.get("type") if btype == "text" and block.get("text"): self._emit(CapEvent.make("cap.text_chunk", message_id=msg_id, text=block["text"], channel="assistant")) elif btype == "thinking" and block.get("text"): self._emit(CapEvent.make("cap.thought", message_id=msg_id, text=block["text"])) elif btype == "tool_use": payload = block.get("input") if not isinstance(payload, dict): payload = {"raw": payload} self.call_seq += 1 self._emit(CapEvent.make("cap.tool_call.start", call_id=block.get("id") or "c%d" % self.call_seq, name=block.get("name") or "tool", input=payload)) def _map_tool_results(self, frame): message = frame.get("message") or {} blocks = message.get("content") or [] if isinstance(message, str): return for block in blocks: if not isinstance(block, dict) or block.get("type") != "tool_result": continue content = block.get("content") if not isinstance(content, str): try: content = json.dumps(content) except (ValueError, TypeError): content = str(content) self._emit(CapEvent.make("cap.tool_call.end", call_id=block.get("tool_use_id") or "", output=content or "", is_error=bool(block.get("is_error", False)), duration_ms=0)) def _map_result(self, frame): if frame.get("subtype") == "error": self._emit(CapEvent.make("cap.error", code="error", message=str(frame.get("error") or "agent failed")[:2000], retryable=False, details={})) self.state = "done" return usage = frame.get("usage") or {} try: out_tokens = int(usage.get("output_tokens", usage.get("total_tokens", 0)) or 0) in_tokens = int(usage.get("input_tokens", 0) or 0) except (ValueError, TypeError): out_tokens, in_tokens = 0, 0 event = CapEvent.make("cap.usage", model_id=self.model or self.manifest.name, input_tokens=in_tokens, output_tokens=out_tokens, duration_ms=int(frame.get("duration_ms", 0) or 0), stop_reason="end_turn") if frame.get("total_cost_usd") is not None: try: event["cost_usd_estimate"] = float(frame["total_cost_usd"]) except (ValueError, TypeError): pass self._emit(event) self.state = "done" def _on_proc_gone(self): if self.state == "done": self.state = "closed" return if self.state != "failed": hint = "; stderr: %s" % " | ".join(self.stderr_lines) if self.stderr_lines else "" self._emit(CapEvent.make("cap.error", code="error", message=("agent process exited unexpectedly%s" % hint)[:2000], retryable=False, details={})) self.state = "failed" CAP_ACP_VERSION = 1 CAP_ACP_TERMINALS_MAX = 4 CAP_ACP_TERM_BUF = 1048576 CAP_ACP_TERM_WAIT_MAX = 600 CAP_ACP_FS_MAX = 1048576 _ACP_DEFERRED = object() class CapAcpDriver(CapDriver): """Orchestrator-side ACP-stdio binding (spec §6.4, C.3): JSON-RPC over pipes, poll-pumped like the stream driver (no reader thread). Serves fs/terminal/elicitation plus tai `_cap/*` extensions for fs.list/exists/delete and user_io/notify. Answers flow through the unified open_asks stash keyed by cap id.""" def __init__(self, manifest, urn="cap://agent-1", cwd=None, model=None, env=None, acp_argv=None, fs_scope="workspace_only", terminal=True, io_sink=None): super().__init__(manifest, urn=urn, cwd=cwd, model=model, env=env) self.acp_argv = list(acp_argv or []) self.fs_scope = fs_scope self.grant_terminal = terminal self.io_sink = io_sink self.io_log = [] self.stderr_lines = collections.deque(maxlen=CAP_STREAM_STDERR_KEEP) self.ignored_frames = 0 self.reader = None self._stderr_thread = None self._next_id = 0 self._pending = {} self._prompt_id = None self.acp_session = None self.turn_msg_id = "msg_boot" self._last_in = 0 self._last_out = 0 self.terminals = {} self.term_seq = 0 def _spawn(self): if not self.acp_argv: raise CapError("cap_binding_unavailable", "acp_stdio needs an explicit server argv (fleet argv or chat --argv)") try: self.proc = subprocess.Popen(list(self.acp_argv), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=self.cwd, env=self._session_env()) except OSError as exc: raise CapError("cap_binding_unavailable", "cannot spawn %s: %s" % (self.acp_argv[0], short_error(exc))) try: os.set_blocking(self.proc.stdout.fileno(), False) except OSError: pass self.reader = CapPipeReader(self.proc.stdout.fileno()) self.stderr_lines, self._stderr_thread = cap_stderr_drain(self.proc) try: self._handshake() except Exception: cap_proc_reap(self.proc, self._stderr_thread) self.proc = None self.reader = None self._stderr_thread = None raise def _handshake(self): caps = {} if self.fs_scope: caps["fs"] = {"readTextFile": True, "writeTextFile": True} if self.grant_terminal: caps["terminal"] = True caps["elicitation"] = {"form": {}} init_id = self._request("initialize", {"protocolVersion": CAP_ACP_VERSION, "clientCapabilities": caps, "clientInfo": {"name": "tai", "version": VERSION}}) init = self._wait_response(init_id, "initialize") if not isinstance(init, dict) or init.get("protocolVersion") != CAP_ACP_VERSION: raise CapError("cap_binding_unavailable", "agent speaks ACP v%s, tai speaks v1" % ( init.get("protocolVersion") if isinstance(init, dict) else "?")) new_id = self._request("session/new", {"cwd": self.cwd, "mcpServers": []}) created = self._wait_response(new_id, "session/new") if not isinstance(created, dict) or not created.get("sessionId"): raise CapError("cap_binding_unavailable", "agent created no session") self.acp_session = created["sessionId"] self.state = "ready" self._emit(CapEvent.make("cap.session.ready", version=CAP_VERSION, session_id=self.urn)) def _wait_response(self, rpc_id, what): deadline = time.time() + max(1, self.init_timeout) while time.time() < deadline: for line in self.reader.read_lines(0.1): self._map_message(line) if rpc_id in self._pending and self._pending[rpc_id].get("done"): entry = self._pending.pop(rpc_id) if entry.get("error") is not None: raise CapError("cap_binding_unavailable", "%s failed: %s" % (what, entry["error"])) return entry.get("result") if self.state == "failed": raise CapError("cap_binding_unavailable", "agent died during %s" % what) raise CapError("cap_binding_unavailable", "%s timed out" % what) def _request(self, method, params): self._next_id += 1 rpc_id = self._next_id self._pending[rpc_id] = {"method": method} self._write({"jsonrpc": "2.0", "id": rpc_id, "method": method, "params": params}) return rpc_id def _notify(self, method, params): self._write({"jsonrpc": "2.0", "method": method, "params": params}) def _write(self, frame): try: self.proc.stdin.write((json.dumps(frame) + "\n").encode("utf-8")) self.proc.stdin.flush() except (OSError, ValueError) as exc: raise CapError("cap_binding_unavailable", "agent input failed: %s" % short_error(exc)) def _respond(self, rpc_id, result=None, error=None): frame = {"jsonrpc": "2.0", "id": rpc_id} if error is None: frame["result"] = result if result is not None else {} else: frame["error"] = error try: self._write(frame) except CapError: pass @staticmethod def _rpc_error(code, message): return {"code": code, "message": str(message)[:500]} def send_text(self, text): self._pre_send() self.msg_seq += 1 self.turn_msg_id = "msg_%d" % self.msg_seq self.open_asks.clear() self._prompt_id = self._request("session/prompt", {"sessionId": self.acp_session, "prompt": [{"type": "text", "text": text}]}) self._pending[self._prompt_id]["prompt"] = True self.state = "working" def poll(self, timeout=0.0): if self.proc is None or self.state in ("failed", "closed"): return self.drain() for line in self.reader.read_lines(timeout): self._map_message(line) if self.reader.take_dropped(): self._emit(CapEvent.make("cap.error", code="parse_failed", message="line over %d bytes dropped" % CAP_STREAM_LINE_MAX, retryable=False, details={})) if self.reader.eof and not self.reader.buf and self.proc.poll() is not None: self._on_proc_gone() return self.drain() def cancel(self, scope="current_turn"): if self.proc is None: raise CapError("cap_binding_unavailable", "session never started") if scope == "session": self.close() return if self.cancel_mode == "none": raise CapError("cap_cancel_unsupported", "manifest says mid_turn_cancel is none") if self.acp_session is not None: try: self._request("session/cancel", {"sessionId": self.acp_session}) except CapError: pass if self._prompt_id is not None: try: self._notify("$/cancel_request", {"id": self._prompt_id}) except CapError: pass for ask_id, (kind, rpc_id, _extra) in list(self.open_asks.items()): if kind == "permission": self._respond(rpc_id, {"outcome": {"outcome": "cancelled"}}) self.open_asks.pop(ask_id, None) def close(self): if self.proc is not None: for tid in list(self.terminals): self._terminal_drop(tid) proc, self.proc = self.proc, None cap_proc_reap(proc, self._stderr_thread) self._stderr_thread = None self.reader = None if self.state != "failed": self.state = "closed" def _on_proc_gone(self): if self.state == "done": self.state = "closed" return if self.state != "failed": hint = "; stderr: %s" % " | ".join(self.stderr_lines) if self.stderr_lines else "" self._emit(CapEvent.make("cap.error", code="error", message=("agent process exited unexpectedly%s" % hint)[:2000], retryable=False, details={})) self.state = "failed" def _map_message(self, line): try: frame = json.loads(line) except ValueError: self.ignored_frames += 1 return if not isinstance(frame, dict): self.ignored_frames += 1 return if "method" in frame: self._map_incoming(frame) elif "id" in frame: self._map_response(frame) else: self.ignored_frames += 1 def _map_response(self, frame): rpc_id = frame.get("id") entry = self._pending.get(rpc_id) if entry is None: self.ignored_frames += 1 return entry["done"] = True if "error" in frame: entry["error"] = frame["error"] else: entry["result"] = frame.get("result") if entry.get("prompt"): self._prompt_id = None if "error" in frame: self._emit(CapEvent.make("cap.error", code="error", message="prompt failed: %s" % str(frame["error"])[:1000], retryable=False, details={})) self.state = "done" def _map_incoming(self, frame): method = frame.get("method") params = frame.get("params") or {} rpc_id = frame.get("id") if method == "session/update" and rpc_id is None: self._map_update(params.get("update") or {}) return if rpc_id is None: return handler = {"session/request_permission": self._serve_permission, "elicitation/create": self._serve_elicit, "fs/read_text_file": self._serve_fs_read, "fs/write_text_file": self._serve_fs_write, "terminal/create": self._serve_term_create, "terminal/output": self._serve_term_output, "terminal/wait_for_exit": self._serve_term_wait, "terminal/kill": self._serve_term_kill, "terminal/release": self._serve_term_release, "_cap/fs.list": self._serve_fs_list, "_cap/fs.exists": self._serve_fs_exists, "_cap/fs.delete": self._serve_fs_delete, "_cap/user_io.show": self._serve_io_show, "_cap/user_io.input": self._serve_io_input, "_cap/notify": self._serve_notify}.get(method) if handler is None: self._respond(rpc_id, error=self._rpc_error(-32601, "unknown method %s" % method)) return try: result = handler(params, rpc_id) except CapError as exc: self._respond(rpc_id, error=self._rpc_error(exc.numeric or -32603, str(exc))) return if result is not _ACP_DEFERRED: self._respond(rpc_id, result=result) def _map_update(self, update): kind = update.get("sessionUpdate") if kind == "agent_message_chunk": content = update.get("content") or {} if isinstance(content, dict) and content.get("type") == "text" and content.get("text"): self._emit(CapEvent.make("cap.text_chunk", message_id=self.turn_msg_id, text=content["text"], channel="assistant")) else: self.ignored_frames += 1 elif kind == "agent_thought_chunk": content = update.get("content") or {} text = content.get("text") if isinstance(content, dict) else None if text: self._emit(CapEvent.make("cap.thought", message_id=self.turn_msg_id, text=text)) else: self.ignored_frames += 1 elif kind == "tool_call": self._emit(CapEvent.make("cap.tool_call.start", call_id=str(update.get("toolCallId") or "c%d" % (self.call_seq + 1)), name=update.get("title") or update.get("kind") or "tool", input={})) elif kind == "tool_call_update": status = update.get("status") or "" text = self._update_text(update.get("content")) if status in ("completed", "failed"): self._emit(CapEvent.make("cap.tool_call.end", call_id=str(update.get("toolCallId") or ""), output=text, is_error=status == "failed", duration_ms=0)) else: self._emit(CapEvent.make("cap.tool_call.delta", call_id=str(update.get("toolCallId") or ""), output_chunk=text)) elif kind == "plan": entries = [] for nth, entry in enumerate(update.get("entries") or []): if not isinstance(entry, dict): continue status = entry.get("status") or "pending" entries.append({"id": str(entry.get("id") or "p%d" % nth), "content": str(entry.get("content") or "")[:2000], "status": status if status in ("pending", "in_progress", "completed") else "pending"}) if entries: self._emit(CapEvent.make("cap.plan", entries=entries)) else: self.ignored_frames += 1 elif kind == "usage_update": usage = update.get("usage") or update try: total_in = int(usage.get("inputTokens", usage.get("input_tokens", 0)) or 0) total_out = int(usage.get("outputTokens", usage.get("output_tokens", usage.get("total_tokens", 0))) or 0) except (ValueError, TypeError): self.ignored_frames += 1 return self._emit(CapEvent.make("cap.usage", model_id=self.model or self.manifest.name, input_tokens=max(0, total_in - self._last_in), output_tokens=max(0, total_out - self._last_out))) self._last_in, self._last_out = total_in, total_out else: self.ignored_frames += 1 @staticmethod def _update_text(content): if isinstance(content, str): return content[:2000] parts = [] for block in content or []: if isinstance(block, dict) and block.get("type") == "text" and block.get("text"): parts.append(block["text"]) return "\n".join(parts)[:2000] def _serve_permission(self, params, rpc_id): call = params.get("toolCall") or {} options = params.get("options") or [] kind = str(call.get("kind") or "").lower() if kind in ("delete", "execute"): risk = "high" elif kind in ("edit", "write"): risk = "medium" else: risk = "low" self.ask_seq += 1 req_id = "perm_%d" % self.ask_seq self.open_asks[req_id] = ("permission", rpc_id, options) self._emit(CapEvent.make("cap.permission.request", req_id=req_id, tool=call.get("title") or call.get("kind") or "tool", intent={"toolCallId": call.get("toolCallId"), "kind": call.get("kind"), "content": str(call.get("content") or "")[:1000]}, scope="execute" if kind == "execute" else "write" if kind in ("edit", "write", "delete") else "read", risk_level=risk)) self.state = "input_required" return _ACP_DEFERRED def _serve_elicit(self, params, rpc_id): if params.get("mode") != "form": return {"action": "decline"} schema = params.get("requestedSchema") or {"type": "string"} self.ask_seq += 1 ask_id = "ask_%d" % self.ask_seq self.open_asks[ask_id] = ("elicit", rpc_id, schema) self.state = "input_required" self._emit(CapEvent.make("cap.ask_user", ask_id=ask_id, prompt=str(params.get("message") or "input needed")[:2000], form=schema)) return _ACP_DEFERRED def _serve_io_show(self, params, _rpc_id): self._io_log("show", params) return {} def _serve_notify(self, params, _rpc_id): self._io_log("notify", params) return {} def _serve_io_input(self, params, rpc_id): self.ask_seq += 1 ask_id = "ask_%d" % self.ask_seq self.open_asks[ask_id] = ("input", rpc_id, params) self.state = "input_required" self._emit(CapEvent.make("cap.ask_user", ask_id=ask_id, prompt=str(params.get("prompt") or "input needed")[:2000], form={"type": "string"})) return _ACP_DEFERRED def _io_log(self, kind, params): entry = {"kind": kind, "params": params} self.io_log.append(entry) if self.io_sink is not None: try: self.io_sink(entry) except Exception: pass def _answer_open(self, ask_id, kind, value): kind_name, rpc_id, extra = kind if kind_name == "permission": self._respond(rpc_id, self._permission_outcome(value, extra)) elif kind_name == "elicit": if value is False or value is None or value == "decline": self._respond(rpc_id, {"action": "decline"}) elif value == "cancel": self._respond(rpc_id, {"action": "cancel"}) else: self._respond(rpc_id, {"action": "accept", "content": value}) elif kind_name == "input": self._respond(rpc_id, {"value": value}) else: raise CapError("cap_invalid_answer", "unknown question kind") if not self.open_asks and self.state == "input_required": self.state = "working" @staticmethod def _permission_outcome(value, options): by_id = {opt.get("optionId"): opt for opt in options if isinstance(opt, dict)} if value in by_id: return {"outcome": {"outcome": "selected", "optionId": value}} kinds = {opt.get("optionId"): str(opt.get("kind") or "") for opt in options if isinstance(opt, dict)} pick = None if value == "deny": pick = next((oid for oid, k in kinds.items() if k.startswith("reject")), None) elif value in ("allow_once", "allow_always"): pick = next((oid for oid, k in kinds.items() if k == value), None) if pick is None: pick = next((oid for oid, k in kinds.items() if k.startswith("allow")), None) if pick is not None: return {"outcome": {"outcome": "selected", "optionId": pick}} return {"outcome": {"outcome": "cancelled"}} def _fs_root(self): if self.fs_scope == "unrestricted": return None if self.fs_scope == "project_root": return self._vcs_root() if self.fs_scope == "workspace_only": return os.path.realpath(self.cwd) return False def _vcs_root(self): node = os.path.realpath(self.cwd) while True: if os.path.isdir(os.path.join(node, ".git")): return node parent = os.path.dirname(node) if parent == node: return os.path.realpath(self.cwd) node = parent def _fs_check(self, path): root = self._fs_root() if root is False: raise CapError("cap_scope_violation", "file access is not granted") target = os.path.realpath(path if os.path.isabs(path) else os.path.join(self.cwd, path)) if root is not None and target != root and not target.startswith(root + os.sep): raise CapError("cap_scope_violation", "path escapes the granted scope") return target def _serve_fs_read(self, params, _rpc_id): target = self._fs_check(params.get("path") or "") try: with open(target, "r", encoding="utf-8") as handle: lines = handle.read().splitlines(True) except FileNotFoundError: raise CapError("cap_path_not_found", "no such file") except (OSError, ValueError) as exc: raise CapError("cap_io_error", short_error(exc)) line = params.get("line") or 0 limit = params.get("limit") or 0 try: start = max(int(line) - 1, 0) if line else 0 picked = lines[start:start + int(limit)] if limit else lines[start:] except (ValueError, TypeError): raise CapError("cap_io_error", "bad line window") return {"content": "".join(picked)[:CAP_ACP_FS_MAX]} def _serve_fs_write(self, params, _rpc_id): target = self._fs_check(params.get("path") or "") try: os.makedirs(os.path.dirname(target) or self.cwd, exist_ok=True) with open(target, "w", encoding="utf-8") as handle: handle.write(params.get("content") or "") except OSError as exc: raise CapError("cap_io_error", short_error(exc)) return {} def _serve_fs_list(self, params, _rpc_id): target = self._fs_check(params.get("path") or "") try: names = sorted(os.listdir(target)) except FileNotFoundError: raise CapError("cap_path_not_found", "no such directory") except OSError as exc: raise CapError("cap_io_error", short_error(exc)) return {"entries": [{"name": name, "is_dir": os.path.isdir(os.path.join(target, name))} for name in names[:1000]]} def _serve_fs_exists(self, params, _rpc_id): try: target = self._fs_check(params.get("path") or "") except CapError: return {"exists": False} return {"exists": os.path.exists(target)} def _serve_fs_delete(self, params, _rpc_id): target = self._fs_check(params.get("path") or "") if not os.path.lexists(target): raise CapError("cap_path_not_found", "no such path") try: if os.path.isdir(target) and not os.path.islink(target): if params.get("recursive"): shutil.rmtree(target) else: os.rmdir(target) else: os.remove(target) except OSError as exc: raise CapError("cap_io_error", short_error(exc)) return {} def _serve_term_create(self, params, _rpc_id): if not self.grant_terminal: raise CapError("cap_terminal_unavailable", "terminals are not granted") if len(self.terminals) >= CAP_ACP_TERMINALS_MAX: raise CapError("cap_terminal_unavailable", "too many terminals") command = params.get("command") or "" if not command: raise CapError("cap_io_error", "no command") cwd = params.get("cwd") or self.cwd if not os.path.isabs(cwd): raise CapError("cap_io_error", "cwd must be absolute") env = dict(os.environ) for item in params.get("env") or []: if isinstance(item, dict) and item.get("name"): env[item["name"]] = item.get("value") or "" try: limit = int(params.get("outputByteLimit") or CAP_ACP_TERM_BUF) except (ValueError, TypeError): limit = CAP_ACP_TERM_BUF limit = max(1024, min(limit, CAP_ACP_TERM_BUF * 4)) try: proc = subprocess.Popen([command] + list(params.get("args") or []), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=cwd, env=env) except OSError as exc: raise CapError("cap_terminal_unavailable", short_error(exc)) self.term_seq += 1 tid = "term_%d" % self.term_seq term = {"proc": proc, "buf": bytearray(), "limit": limit, "truncated": False, "lock": threading.Lock(), "killed": None} self.terminals[tid] = term thread = threading.Thread(target=self._term_pump, args=(tid, proc, term), daemon=True) thread.start() return {"terminalId": tid} def _term_pump(self, tid, proc, term): try: while True: chunk = proc.stdout.read(65536) if not chunk: break with term["lock"]: term["buf"] += chunk if len(term["buf"]) > term["limit"]: del term["buf"][:len(term["buf"]) - term["limit"]] while term["buf"][:1] and term["buf"][0] & 0xC0 == 0x80: del term["buf"][:1] term["truncated"] = True except (OSError, ValueError): pass def _term_get(self, tid): term = self.terminals.get(tid) if term is None: raise CapError("cap_terminal_unavailable", "no such terminal") return term @staticmethod def _term_status(term): code = term["proc"].poll() if code is None: return None name = None if code < 0: try: name = signal.Signals(-code).name except ValueError: name = str(-code) elif term["killed"]: name = term["killed"] return {"exitCode": code if code >= 0 else None, "signal": name} def _serve_term_output(self, params, _rpc_id): term = self._term_get(params.get("terminalId") or "") with term["lock"]: output = bytes(term["buf"]).decode("utf-8", "replace") truncated = term["truncated"] result = {"output": output, "truncated": truncated} status = self._term_status(term) if status is not None: result["exitStatus"] = status return result def _serve_term_wait(self, params, _rpc_id): term = self._term_get(params.get("terminalId") or "") deadline = time.time() + CAP_ACP_TERM_WAIT_MAX while time.time() < deadline: status = self._term_status(term) if status is not None: return {"exitCode": status["exitCode"], "signal": status["signal"]} time.sleep(0.05) return {"exitCode": None, "signal": None} def _serve_term_kill(self, params, _rpc_id): term = self._term_get(params.get("terminalId") or "") term["killed"] = "SIGTERM" cap_proc_kill(term["proc"]) return {} def _serve_term_release(self, params, _rpc_id): self._terminal_drop(params.get("terminalId") or "") return {} def _terminal_drop(self, tid): term = self.terminals.pop(tid, None) if term is None: raise CapError("cap_terminal_unavailable", "no such terminal") if term["proc"].poll() is None: term["killed"] = "SIGTERM" cap_proc_kill(term["proc"]) CAP_A2A_TIMEOUT = 30 CAP_ANSWER_TIMEOUT = 300 CAP_ANSWER_PARALLEL = 4 CAP_ANSWER_MARKER = "ANSWER:" CAP_ANSWER_RISKS = ("low", "medium", "high") class CapAnswerer: """Answers subagent questions so no turn stalls: permissions by policy (unknown tools researched), asks by an isolated research subprocess (a tai one-shot in its own vault). Everything bounded, every failure falls back to the safe answer.""" def __init__(self, allow_tools=(), deny_tools=(), max_risk="medium", research_home=None, timeout=CAP_ANSWER_TIMEOUT, tai_binary=None): self.allow_tools = set(allow_tools or ()) self.deny_tools = set(deny_tools or ()) self.max_risk = max_risk if max_risk in CAP_ANSWER_RISKS else "medium" home = os.environ.get("TAI_HOME") or os.path.join(os.path.expanduser("~"), ".tai") self.research_home = research_home or os.path.join(home, "cap-research") self.timeout = timeout self.tai_binary = tai_binary or sys.argv[0] self._slots = threading.Semaphore(CAP_ANSWER_PARALLEL) def handle(self, session, event): kind = event.get("kind") if kind == "cap.permission.request": session.answer(event["req_id"], self.decide_permission(session.urn, event)) elif kind == "cap.ask_user": session.answer(event["ask_id"], self.answer_ask(session.urn, event)) def decide_permission(self, urn, event): tool = event.get("tool") or "" if tool in self.deny_tools: return "deny" if tool in self.allow_tools: return "allow_once" risk = event.get("risk_level") or "medium" if CAP_ANSWER_RISKS.index(risk) > CAP_ANSWER_RISKS.index(self.max_risk): return "deny" verdict = self.research("Decide whether the supervised subagent %s may perform this action.\n" "Tool: %s\nIntent: %s\nRisk: %s\nReply with reasoning, then end with exactly one " "line: %s allow OR %s deny." % ( urn, tool, json.dumps(event.get("intent") or {})[:2000], risk, CAP_ANSWER_MARKER, CAP_ANSWER_MARKER)) if verdict is None: return "deny" return "allow_once" if verdict.strip().lower().startswith("allow") else "deny" def answer_ask(self, urn, event): form = event.get("form") or {} prompt = event.get("prompt") or "" boolean = self._is_yes_no(form) shape = "yes/no" if boolean else json.dumps(form)[:2000] verdict = self.research("Answer for the supervised subagent %s. Research with web_search and web_fetch, " "then end with exactly one line: %s <value>.\nQuestion: %s\nAnswer shape: %s" % ( urn, CAP_ANSWER_MARKER, prompt[:2000], shape)) if verdict is None: return False if boolean else "unknown (research failed)" if boolean: return self._parse_bool(verdict) return verdict[:2000] @staticmethod def _is_yes_no(form): if not isinstance(form, dict): return False consts = set() for option in form.get("oneOf") or []: if isinstance(option, dict) and "const" in option: consts.add(str(option["const"]).lower()) return consts == {"yes", "no"} @staticmethod def _parse_bool(text): lowered = (text or "").strip().lower() first = lowered.split(None, 1)[0] if lowered.split(None, 1) else "" if first in ("y", "yes", "true", "1"): return True if first in ("n", "no", "false", "0"): return False if re.search(r"\byes\b", lowered): return True if re.search(r"\bno\b", lowered): return False return False def research(self, prompt): """Run one isolated research turn. Returns the marker value, or None when the turn failed, timed out, or never marked.""" with self._slots: env = dict(os.environ, TAI_HOME=self.research_home, TAI_CHILD="1") try: done = subprocess.run([sys.executable, self.tai_binary, "--auto", prompt], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=self.timeout, env=env) except (OSError, subprocess.SubprocessError): return None if done.returncode != 0: return None try: output = done.stdout.decode("utf-8", "replace") except UnicodeDecodeError: return None for line in reversed(output.splitlines()): if line.strip().upper().startswith(CAP_ANSWER_MARKER): return line.strip()[len(CAP_ANSWER_MARKER):].strip() return None class CapA2aDriver(CapDriver): """Orchestrator-side A2A binding (spec §6.5, §7.8, C.4): plain https? client over http.client. Tries draft endpoints first (what CAP peers speak), then current A2A spellings; the SSE reader is the one thread (blocking socket reads), everything else pumps through poll(). Answers travel back as data parts with the _meta.cap.kind marker, reference-literal.""" def __init__(self, manifest, urn="cap://agent-1", cwd=None, model=None, env=None): super().__init__(manifest, urn=urn, cwd=cwd, model=model, env=env) self.endpoint = (manifest.get("fast_path.a2a_serve_at") or "").rstrip("/") self.card_name = "" self.flavor = None self.ignored_frames = 0 self.turn_msg_id = "msg_boot" self._context_id = None self._task_id = None self._sse_lines = queue.Queue() self._sse_thread = None self._resp = None self._conn = None self._turn_events = 0 def _split_endpoint(self): parsed = urllib.parse.urlparse(self.endpoint) if parsed.scheme not in ("http", "https"): raise CapError("cap_binding_unavailable", "a2a endpoint must be http(s): %r" % self.endpoint) host = parsed.hostname or "" port = parsed.port or (443 if parsed.scheme == "https" else 80) return parsed.scheme, host, port, parsed.path.rstrip("/") def _request(self, method, path, body=None, accept_stream=False): scheme, host, port, prefix = self._split_endpoint() cls = http.client.HTTPSConnection if scheme == "https" else http.client.HTTPConnection conn = cls(host, port, timeout=CAP_A2A_TIMEOUT) headers = {"Content-Type": "application/json", "Accept": "text/event-stream, application/json" if accept_stream else "application/json"} payload = json.dumps(body) if body is not None else None try: conn.request(method, prefix + path, body=payload, headers=headers) return conn, conn.getresponse() except (OSError, http.client.HTTPException) as exc: try: conn.close() except OSError: pass raise CapError("cap_binding_unavailable", "a2a %s %s failed: %s" % (method, path, short_error(exc))) def _get_json(self, path): conn, resp = self._request("GET", path) try: if resp.status != 200: raise CapError("cap_binding_unavailable", "a2a GET %s returned %d" % (path, resp.status)) try: return json.loads(resp.read().decode("utf-8")) except ValueError as exc: raise CapError("cap_binding_unavailable", "a2a GET %s is not JSON: %s" % (path, exc)) finally: try: conn.close() except OSError: pass def _spawn(self): if not self.endpoint: raise CapError("cap_binding_unavailable", "manifest has no fast_path.a2a_serve_at") try: card = self._get_json("/.well-known/agent-card.json") except CapError: raise extensions = card.get("extensions") or [] if not any(isinstance(ext, dict) and ext.get("uri") == "cap-protocol/v1" for ext in extensions): raise CapError("cap_binding_unavailable", "A2A card does not advertise cap-protocol/v1") self.card_name = card.get("name") or "" self.proc = True self.state = "ready" self._emit(CapEvent.make("cap.session.ready", version=CAP_VERSION, session_id=self.urn)) def _message_body(self, parts): message = {"role": "user", "messageId": uuid.uuid4().hex, "parts": parts} body = {"message": message} if self._context_id: body["contextId"] = self._context_id message["contextId"] = self._context_id if self._task_id: body["taskId"] = self._task_id return body def send_text(self, text): self._pre_send() self.msg_seq += 1 self.turn_msg_id = "msg_%d" % self.msg_seq self.open_asks.clear() self.state = "working" self._post_message([{"kind": "text", "text": text}]) def _post_message(self, parts): body = self._message_body(parts) attempts = [self.flavor] if self.flavor else ["draft", "stream", "send"] last_error = None for flavor in attempts: try: if flavor == "draft": self._start_sse("/message/send", body) elif flavor == "stream": self._start_sse("/message:stream", body) else: self._post_blocking("/message:send", body) self.flavor = flavor return except CapError as exc: last_error = exc if self.flavor or " failed: " in str(exc): raise raise last_error def _start_sse(self, path, body): conn, resp = self._request("POST", path, body, accept_stream=True) if resp.status in (404, 405, 410): try: conn.close() except OSError: pass raise CapError("cap_binding_unavailable", "a2a POST %s returned %d" % (path, resp.status)) if resp.status != 200: try: conn.close() except OSError: pass raise CapError("cap_binding_unavailable", "a2a POST %s returned %d" % (path, resp.status)) self._stop_stream() self._conn, self._resp = conn, resp self._turn_events = 0 self._sse_thread = threading.Thread(target=self._sse_pump, args=(resp,), daemon=True) self._sse_thread.start() def _post_blocking(self, path, body): _conn, resp = self._request("POST", path, body) try: if resp.status in (404, 405, 410): raise CapError("cap_binding_unavailable", "a2a POST %s returned %d" % (path, resp.status)) if resp.status != 200: raise CapError("cap_binding_unavailable", "a2a POST %s returned %d" % (path, resp.status)) try: payload = json.loads(resp.read().decode("utf-8")) except ValueError as exc: raise CapError("cap_binding_unavailable", "a2a response is not JSON: %s" % exc) finally: try: _conn.close() except OSError: pass if isinstance(payload, dict) and "error" in payload: raise CapError("cap_binding_unavailable", "a2a error: %s" % str(payload["error"])[:500]) result = payload.get("result", payload) if isinstance(payload, dict) else {} if isinstance(result, dict): self._capture_ids(result) self._map_payload(result) if self.state == "working": self.state = "done" def _sse_pump(self, resp): buf = [] try: while True: line = resp.readline() if not line: break text = line.decode("utf-8", "replace").rstrip("\n").rstrip("\r") if text == "": if buf: self._sse_lines.put(("data", "\n".join(buf))) buf = [] continue if text.startswith(":"): continue if text.startswith("data:"): buf.append(text[len("data:"):].lstrip()) except (OSError, ValueError, http.client.HTTPException): pass finally: if buf: self._sse_lines.put(("data", "\n".join(buf))) self._sse_lines.put(("end", None)) def _stop_stream(self): if self._resp is not None: try: self._resp.close() except (OSError, ValueError, http.client.HTTPException): pass self._resp = None if self._conn is not None: try: self._conn.close() except OSError: pass self._conn = None if self._sse_thread is not None: self._sse_thread.join(timeout=5) self._sse_thread = None while True: try: self._sse_lines.get_nowait() except queue.Empty: break def poll(self, timeout=0.0): if self.proc is None or self.state in ("failed", "closed"): return self.drain() deadline = time.time() + max(0.0, timeout) while True: try: kind, payload = self._sse_lines.get_nowait() except queue.Empty: break if kind == "data": self._map_sse_data(payload) else: self._on_stream_end() if timeout > 0 and self.state == "working": remaining = deadline - time.time() if remaining > 0: time.sleep(min(remaining, 0.05)) return self.poll(0.0) return self.drain() def _map_sse_data(self, data): try: payload = json.loads(data) except ValueError: self.ignored_frames += 1 return if isinstance(payload, dict): self._map_payload(payload) else: self.ignored_frames += 1 def _capture_ids(self, payload): task = payload.get("task") or {} if isinstance(task, dict): self._task_id = task.get("id") or task.get("taskId") or self._task_id self._context_id = task.get("contextId") or self._context_id message = payload.get("message") or {} if isinstance(message, dict): self._context_id = message.get("contextId") or self._context_id def _map_payload(self, payload): self._capture_ids(payload) kind = payload.get("kind") if isinstance(kind, str) and kind.startswith("cap."): try: CapEvent.validate(payload) except ValueError: self.ignored_frames += 1 return self._turn_events += 1 self._emit(dict(payload)) return if self._map_data_parts(payload): return state = ((payload.get("task") or {}).get("status") or {}).get("state") if isinstance(payload.get("task"), dict) else None if state is not None: self._map_task_state(payload, state) text, msg_id = self._payload_text(payload) if text: self._turn_events += 1 self._emit(CapEvent.make("cap.text_chunk", message_id=msg_id, text=text, channel="assistant")) return artifact = payload.get("artifact") if isinstance(artifact, dict): self._map_plain_artifact(artifact) return if state is None: self.ignored_frames += 1 def _map_data_parts(self, payload): buckets = [] message = payload.get("message") if isinstance(message, dict) and isinstance(message.get("parts"), list): buckets += message["parts"] task = payload.get("task") if isinstance(task, dict) and isinstance((task.get("status") or {}).get("message"), dict): parts = (task["status"]["message"]).get("parts") if isinstance(parts, list): buckets += parts artifact = payload.get("artifact") if isinstance(artifact, dict) and isinstance(artifact.get("parts"), list): buckets += artifact["parts"] for part in buckets: if not isinstance(part, dict): continue meta = part.get("_meta") or {} cap_kind = (meta.get("cap") or {}).get("kind") if isinstance(meta, dict) else None if not isinstance(cap_kind, str) or not cap_kind.startswith("cap."): continue data = part.get("data") if not isinstance(data, dict): continue event = dict(data) event.setdefault("kind", cap_kind) try: CapEvent.validate(event) except ValueError: continue self._turn_events += 1 self._emit(event) return True return False @staticmethod def _payload_text(payload): message = payload.get("message") if not isinstance(message, dict): status = (payload.get("task") or {}).get("status") if isinstance(payload.get("task"), dict) else None message = (status or {}).get("message") if isinstance(status, dict) else None if not isinstance(message, dict): return "", "a2a-message" for part in message.get("parts") or []: if not isinstance(part, dict): continue if part.get("kind", part.get("type")) == "text" and part.get("text"): msg_id = message.get("messageId") or message.get("message_id") or "a2a-message" return part["text"], msg_id return "", "a2a-message" def _map_task_state(self, payload, state): if state in ("submitted", "working"): return if state == "input-required": text, _msg_id = self._payload_text(payload) self.ask_seq += 1 ask_id = "ask_%d" % self.ask_seq self.open_asks[ask_id] = ("a2a_ask", None, {}) self._emit(CapEvent.make("cap.ask_user", ask_id=ask_id, prompt=text or "agent needs input", form={"type": "string"})) self.state = "input_required" return if state in ("completed", "canceled", "rejected"): self.state = "done" return if state == "failed": message = payload.get("task", {}).get("status", {}).get("message") if isinstance(payload.get("task"), dict) else None text = message if isinstance(message, str) else (self._payload_text(payload)[0] or "A2A task failed") self._turn_events += 1 self._emit(CapEvent.make("cap.error", code="error", message=str(text)[:2000], retryable=False, details={"task_state": state})) self.state = "done" return self.ignored_frames += 1 def _map_plain_artifact(self, artifact): texts = [] for part in artifact.get("parts") or []: if isinstance(part, dict) and part.get("kind", part.get("type")) == "text" and part.get("text"): texts.append(part["text"]) content = "\n".join(texts) self.ask_seq += 1 self._turn_events += 1 self._emit(CapEvent.make("cap.artifact", artifact_id=str(artifact.get("artifactId") or "a2a_%d" % self.ask_seq), mime_type=str(artifact.get("mimeType") or "application/octet-stream"), name=str(artifact.get("name") or "artifact"), size=len(content.encode("utf-8")), content=content)) def _on_stream_end(self): if self.state != "working": return if self._turn_events: self.state = "done" else: self._emit(CapEvent.make("cap.error", code="error", message="a2a stream ended without events", retryable=False, details={})) self.state = "failed" def _answer_open(self, ask_id, kind, value): kind_name, _rpc_id, _extra = kind if kind_name != "a2a_ask": raise CapError("cap_invalid_answer", "unknown question kind") event = CapEvent.make("cap.ask_user.answer", ask_id=ask_id, value=value) self._post_message([{"kind": "data", "data": event, "_meta": {"cap": {"kind": "cap.ask_user.answer"}}}]) if not self.open_asks and self.state == "input_required": self.state = "working" def cancel(self, scope="current_turn"): if self.proc is None: raise CapError("cap_binding_unavailable", "session never started") if scope == "session": self.close() return if self.cancel_mode == "none": raise CapError("cap_cancel_unsupported", "manifest says mid_turn_cancel is none") try: conn, resp = self._request("POST", "/tasks/cancel") try: resp.read() finally: conn.close() except CapError: if self._task_id: try: conn, resp = self._request("POST", "/tasks/%s:cancel" % self._task_id) try: resp.read() finally: conn.close() except CapError: pass self._stop_stream() self._task_id = None if self.state not in ("failed", "closed"): self.state = "ready" def close(self): self._stop_stream() self.proc = None if self.state != "failed": self.state = "closed" CAP_SESSION_POLL = 0.1 CAP_TURN_TIMEOUT = 600 CAP_WORKTREE_DIR = ".cap-worktrees" CAP_ROUTE_QUEUE_MAX = 16 CAP_FLEET_DIFF_MAX = 65536 CAP_MENTION_RE = re.compile(r"@(cap://[A-Za-z0-9_.-]+)") class CapTurnResult: """One blocking turn: the collected events, the end state, summed usage, and the assistant text joined for routing.""" def __init__(self, events, state, usage=None): self.events = list(events) self.state = state self.usage = dict(usage or {}) @property def text(self): return "\n".join(event.get("text", "") for event in self.events if event.get("kind") == "cap.text_chunk") class CapSession: """One orchestrated agent: the driver pumps on its own thread, all events land on a queue, ask() blocks one turn with a timeout.""" def __init__(self, urn, driver): self.urn = urn self.driver = driver self.events = queue.Queue() self.taps = [] self.usage = {"input_tokens": 0, "output_tokens": 0, "cost_usd_estimate": 0.0} self._stop = threading.Event() self._thread = None def start(self, timeout=None): for event in self.driver.start(timeout): self._note(event) while True: try: self.events.get_nowait() except queue.Empty: break self._thread = threading.Thread(target=self._pump_loop, daemon=True) self._thread.start() return self def ask(self, text, timeout=CAP_TURN_TIMEOUT): baseline = dict(self.usage) self.driver.send_text(text) return self.wait_done(timeout, baseline) def wait_done(self, timeout=CAP_TURN_TIMEOUT, baseline=None): events = [] deadline = time.time() + max(1, timeout) while True: try: event = self.events.get(timeout=CAP_SESSION_POLL) except queue.Empty: event = None if event is not None: events.append(event) state = self.driver.state if state in ("done", "failed", "closed", "input_required") and self.events.empty(): events += self._grace_drain() return CapTurnResult(events, state, self._delta(baseline)) if self._stop.is_set(): return CapTurnResult(events, state, self._delta(baseline)) if time.time() > deadline: raise TimeoutError("turn timed out after %ds" % timeout) def _grace_drain(self): extra = [] deadline = time.time() + 0.2 while time.time() < deadline: try: extra.append(self.events.get(timeout=0.05)) except queue.Empty: pass return extra def _delta(self, baseline): baseline = baseline or {"input_tokens": 0, "output_tokens": 0, "cost_usd_estimate": 0.0} return {key: self.usage.get(key, 0.0) - baseline.get(key, 0.0) for key in self.usage} def answer(self, ask_id, value): self.driver.answer(ask_id, value) def cancel(self, scope="current_turn"): self.driver.cancel(scope) def close(self): self._stop.set() if self._thread is not None: self._thread.join(timeout=5) self._thread = None self.driver.close() def _note(self, event): self.events.put(event) for tap in self.taps: tap((self.urn, event)) if event.get("kind") == "cap.usage": self.usage["input_tokens"] += int(event.get("input_tokens", 0) or 0) self.usage["output_tokens"] += int(event.get("output_tokens", 0) or 0) try: self.usage["cost_usd_estimate"] += float(event.get("cost_usd_estimate", 0.0) or 0.0) except (ValueError, TypeError): pass def _pump_loop(self): while not self._stop.is_set(): try: events = self.driver.poll(CAP_SESSION_POLL) except Exception as exc: self._note(CapEvent.make("cap.error", code="error", message="driver pump failed: %s" % short_error(exc), retryable=False, details={})) return for event in events: self._note(event) if self.driver.state in ("failed", "closed"): return class CapFleet: """N orchestrated sessions (spec §10): worktree isolation, master plan, mediated @cap:// routing with per-session pending queues, budget aggregation with hard refusal, and the human audit log.""" def __init__(self, workdir=None, budget_usd=None): self.workdir = os.path.abspath(workdir or os.getcwd()) self.budget_usd = budget_usd self.sessions = {} self.worktrees = {} self.pending_routes = {} self.master_plan = None self.audit = [] self.feed = queue.Queue() self.answerer = None self._answering = set() self._answering_lock = threading.Lock() def set_answerer(self, answerer): self.answerer = answerer def _log(self, kind, sender="", recipient="", text=""): self.audit.append({"ts": time.time(), "kind": kind, "from": sender, "to": recipient, "text": text[:2000]}) def add(self, name, agent, cwd=None, model=None, env=None, worktree=True, registry=None, argv=None): urn = "cap://%s" % name if urn in self.sessions: raise ValueError("fleet already has %s" % urn) manifest = agent if isinstance(agent, CapManifest) else (registry or CapRegistry()).resolve(agent, agent) path = self._session_cwd(name, cwd, manifest, worktree) driver = CapDriver.create(manifest, urn=urn, cwd=path, model=model, env=env, acp_argv=argv) session = CapSession(urn, driver) session.taps.append(self.feed.put) self.sessions[urn] = session self.pending_routes[urn] = collections.deque(maxlen=CAP_ROUTE_QUEUE_MAX) self._log("session.add", recipient=urn, text="%s via %s in %s" % (manifest.name, manifest.binding(), path)) return session def _session_cwd(self, name, cwd, manifest, worktree): if cwd: return os.path.abspath(cwd) if not worktree or "coding" not in (manifest.get("agent.profiles") or []): return self.workdir slug = re.sub(r"[^a-z0-9-]+", "-", name.lower()).strip("-") or "agent" path = os.path.join(self.workdir, CAP_WORKTREE_DIR, slug) if self._git_inside(): branch = "cap/%s" % slug done = subprocess.run(["git", "worktree", "add", "-b", branch, path, "HEAD"], cwd=self.workdir, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=120) if done.returncode != 0: raise CapError("cap_io_error", "worktree for %s failed: %s" % (name, (done.stderr or "").strip()[:300])) self.worktrees["cap://%s" % name] = path self._log("worktree.add", recipient="cap://%s" % name, text="%s on %s" % (path, branch)) return path os.makedirs(path, exist_ok=True) self.worktrees["cap://%s" % name] = path self._log("worktree.plain", recipient="cap://%s" % name, text="%s (no git, unisolated)" % path) return path def _git_inside(self): try: done = subprocess.run(["git", "rev-parse", "--is-inside-work-tree"], cwd=self.workdir, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=15) except (OSError, subprocess.SubprocessError): return False return done.returncode == 0 and done.stdout.strip() == b"true" def start_all(self, timeout=None): for urn, session in self.sessions.items(): session.start(timeout) self._log("session.ready", recipient=urn) def publish_plan(self, plan): if not isinstance(plan, dict) or plan.get("kind") != "cap.plan" or not isinstance(plan.get("entries"), list): raise ValueError("master plan must be a cap.plan event with entries") for entry in plan["entries"]: target = ((entry.get("_meta") or {}).get("cap.assigned_to") if isinstance(entry.get("_meta"), dict) else None) if target is not None and target not in self.sessions: raise ValueError("plan assigns %s to unknown session" % target) self.master_plan = plan self._log("plan.publish", text="%d entries" % len(plan["entries"])) def ask(self, urn, text, timeout=CAP_TURN_TIMEOUT): if urn not in self.sessions: raise ValueError("unknown session %s" % urn) if self.budget_usd is not None and self.spent_usd() >= self.budget_usd: raise CapError("cap_budget_exceeded", "fleet spent %.4f of %.4f USD" % (self.spent_usd(), self.budget_usd)) self._log("turn.start", recipient=urn, text=text) started = time.time() result = self.sessions[urn].ask(text, timeout) state = getattr(result, "state", "?") if state == "done": outcome = "ok" elif state == "timeout": outcome = "timeout" else: outcome = "error" LOG.event("INFO", "cap", "turn", "cap turn to %s finished: %s" % (urn, state), meta={"target": urn, "payload_chars": len(text or ""), "exec_ms": int((time.time() - started) * 1000), "reply_chars": len(getattr(result, "text", "") or ""), "outcome": outcome}) self._log("turn.end", recipient=urn, text="%s, %d events" % (result.state, len(result.events))) self.pump() return result def spent_usd(self): return sum(session.usage.get("cost_usd_estimate", 0.0) for session in self.sessions.values()) def pump(self): """Drain the tapped feed, route @cap:// mentions, hand open questions to the answerer, deliver pending routes to idle sessions. Returns routed count.""" routed = 0 while True: try: urn, event = self.feed.get_nowait() except queue.Empty: break if event.get("kind") == "cap.text_chunk": routed += self._route_mentions(urn, event.get("text", "")) elif event.get("kind") in ("cap.ask_user", "cap.permission.request"): self._maybe_answer(urn, event) for urn, session in self.sessions.items(): routed += self._deliver_pending(urn, session) return routed def _maybe_answer(self, urn, event): if self.answerer is None: return key = event.get("ask_id") or event.get("req_id") or "" session = self.sessions.get(urn) if session is None or key not in session.driver.open_asks: return with self._answering_lock: if key in self._answering: return self._answering.add(key) self._log("answer.start", recipient=urn, text=key) thread = threading.Thread(target=self._answer_one, args=(urn, key, event), daemon=True) thread.start() def _answer_one(self, urn, key, event): try: session = self.sessions.get(urn) if session is not None and self.answerer is not None: self.answerer.handle(session, event) self._log("answer.done", recipient=urn, text=key) except Exception as exc: self._log("answer.error", recipient=urn, text="%s: %s" % (key, short_error(exc))) finally: with self._answering_lock: self._answering.discard(key) def _route_mentions(self, sender, text): count = 0 for target in CAP_MENTION_RE.findall(text or ""): if target == sender or target not in self.sessions: self._log("route.drop", sender, target, "unknown or self target") continue pending = self.pending_routes[target] if len(pending) >= CAP_ROUTE_QUEUE_MAX: self._log("route.drop", sender, target, "pending queue full") continue pending.append((sender, text.strip()[:2000])) self._log("route.queue", sender, target, text) count += 1 return count def _deliver_pending(self, urn, session): if session.driver.state not in ("ready", "done"): return 0 count = 0 pending = self.pending_routes[urn] while pending: sender, text = pending.popleft() try: session.driver.send_text("[from %s]\n%s" % (sender, text)) except CapError as exc: self._log("route.drop", sender, urn, str(exc)) continue self._log("route.send", sender, urn, text) count += 1 return count def wait_idle(self, timeout=CAP_TURN_TIMEOUT): deadline = time.time() + max(1, timeout) while time.time() < deadline: self.pump() busy = [urn for urn, session in self.sessions.items() if session.driver.state in ("starting", "working") or self.pending_routes[urn]] with self._answering_lock: busy = busy or list(self._answering) if not busy: return True time.sleep(CAP_SESSION_POLL) return False def collect_diffs(self): """Per-session uncommitted diffs (bounded); merging them is still the operator's call.""" result = {} for urn, path in self.worktrees.items(): try: done = subprocess.run(["git", "diff", "HEAD", "--"], cwd=path, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=60) except (OSError, subprocess.SubprocessError) as exc: result[urn] = {"note": "diff failed: %s" % short_error(exc)} continue diff = done.stdout or b"" result[urn] = {"size": len(diff), "truncated": len(diff) > CAP_FLEET_DIFF_MAX, "diff": diff[:CAP_FLEET_DIFF_MAX].decode("utf-8", "replace")} for urn in self.sessions: if urn not in result: result[urn] = {"note": "no worktree"} return result def cancel_all(self, scope="session"): for session in self.sessions.values(): try: session.cancel(scope) except CapError: pass def close_all(self): for session in self.sessions.values(): try: session.close() except Exception: pass for urn, path in self.worktrees.items(): try: done = subprocess.run(["git", "worktree", "remove", "--force", path], cwd=self.workdir, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120) if done.returncode != 0: shutil.rmtree(path, ignore_errors=True) subprocess.run(["git", "worktree", "prune"], cwd=self.workdir, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=60) except (OSError, subprocess.SubprocessError): pass self._log("worktree.remove", recipient=urn, text=path) def cap_cli_help(): return ("usage: tai.py --cap <resolve|chat|run> [...]\n" " resolve <name> [--binary B] print the manifest source, binding and profiles\n" " chat --driver NAME [--task T] [--cwd D] [--model M]\n" " talk to one agent (task once, then REPL on a tty)\n" " run <fleet.toml> [--task T] static-route fleet run, see cap.md for the schema") def cap_cli_main(argv): if not argv or argv[0] in ("-h", "--help", "help"): print(cap_cli_help()) return 0 verb, rest = argv[0], argv[1:] if verb == "resolve": return cap_resolve_main(rest) if verb == "chat": return cap_chat_main(rest) if verb == "run": return cap_run_main(rest) print("unknown cap command %r\n%s" % (verb, cap_cli_help()), file=sys.stderr) return 2 def cap_resolve_main(argv): parser = argparse.ArgumentParser(prog="tai.py --cap resolve") parser.add_argument("name") parser.add_argument("--binary", default=None) args = parser.parse_args(argv) try: manifest = CapRegistry().resolve(args.name, args.binary) except (CapError, ValueError) as exc: print("cap resolve: %s" % exc, file=sys.stderr) return 1 print("%s: %s via %s profiles=%s" % (manifest.name, manifest.source, manifest.binding(), ",".join(manifest.get("agent.profiles") or []) or "-")) return 0 def cap_print_result(result): if result.text.strip(): print(result.text) print("[%s] tokens in=%d out=%d" % (result.state, result.usage.get("input_tokens", 0), result.usage.get("output_tokens", 0))) def cap_build_answerer(auto, allow=(), deny=(), max_risk="medium"): if not auto: return None return CapAnswerer(allow_tools=allow, deny_tools=deny, max_risk=max_risk) def cap_answer_flags(parser): parser.add_argument("--auto-answer", action="store_true", help="answer subagent questions by policy plus research turns") parser.add_argument("--allow-tool", action="append", default=[], metavar="TOOL", help="pre-approve a tool (repeatable)") parser.add_argument("--deny-tool", action="append", default=[], metavar="TOOL", help="pre-deny a tool (repeatable)") parser.add_argument("--max-risk", default="medium", choices=CAP_ANSWER_RISKS, help="riskiest auto-approved unknown tool") def cap_chat_main(argv): parser = argparse.ArgumentParser(prog="tai.py --cap chat") parser.add_argument("--driver", required=True) parser.add_argument("--task", default="") parser.add_argument("--cwd", default=None) parser.add_argument("--model", default=None) parser.add_argument("--argv", default=None, help="ACP server command line (shell-split) for acp_stdio agents") cap_answer_flags(parser) args = parser.parse_args(argv) try: manifest = CapRegistry().resolve(args.driver, args.driver) except (CapError, ValueError) as exc: print("cap chat: %s" % exc, file=sys.stderr) return 1 fleet = CapFleet() fleet.set_answerer(cap_build_answerer(args.auto_answer, args.allow_tool, args.deny_tool, args.max_risk)) try: argv = shlex.split(args.argv) if args.argv else None session = fleet.add("chat", manifest, cwd=args.cwd, model=args.model, argv=argv) session.start() except (CapError, ValueError) as exc: print("cap chat: %s" % exc, file=sys.stderr) return 1 try: if args.task: try: cap_print_result(fleet.ask(session.urn, args.task)) except (CapError, TimeoutError) as exc: print("cap chat: %s" % exc, file=sys.stderr) return 1 if not sys.stdin.isatty(): return 0 while True: try: line = input("cap[%s]› " % args.driver) except EOFError: print() break except KeyboardInterrupt: print() continue if line.strip() in ("", "/quit", "/exit"): if line.strip(): break continue try: cap_print_result(fleet.ask(session.urn, line)) except TimeoutError as exc: print("cap chat: %s" % exc, file=sys.stderr) try: session.cancel() except CapError: pass except CapError as exc: print("cap chat: %s" % exc, file=sys.stderr) finally: fleet.close_all() return 0 def cap_run_main(argv): parser = argparse.ArgumentParser(prog="tai.py --cap run") parser.add_argument("fleet_file") parser.add_argument("--task", default=None) cap_answer_flags(parser) args = parser.parse_args(argv) try: import tomllib except ImportError: print("cap run: fleet files need Python 3.11 or newer (tomllib)", file=sys.stderr) return 2 try: with open(args.fleet_file, "rb") as handle: spec = tomllib.load(handle) except (OSError, ValueError) as exc: print("cap run: cannot load %s: %s" % (args.fleet_file, short_error(exc)), file=sys.stderr) return 2 try: plan = cap_fleet_plan(spec, args.task) except ValueError as exc: print("cap run: %s" % exc, file=sys.stderr) return 2 fleet = CapFleet(budget_usd=plan["budget_usd"]) LOG.event("INFO", "cap", "run_start", "cap run started (%d sessions)" % len(plan["sessions"]), meta={"sessions": len(plan["sessions"])}) auto = args.auto_answer or plan["auto_answer"] max_risk = plan["max_risk"] if args.max_risk == "medium" else args.max_risk fleet.set_answerer(cap_build_answerer(auto, args.allow_tool + plan["allow_tools"], args.deny_tool + plan["deny_tools"], max_risk)) try: for entry in plan["sessions"]: fleet.add(entry["name"], entry["agent"], cwd=entry.get("cwd"), model=entry.get("model"), argv=entry.get("argv")) fleet.start_all() last_text = {} result = fleet.ask(plan["start"], plan["task"]) print("== %s [%s] ==" % (plan["start"], result.state)) cap_print_result(result) last_text[plan["start"]] = result.text for source, target in plan["routes"]: payload = last_text.get(source, "") print("== route %s -> %s ==" % (source, target)) result = fleet.ask(target, payload or "[no output from %s]" % source) print("== %s [%s] ==" % (target, result.state)) cap_print_result(result) last_text[target] = result.text except (CapError, TimeoutError, ValueError) as exc: print("cap run: %s" % exc, file=sys.stderr) return 1 finally: fleet.close_all() return 0 def cap_fleet_plan(spec, task_override=None): """Validate a fleet.toml mapping into {start, task, budget_usd, sessions, routes}. Pure function, no processes.""" if not isinstance(spec, dict): raise ValueError("fleet file must hold TOML tables") fleet = spec.get("fleet") or {} sessions = spec.get("sessions") or [] if not sessions: raise ValueError("fleet needs at least one [[sessions]] entry") urns = set() for entry in sessions: if not isinstance(entry, dict) or not entry.get("name") or not entry.get("agent"): raise ValueError("every [[sessions]] entry needs name and agent") if entry.get("argv") is not None and not isinstance(entry.get("argv"), list): raise ValueError("session %r argv must be a list" % entry.get("name")) urns.add("cap://%s" % entry["name"]) start = fleet.get("start") or "" if start not in urns: raise ValueError("[fleet] start must name one of: %s" % ", ".join(sorted(urns))) task = task_override if task_override is not None else fleet.get("task") or "" if not task.strip(): raise ValueError("fleet needs a task ([fleet] task or --task)") routes = [] for route in spec.get("routes") or []: if not isinstance(route, dict): raise ValueError("every [[routes]] entry needs when and route_to") source = route.get("when") or "" source = source[:-len(".done")] if source.endswith(".done") else source target = route.get("route_to") or "" if source not in urns or target not in urns: raise ValueError("route %r -> %r names an unknown session" % (source, target)) routes.append((source, target)) budget = fleet.get("budget_usd") if budget is not None: try: budget = float(budget) except (ValueError, TypeError): raise ValueError("[fleet] budget_usd must be a number") max_risk = fleet.get("max_risk", "medium") if max_risk not in CAP_ANSWER_RISKS: raise ValueError("[fleet] max_risk must be one of low, medium, high") for key in ("allow_tools", "deny_tools"): if key in fleet and not isinstance(fleet[key], list): raise ValueError("[fleet] %s must be a list" % key) return {"start": start, "task": task, "budget_usd": budget, "sessions": sessions, "routes": routes, "auto_answer": bool(fleet.get("auto_answer", False)), "allow_tools": list(fleet.get("allow_tools") or []), "deny_tools": list(fleet.get("deny_tools") or []), "max_risk": max_risk} TAI_CAP_MANIFEST = """# tai CAP agent manifest. Emitted by `tai.py --cap-manifest`. [agent] name = "tai" binary = "tai.py" profiles = ["coding"] [probe] command = ["tai.py", "--version"] version_pattern = "tai (\\\\d+\\\\.\\\\d+\\\\.\\\\d+)" [startup] command = ["tai.py"] ready_when = { pattern = "type /help for commands" } init_timeout_seconds = 60 [fast_path] stream_json = ["tai.py", "--cap-speak"] grpc = false acp_stdio = false a2a_serve_at = false [pty] cols = 200 rows = 50 bracketed_paste = false sigint_cancels_turn = "graceful" queued_input_supported = false [parse] idle = ["^tai\\\\[.*\\\\]› $"] error_lines = ["^seal error:", "^sandbox unavailable:"] [capabilities] streaming_output = true queued_input = false mid_turn_cancel = "graceful" multi_session = true input_modalities = ["text"] output_modalities = ["text"] [capabilities.ask_user] yes_no = true options = false free_text = true [capabilities.coding] revision = "2026-05-18" fs = { read = true, write = true, list = true, exists = true, scope = "workspace_only" } terminal = { enabled = true, concurrent_max = 4 } tool_permission = "interactive" artifacts = ["diff", "transcript"] [cost] metered = true currency = "USD" """ def build_parser(): parser = argparse.ArgumentParser(prog="tai", description="single-file autonomous agent, standard library only") parser.add_argument("--profile", default=None) parser.add_argument("--yes", action="store_true") parser.add_argument("--yolo", action="store_true") parser.add_argument("--auto", action="store_true") parser.add_argument("--continue", dest="resume", action="store_true") parser.add_argument("--debug", nargs="?", const="file", default=None, choices=("file", "stdout", "stderr"), metavar="SINK", help="extensive debug logging to <home>/debug.log (file), stdout, or stderr, plus the vault logs table (see logging.md)") parser.add_argument("--debug-format", default=None, choices=("sentence", "json"), metavar="FORMAT", help="debug log line format, sentence or json (default sentence, or TAI_DEBUG_FORMAT)") parser.add_argument("--env", default=None) parser.add_argument("--no-opencode", action="store_true", help="skip the opencode CLI backends for this run (same as TAI_OPENCODE=0)") parser.add_argument("--update-models", action="store_true", help="rediscover the free model lists of every provider, drop models that are gone, and exit") parser.add_argument("--eval-retrieval", action="store_true", help="score vault retrieval (Hit@K, MRR, nDCG, Context Precision) on generated goldens plus implicit pairs, print the report, and exit") parser.add_argument("--version", action="store_true") parser.add_argument("--cap-manifest", action="store_true", help="print tai's CAP agent manifest (cap-agent.toml) and exit") parser.add_argument("--cap-speak", action="store_true", help="speak CAP stream-json on stdio as an agent (bulk session config via CAP_SESSION_CONFIG)") parser.add_argument("--cap-cwd", default=None, metavar="DIR", help="speak session working directory (overrides CAP_SESSION_CONFIG cwd)") parser.add_argument("--cap-model", default=None, metavar="MODEL", help="speak session model label (overrides CAP_SESSION_CONFIG model)") parser.add_argument("--cap-permission-mode", default=None, choices=("none", "confirm", "interactive"), help="speak session permission mode (overrides CAP_SESSION_CONFIG permission_mode)") parser.add_argument("--cap-max-turns", default=None, type=int, metavar="N", help="speak session turn cap (overrides CAP_SESSION_CONFIG max_turns)") parser.add_argument("--cap", nargs=argparse.REMAINDER, default=None, metavar="COMMAND", help="orchestrator: resolve, chat or run fleet commands (see cap.md)") parser.add_argument("--serve-mcp", default=None, metavar="KEY", help="serve MCP over HTTP (Streamable HTTP): all native tools plus chat/completions, Bearer KEY required (see mcp.md)") parser.add_argument("--mcp-port", default=None, type=int, metavar="PORT", help="serve-mcp port (default TAI_MCP_PORT else 8765)") parser.add_argument("--mcp-bind", default=None, metavar="HOST", help="serve-mcp bind address (default TAI_MCP_BIND else 127.0.0.1)") parser.add_argument("--telegram", action="store_true") parser.add_argument("--scheduler", action="store_true") parser.add_argument("--install", action="store_true") parser.add_argument("--install-telegram", action="store_true") parser.add_argument("--uninstall-telegram", action="store_true") parser.add_argument("--plexer-wrap", action="store_true", help="internal: invoked by the bashrc plexer hook, not meant to be run by hand") parser.add_argument("--mplex", nargs=argparse.REMAINDER, default=None, metavar="COMMAND", help="terminal multiplexer: any mplex command (new-session, attach-session, list-keys ...); bare --mplex starts a session. Config: ~/.tai/mplex.toml") parser.add_argument("--demo", action="store_true", help="run the fullscreen TUI framework showcase") parser.add_argument("--demo-shot", action="store_true", help="print one composed demo frame as text and exit") parser.add_argument("--tui", action="store_true", help="run the fullscreen grr client (bots, chats, automations, library, projects, settings) against the grr server; address in GRR_URL") parser.add_argument("--tui-local", action="store_true", help="run the fullscreen Telegram-style chat UI on the local vault, no server needed") parser.add_argument("--theme", default=None, metavar="NAME", help="TUI theme: dark, light, nord, gruvbox, ansi (or TAI_THEME)") parser.add_argument("prompt", nargs="*") return parser def banner(agent): if Glow.enabled(): print("%s◆%s %stai%s %s%s%s %s· profile %s%s" % (Ansi.BOLD, Ansi.RESET, Ansi.BOLD, Ansi.RESET, FG_FILE, VERSION, Ansi.RESET, Ansi.DIM, agent.profile, Ansi.RESET)) else: print(paint("tai %s · profile %s" % (VERSION, agent.profile), Ansi.BOLD, Ansi.CYAN)) if agent.yolo: print(paint("YOLO MODE: all approvals off", Ansi.BOLD, Ansi.RED)) if agent.auto: print(paint("AUTO MODE: never asks, researches instead", Ansi.BOLD, Ansi.YELLOW)) seal_state = "off" if agent.store.seal.enabled: seal_state = "on (default key)" if agent.store.seal.default_key else "on (TAI_PASSPHRASE)" if Glow.enabled(): print(" %sseal:%s %s" % (Ansi.DIM, Ansi.RESET, seal_state)) else: print(" seal: %s" % seal_state) if getattr(agent, "ctx_id", ""): if Glow.enabled(): print(" %sctx:%s %s" % (Ansi.DIM, Ansi.RESET, agent.ctx_id)) else: print(" ctx: %s" % agent.ctx_id) for label, _chat_url, models_url, key, _model in agent.chat.backends(): if Glow.enabled(): print(" %s%s:%s %s" % (Ansi.DIM, label, Ansi.RESET, probe_backend(models_url, key))) else: print(" %s: %s" % (label, probe_backend(models_url, key))) if agent.store.load_turn_state(agent.profile, agent.bot, ctx=getattr(agent, "ctx_id", "")).get("open"): print(paint("interrupted turn available, restart with --continue to pick it up", Ansi.YELLOW)) print(paint("type /help for commands", Ansi.DIM)) def resume_turn(agent): state = agent.store.load_turn_state(agent.profile, agent.bot, ctx=getattr(agent, "ctx_id", "")) if not state.get("open"): print("nothing to resume: last turn for %s/%s completed normally" % (agent.profile, agent.bot)) return "" goal = state.get("goal") or "" print("resuming interrupted turn for %s/%s: %d steps, %d tool calls, goal: %s" % (agent.profile, agent.bot, state.get("steps", 0), state.get("tools", 0), goal[:200])) message = "Continue where you left off%s. Do not repeat steps already completed above; carry on with the next step." % (": the interrupted request was: %s" % goal if goal else "") return agent.run_turn(message) def repl(agent): if readline is not None: for entry in agent.store.history_load(agent.profile): readline.add_history(entry.replace("\n", " ")) readline.set_completer(lambda text, state: complete_input(text, state, agent)) readline.parse_and_bind("tab: complete") scheduler_stop = start_scheduler(agent.config, agent.store.seal) try: while True: try: busy = running_agents() base = agent.profile + ("!" if agent.yolo else "") tag = "%s+%d" % (base, busy) if busy else base if Glow.enabled(): print(Glow.statusbar(VERSION, agent.profile, dir_key(), agent.yolo, busy)) line = input(Glow.prompt_text()) else: line = input(paint("tai[%s]› " % tag, Ansi.BOLD, Ansi.GREEN)) except EOFError: print() break except KeyboardInterrupt: print() continue text = line.strip() if not text: continue agent.store.history_add(agent.profile, agent.bot, text) if text.startswith("/"): if not handle_command(agent, text): break continue try: agent.run_turn(text) except KeyboardInterrupt: print(paint("\ninterrupted", Ansi.YELLOW)) agent.checkpoint() finally: scheduler_stop.set() agent.store.save_session(agent.profile, agent.messages, agent.bot, ctx=getattr(agent, "ctx_id", "")) def update_models_command(args): chat = ChatClient(Config(args)) failed = False for label, total, added, removed in chat.update_models(): if total is None: failed = True print("%s: skipped, source unreachable or empty, old list kept" % label) continue print("%s: %d models, +%d new, -%d removed" % (label, total, len(added), len(removed))) for item in added: print(" + %s" % item) for item in removed: print(" - %s" % item) return 1 if failed else 0 def main(argv=None): args = build_parser().parse_args(argv) debug_sink = args.debug or (os.environ.get("TAI_DEBUG") or "").strip().lower() if debug_sink in ("file", "stdout", "stderr"): debug_format = args.debug_format or (os.environ.get("TAI_DEBUG_FORMAT") or "").strip().lower() if debug_format not in ("sentence", "json"): debug_format = "sentence" home = os.environ.get("TAI_HOME") or os.path.join(os.path.expanduser("~"), ".tai") LOG.configure(debug_sink, debug_format, home) if args.install: return install_self() if args.install_telegram: return install_telegram() if args.uninstall_telegram: return uninstall_telegram() if args.version: print("tai %s" % VERSION) return 0 if args.cap_manifest: print(TAI_CAP_MANIFEST) return 0 if args.cap_speak: return cap_speak_main(args) if args.cap is not None: return cap_cli_main(args.cap) if args.serve_mcp is not None: if not args.serve_mcp: print("error: --serve-mcp needs a KEY clients must send as Bearer token", file=sys.stderr) return 2 return mcp_serve_main(args, args.serve_mcp) if args.update_models: return update_models_command(args) if args.demo: return tui_demo() if args.demo_shot: return tui_demo_shot() try: theme_name = tui_resolve_theme_name(args.theme) except ValueError as exc: print(exc, file=sys.stderr) return 2 if args.mplex is not None: home = os.environ.get("TAI_HOME") or os.path.join(os.path.expanduser("~"), ".tai") return MplexCli.main(home, args.mplex) if args.plexer_wrap: home = os.environ.get("TAI_HOME") or os.path.join(os.path.expanduser("~"), ".tai") profile = args.profile or "default" backend = NativePlexerBackend(home, profile) return backend.run() if args.tui: return grr_tui() if args.profile is None: args.profile = "telegram" if args.telegram else "default" if not PROFILE_RE.match(args.profile): print("invalid profile name", file=sys.stderr) return 1 try: agent = boot(args) except KeyboardInterrupt: print(paint("\ninterrupted", Ansi.YELLOW)) return 130 except SealError as exc: print("seal error: %s" % exc, file=sys.stderr) return 2 if args.env is not None: if args.env not in ("home", "sandbox"): print("use --env home or --env sandbox", file=sys.stderr) return 1 if args.env == "sandbox": try: ensure_box() except BackendError as exc: print("sandbox unavailable: %s" % exc, file=sys.stderr) return 2 agent.env = args.env if args.eval_retrieval: try: goldens = RetrievalEval.goldens_from_vault(agent.store, agent.profile) result = RetrievalEval.score(agent.store, goldens, agent.profile) pairs = RetrievalEval.implicit_pairs(agent.store, agent.profile) print(RetrievalEval.format_report(result, pairs)) finally: agent.store.close() return 0 if args.scheduler: print("scheduler online, press Ctrl-C to stop") try: run_scheduler_loop(agent.config, agent.store.seal, threading.Event()) except KeyboardInterrupt: print() finally: agent.store.close() return 0 if args.telegram: return run_telegram_bot(agent) if args.tui_local: try: return tui_chat(agent, theme_name) finally: agent.store.close() if args.prompt: try: prompt_text = " ".join(args.prompt) verified = agent.ensure_verified(prompt_text, agent.run_turn(prompt_text)) except KeyboardInterrupt: print(paint("\ninterrupted", Ansi.YELLOW)) return 130 finally: try: agent.checkpoint() except Exception: pass try: agent.store.close() except Exception: pass return 0 if verified else 1 if args.resume and not args.telegram and not args.scheduler: resume_turn(agent) banner(agent) try: repl(agent) finally: agent.store.close() return 0 if __name__ == "__main__": sys.exit(main())