#!/usr/bin/env bun
import { Bot, Context, GrammyError, HttpError } from 'grammy'
import { randomUUID } from 'crypto'
import {
  chmodSync,
  closeSync,
  existsSync,
  mkdirSync,
  openSync,
  readdirSync,
  readFileSync,
  readSync,
  renameSync,
  rmSync,
  statSync,
  unlinkSync,
  writeFileSync,
} from 'fs'
import { homedir, tmpdir } from 'os'
import { basename, dirname, extname, join } from 'path'
import { spawn, spawnSync } from 'child_process'
import { selfInstall } from './selfInstall.ts'
import { runSelfConfig } from './selfConfig.ts'
import {
  APPROVALS_PATH,
  fleetAvailable,
  listApprovals,
  listNotices,
  NOTICES_PATH,
  probeApprovals,
  probeNotices,
  probeSurface,
  resolveApproval,
  resolveFleetBase,
  routerJsonPath,
  type FleetNotice,
  type PendingApproval,
  type SurfaceState,
} from './fleetClient.ts'
import { FleetFace } from './fleetFace.ts'
import {
  buildCardText,
  buildKeyboard,
  buildResolvedText,
  DENY_REASON,
  parseCallbackData,
} from './approvalCard.ts'
import { buildNoticeText } from './noticeCard.ts'
// NAME_RE / RUNTIME / IAPEER_DIR / PEER_PROFILE_FILE are the shared ecosystem
// contract values — single source of truth in constants.ts (the sibling contract
// modules import them too). Imported here rather than re-declared so a grammar
// change (e.g. NAME_RE) is made ONCE, not in two places.
import { BOT_USERNAME_RE, IAPEER_DIR, NAME_RE, PEER_PROFILE_FILE, RUNTIME } from './constants.ts'
import { writeJsonAtomic } from './fsAtomic.ts'

const MAX_TELEGRAM_TEXT = 4096
// Outbound send hardening: a hung Telegram API call (transient network /
// proxy glitch) used to block the serial outbound queue forever — the peer
// silently stopped receiving. We bound every outbound send with a timeout
// (so a hung call rejects instead of hanging) plus a few backoff retries.
const OUTBOUND_SEND_TIMEOUT_MS = Number(process.env.TELEGRAM_OUTBOUND_TIMEOUT_MS ?? '') || 30_000
const OUTBOUND_SEND_RETRIES = Number(process.env.TELEGRAM_OUTBOUND_RETRIES ?? '') || 2
// Telegram → IAP delivery is synchronous from the operator's point of view:
// if the core router refuses the send (ok=false/err) or our local waiter times
// out before the core wake deadline, the operator must see that verdict in the
// same chat. This timeout is deliberately shorter than iapeer's wake-deadline
// (240s today) so the bridge never leaves Telegram polling frozen behind a
// stuck CLI child. The verdict is "not delivered: …" — no auto-retry, no silent
// loss.
const IAP_SEND_TIMEOUT_MS = Number(process.env.TELEGRAM_IAP_SEND_TIMEOUT_MS ?? '') || 60_000
// Rich messages (Bot API 10.1, released 2026-06-11): an outbound peer envelope
// is sent as ONE rich message — `InputRichMessage.markdown` carries the agent's
// GFM verbatim and Telegram parses it SERVER-SIDE ("Rich Markdown is compatible
// with GitHub Flavored Markdown where possible"): headings, lists, tables,
// quotes and code render natively on the owner's client. Doc limits: 32768
// characters / 500 blocks (vs 4096 for sendMessage) — one rich send replaces
// chunking for any realistic report. The call goes through `bot.api.raw`,
// typed natively since grammy 1.44.0 (@grammyjs/types 3.28 ships the 10.1
// `sendRichMessage` method). Every failure falls back to the legacy chunked
// MarkdownV2→plain path — rich can degrade, never lose a message. Kill
// switch: TELEGRAM_RICH=0.
const RICH_OUTBOUND_ENABLED = process.env.TELEGRAM_RICH !== '0'
// The docs say "32768 UTF-8 characters"; whether that counts code points or
// UTF-8 bytes is unverified — we gate on JS .length as the approximation and
// let a 400 "too long" fall back to the chunked path (graceful, message kept).
const MAX_RICH_TEXT = 32768
// Paragraph-spacing workaround (defect 2026-06-12): Telegram clients render
// ADJACENT paragraph blocks with line breaks but NO vertical gap — the server
// parses \n\n into separate paragraph blocks correctly (proven by the API
// response echo), the air is lost at render time (owner screenshots). The
// bridge compensates by inserting a spacer paragraph (a lone &nbsp;) between
// two PLAIN paragraphs; structural blocks (headings, tables, lists, quotes,
// code, dividers) already render with spacing and must NOT get spacers. The
// feature is a day old client-side — this is deliberately CHEAP TO REMOVE:
// one env (TELEGRAM_RICH_SPACER=0), one call site, one function.
const RICH_SPACER_ENABLED = process.env.TELEGRAM_RICH_SPACER !== '0'
const RICH_SPACER_LINE = '&nbsp;'
// Soft-break hardening (defect 2026-06-23): the rich path hands the agent's text
// to Telegram's server-side GFM parser, which treats a single \n inside a
// paragraph as a CommonMark SOFT break — it space-joins the two lines. So an
// agent's natural multi-line plain text (bullets via •, ad-hoc lists, line-by-line
// notes) collapses into one run-on line; only a blank line (\n\n) survives as a
// break. The owner's bar: plain newlines must "just work" without the agent
// padding with double newlines. The bridge hardens each in-paragraph soft break
// into a GFM HARD break (two trailing spaces before the newline) so the line is
// preserved — see hardenSoftBreaks. The legacy MarkdownV2 fallback path renders
// \n literally and needs no such fix; this is rich-path-only. Cheap to remove
// under a future client/server change: one env (TELEGRAM_RICH_HARDBREAK=0), one
// call site, one function.
const RICH_HARDBREAK_ENABLED = process.env.TELEGRAM_RICH_HARDBREAK !== '0'
const RICH_HARDBREAK_MARK = '  '
const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp'])
// `.gif` is NOT a photo: Telegram's sendPhoto runs server-side image processing that
// rejects GIFs (`Bad Request: IMAGE_PROCESS_FAILED`), so a GIF attachment was silently
// dropped. A GIF's native method is sendAnimation — it accepts the GIF container and
// renders an inline, looping, muted player (Telegram transcodes GIF→MP4 itself).
const ANIMATION_EXTS = new Set(['.gif'])
// Outbound attachment → Telegram Bot API method, keyed on the file extension the
// sending agent chose. We trust the extension as the declared format rather than
// probing bytes/ffprobe: it keeps the outbound path dependency-free and synchronous,
// and `sendVoice` in particular *requires* an OGG/OPUS container — the same thing the
// `.ogg`/`.oga` extension declares. Voice → inline waveform player; audio → music
// track; animation → inline GIF player; photo → image; everything else falls back to a
// plain document (no regression).
const VOICE_EXTS = new Set(['.ogg', '.oga'])
const AUDIO_EXTS = new Set(['.mp3', '.m4a', '.wav', '.aac', '.flac'])

type Flags = Record<string, string | boolean | string[]>

type TelegramInterface = {
  user_id?: string
  // The bot @username (BOT_USERNAME_RE grammar) — the NATURAL KEY of the telegram bot
  // (decision 2026-06-20): it names the credential dir bots/<username>/.env (token +
  // @username) AND keys inbound/outbound routing. Globally unique in Telegram and
  // immutable (changing a bot's @username = creating a new bot), so it is a stable
  // catalog key with no orphan-on-rename hazard.
  bot_username?: string
  // Agent-activity progress channel (second, separate channel — NOT
  // send_to_peer). Tri-state on purpose: `true`/`false` is an explicit
  // per-peer operator choice (set via the `/activity` chat command), `undefined`
  // means "never decided" → falls back to the default, which is ON in code
  // (product decision: the activity stream is visible by default — a new
  // peer streams out of the box). TELEGRAM_ACTIVITY_DEFAULT=0 is the env
  // opt-out for hosts where the stream is noise.
  activity?: boolean
}

// IAP identity-contract vocabulary: natural (human peers) / artificial (LLM agents)
// / absent (programmatic sources — no LLM, no human). The foundation normalizes any
// legacy value (human→natural, scripted→absent) at its boundary BEFORE an envelope
// or a provisioned profile reaches this runtime, so only the current vocabulary is
// accepted here — byte-identical to notifier-runtime's envelope set. Values still
// round-trip verbatim through readPeerProfile/writePeerProfile (preserve, never
// re-derive identity — the pilot-notifier lesson selfConfig.ts guards).
type Intelligence = 'natural' | 'artificial' | 'absent'

const HUMAN_RUNTIMES_TR = new Set(['telegram', 'discord', 'matrix', 'email', 'web'])
const SCRIPTED_RUNTIMES_TR = new Set(['webhook', 'api', 'cron'])

function defaultIntelligenceForRuntime(runtime: string): Intelligence {
  if (HUMAN_RUNTIMES_TR.has(runtime)) return 'natural'
  if (SCRIPTED_RUNTIMES_TR.has(runtime)) return 'absent'
  return 'artificial'
}

function isIntelligence(value: unknown): value is Intelligence {
  return value === 'natural' || value === 'artificial' || value === 'absent'
}

// Read-compat normalizer for an intelligence value coming off the wire (canon:
// core normalizeIntelligenceValue). Legacy envelopes in disk queues / historic
// transcripts may still carry the pre-contract vocabulary: human → natural,
// scripted → absent; a genuinely unknown value is dropped, not invented.
const LEGACY_INTELLIGENCE: Readonly<Record<string, Intelligence>> = {
  human: 'natural',
  scripted: 'absent',
}

function normalizeIntelligenceValue(value: unknown): Intelligence | undefined {
  if (typeof value !== 'string') return undefined
  if (isIntelligence(value)) return value
  return LEGACY_INTELLIGENCE[value]
}

type PeerProfile = {
  personality: string
  runtime: string
  runtimes: string[]
  description: string
  intelligence: Intelligence
  // Private runtime-plugin config (owner: telegram-runtime; design sync with
  // iapeer 2026-06-11, topic aliases-section-design). Named for the MECHANISM,
  // not the plugin: expansion is runtime-agnostic by the 2026-05-25 decision —
  // a future discord/matrix runtime reads the SAME aliases. Round-trips
  // verbatim (unknown siblings preserved); sanitizing happens at the point of
  // use — see resolveAliases(). Intentionally NOT projected into the public
  // peers-profiles.json registry (iapeer side) — read from the local profile
  // per-message.
  expansion?: {
    aliases?: Record<string, string>
    [key: string]: unknown
  }
  interfaces?: {
    telegram?: TelegramInterface
    [key: string]: unknown
  }
}

type PeerRecord = PeerProfile & {
  cwd: string
}

type PeersIndex = {
  version: number
  peers: PeerRecord[]
}

type BotCredential = {
  key: string
  token: string
  /** Optional service role of this bot (Ф3): `approval` marks the single shared
   *  approval bot — a pure telegram-runtime service-bot (no foundation peer), routed to
   *  for FACELESS peers' approval cards. Read from TELEGRAM_BOT_ROLE in the credential. */
  role?: string
}

type PeerDirectory = {
  peers: PeerRecord[]
  byPersonality: Map<string, PeerRecord>
  byTelegramBot: Map<string, PeerRecord>
}

type IapEnvelope = {
  fromPersonality: string
  fromRuntime: string
  fromIntelligence?: Intelligence
  /** The sender's dispatch instant (`ts` attribute, envelope-compaction F) —
   *  full local ISO on the wire; absent on legacy envelopes. */
  sentAt?: string
  topic?: string
  attachments: string[]
  message: string
}

type RuntimeContext = {
  cwd: string
  owner: PeerProfile
  ownerUserId: string
  iapBin: string
  bots: Map<string, Bot>
  credentials: Map<string, BotCredential>
  /** Fleet base URL for the approval face + callback resolves (Ф3); set at run() when the
   *  daemon advertises fleet. Undefined ⇒ approval face is off (pre-approval daemon). */
  approvalBase?: string
  /** botKey of the loaded shared approval bot (credential role=approval), if provisioned.
   *  FACELESS peers' cards route here. Undefined ⇒ owner declined / not provisioned ⇒
   *  faceless approvals have no Telegram route (bar/CLI still hold them). */
  approvalBotKey?: string
}

class TelegramRuntimeError extends Error {}

type ReleaseLock = () => void

function usage(): string {
  return `Usage:
  telegram-runtime                 # self-install (npx contract): bin on PATH + manifest
  telegram-runtime self-install    # explicit self-install (idempotent)
  telegram-runtime self-config     # per-peer self-config hook (foundation-invoked)
  telegram-runtime prepare [--user-id <telegram-user-id>]
  telegram-runtime interface human --user-id <telegram-user-id>
  telegram-runtime interface bot <bot-username> --peer <personality>
  telegram-runtime bot add <bot-username> --token <token>
  telegram-runtime bot remove <bot-username>
  telegram-runtime bot list [--json]
  telegram-runtime run
  telegram-runtime doctor [--json]
  telegram-runtime approvals [--json]   # face-side read of the daemon approval queue
  telegram-runtime notices [--json]     # face-side read of the daemon notice board (mute peers)
  telegram-runtime onboard-approval [--token <token> | --decline [--yes] | --status]`
}

function setFlag(flags: Flags, key: string, value: string | boolean): void {
  const previous = flags[key]
  if (previous === undefined) {
    flags[key] = value
    return
  }
  if (Array.isArray(previous)) {
    previous.push(String(value))
    return
  }
  flags[key] = [String(previous), String(value)]
}

function parseFlags(argv: string[]): { positional: string[]; flags: Flags } {
  const positional: string[] = []
  const flags: Flags = {}
  for (let i = 0; i < argv.length; i++) {
    const arg = argv[i]
    if (!arg.startsWith('--')) {
      positional.push(arg)
      continue
    }
    const raw = arg.slice(2)
    const eq = raw.indexOf('=')
    if (eq >= 0) {
      setFlag(flags, raw.slice(0, eq), raw.slice(eq + 1))
      continue
    }
    const next = argv[i + 1]
    if (!next || next.startsWith('--')) {
      setFlag(flags, raw, true)
      continue
    }
    i++
    setFlag(flags, raw, next)
  }
  return { positional, flags }
}

function stringFlag(flags: Flags, key: string): string | undefined {
  const value = flags[key]
  if (Array.isArray(value)) return value.at(-1)
  return typeof value === 'string' ? value : undefined
}

function printJson(value: unknown): void {
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`)
}

function assertName(value: string, source: string): void {
  if (!NAME_RE.test(value)) {
    throw new TelegramRuntimeError(
      `${source} must match /^[a-z][a-z0-9-]{0,31}$/, got "${value}"`,
    )
  }
}

function normalizeName(value: string): string {
  return value.trim().toLowerCase()
}

/** Normalize a telegram @username to its catalog/dir key form: trim, strip a
 *  leading '@' (operator convenience), lowercase (Telegram usernames are
 *  case-insensitive; on-disk keys must be deterministic). */
function normalizeBotUsername(value: string): string {
  return value.trim().replace(/^@/, '').toLowerCase()
}

function assertBotUsername(value: string, source: string): void {
  if (!BOT_USERNAME_RE.test(value)) {
    throw new TelegramRuntimeError(
      `${source} must be a telegram @username (5–32 chars, letters/digits/underscore, ` +
        `start with a letter), got "${value}"`,
    )
  }
}

function iapeerRoot(): string {
  // IAPEER_ROOT-aware (mirror of the foundation's resolveGlobalRoot): the env override
  // wins, else ~/.iapeer. Unset in production → identical to the historical behavior
  // (the live peer unchanged); set in a sandbox/test → the whole runtime (manifest, bots
  // registry, peers index) lands under the isolated root, in lockstep with manifest.ts.
  const override = process.env.IAPEER_ROOT?.trim()
  if (override) return override
  return join(homedir(), '.iapeer')
}

function globalTelegramRoot(): string {
  return join(iapeerRoot(), 'runtimes', RUNTIME)
}

function botsRoot(): string {
  return join(globalTelegramRoot(), 'bots')
}

function inboxRoot(): string {
  return join(globalTelegramRoot(), 'inbox')
}

function peersIndexPath(): string {
  return join(iapeerRoot(), 'peers-profiles.json')
}

function peerProfilePath(cwd = process.cwd()): string {
  return join(cwd, IAPEER_DIR, PEER_PROFILE_FILE)
}

function ensureScaffold(cwd = process.cwd()): void {
  mkdirSync(join(cwd, IAPEER_DIR, 'runtimes', RUNTIME), { recursive: true, mode: 0o700 })
  mkdirSync(join(cwd, IAPEER_DIR, 'plugins', 'telegram-runtime'), {
    recursive: true,
    mode: 0o700,
  })
  mkdirSync(globalTelegramRoot(), { recursive: true, mode: 0o700 })
  mkdirSync(botsRoot(), { recursive: true, mode: 0o700 })
  mkdirSync(inboxRoot(), { recursive: true, mode: 0o700 })
  mkdirSync(join(globalTelegramRoot(), 'logs'), { recursive: true, mode: 0o700 })
  mkdirSync(join(globalTelegramRoot(), 'cache'), { recursive: true, mode: 0o700 })
}

function readJsonFile<T>(path: string): T | null {
  if (!existsSync(path)) return null
  try {
    return JSON.parse(readFileSync(path, 'utf8')) as T
  } catch (err) {
    throw new TelegramRuntimeError(
      `${path} is invalid JSON: ${err instanceof Error ? err.message : String(err)}`,
    )
  }
}

function readPeerProfile(cwd = process.cwd()): PeerProfile | null {
  const raw = readJsonFile<Partial<PeerProfile> & { default_runtime?: string }>(peerProfilePath(cwd))
  if (!raw) return null
  if (typeof raw.personality !== 'string') {
    throw new TelegramRuntimeError(`${peerProfilePath(cwd)} personality is required`)
  }
  // Effective runtime: prefer the current `default_runtime`; fall back to the legacy
  // `runtime` mirror the foundation is retiring. Only the absence of BOTH is an error —
  // we no longer REQUIRE the legacy field (that throw is what blocked the mirror's
  // removal). The internal PeerProfile.runtime is populated from this resolved value, so
  // every downstream consumer (paneLogPath, transcript select, lifecycle) is unchanged.
  const effectiveRuntime =
    typeof raw.default_runtime === 'string' && raw.default_runtime
      ? raw.default_runtime
      : typeof raw.runtime === 'string'
        ? raw.runtime
        : undefined
  if (!effectiveRuntime) {
    throw new TelegramRuntimeError(
      `${peerProfilePath(cwd)} default_runtime (or legacy runtime) is required`,
    )
  }
  const personality = normalizeName(raw.personality)
  assertName(personality, 'personality')
  const runtimes = Array.isArray(raw.runtimes)
    ? raw.runtimes.filter((item): item is string => typeof item === 'string')
    : [effectiveRuntime]
  const intelligence: Intelligence = isIntelligence(raw.intelligence)
    ? raw.intelligence
    : defaultIntelligenceForRuntime(effectiveRuntime)
  return {
    personality,
    runtime: effectiveRuntime,
    runtimes: unique([effectiveRuntime, ...runtimes]),
    description: typeof raw.description === 'string' ? raw.description : '',
    intelligence,
    // expansion и interfaces round-trip'ятся verbatim (sanitize в точке
    // использования — resolveAliases): typed-парс здесь терял бы незнакомые
    // сиблинги при последующем writePeerProfile.
    ...(raw.expansion && typeof raw.expansion === 'object' && !Array.isArray(raw.expansion)
      ? { expansion: raw.expansion }
      : {}),
    ...(raw.interfaces && typeof raw.interfaces === 'object'
      ? { interfaces: raw.interfaces }
      : {}),
  }
}

function sanitizeAliases(input: Record<string, unknown>): Record<string, string> | undefined {
  const out: Record<string, string> = {}
  for (const [key, value] of Object.entries(input)) {
    if (typeof key !== 'string' || !key.startsWith('/')) continue
    if (typeof value !== 'string' || value.length === 0) continue
    out[key] = value
  }
  return Object.keys(out).length > 0 ? out : undefined
}

/**
 * Expand an operator slash-command into the corresponding peer-profile alias text.
 *
 * Per §3.5 IAPeer DECISIONS: when an operator (human source) sends a message that
 * exactly matches a key in the target peer's `aliases` map (after trimming
 * surrounding whitespace, no fuzzy matching), the *-runtime package substitutes
 * the message text with the alias value before IAP delivery. The peer-LLM never
 * sees the literal `/<command>` — only the expanded text. If no alias matches,
 * the original text is returned unchanged.
 *
 * The trim is intentional: Telegram clients sometimes append a trailing newline
 * to single-line messages, and operators sometimes type a leading space; treating
 * `\n/new` and `/new ` as `/new` matches operator intent.
 *
 * This function does not check the source. The caller is responsible for ensuring
 * the message originates from a human operator (in the Telegram inbound flow,
 * that filter is already applied through `fromId === ctx.ownerUserId`).
 */
export function expandAlias(text: string, aliases: Record<string, string> | undefined): string {
  if (!aliases) return text
  const key = text.trim()
  const expansion = aliases[key]
  return typeof expansion === 'string' && expansion.length > 0 ? expansion : text
}

/**
 * Resolve the effective alias map for a peer profile.
 *
 * Source is the top-level `expansion.aliases` section — private runtime-plugin
 * config, named for the mechanism so future runtimes (discord/matrix) read the
 * same map (design sync with iapeer 2026-06-11, topic aliases-section-design).
 * Two earlier transition homes — `interfaces.telegram.aliases` (telegram-bound,
 * 2026-06-07 contract split) and the original top-level `aliases` — were retired
 * by the fleet-wide migration to `expansion.aliases`; both read-fallbacks were
 * dropped once zero profiles carried them (interfaces.telegram.aliases
 * co-verified empty fleet-wide 2026-06-20).
 *
 * The section round-trips verbatim through readPeerProfile (unknown-field
 * preservation), so sanitizing happens here at the point of use, not at parse.
 */
export function resolveAliases(profile: PeerProfile | null): Record<string, string> | undefined {
  if (!profile) return undefined
  const canonical = profile.expansion?.aliases
  if (canonical && typeof canonical === 'object' && !Array.isArray(canonical)) {
    const sanitized = sanitizeAliases(canonical as Record<string, unknown>)
    if (sanitized) return sanitized
  }
  return undefined
}

function writePeerProfile(cwd: string, profile: PeerProfile): void {
  // Preserve unknown fields на round-trip. telegram-runtime PeerProfile type
  // содержит только runtime-relevant поля (personality/runtime/runtimes/
  // description/intelligence/expansion/interfaces); поля смежных контрактов
  // (например initial_prompt от Persistent-Peer §3.3.1) telegram-runtime не
  // парсит, но обязан сохранять — иначе interface bot/human команды молча
  // их теряют. Read-before-write merge поверх существующего файла.
  const path = peerProfilePath(cwd)
  let existing: Record<string, unknown> = {}
  try {
    if (existsSync(path)) {
      const raw = JSON.parse(readFileSync(path, 'utf8'))
      if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
        existing = raw as Record<string, unknown>
      }
    }
  } catch {
    // невалидный файл — пишем чистую typed версию (потери unknown нет, их и не было)
  }
  const merged: Record<string, unknown> = {
    ...existing,
    ...profile,
  }
  // Canonical runtime field is `default_runtime` — the foundation retired the legacy
  // `runtime` mirror (registry-side in iapeer 0.4.10). `...profile` carries the resolved
  // runtime; persist it as default_runtime and DROP the mirror so a local mutation
  // (interface/activity) never re-introduces the field readPeerProfile no longer
  // requires. The resolved value equals the existing default_runtime (it was read from
  // it), so identity round-trips unchanged — this only stops re-seeding the mirror.
  merged.default_runtime = profile.runtime
  delete merged.runtime
  writeJsonAtomic(path, merged)
}

function unique<T>(values: readonly T[]): T[] {
  const out: T[] = []
  for (const value of values) {
    if (!out.includes(value)) out.push(value)
  }
  return out
}

function ensureCurrentProfile(cwd = process.cwd()): PeerProfile {
  ensureScaffold(cwd)
  const existing = readPeerProfile(cwd)
  if (existing) {
    if (!existing.runtimes.includes(RUNTIME)) {
      const updated = { ...existing, runtimes: unique([...existing.runtimes, RUNTIME]) }
      writePeerProfile(cwd, updated)
      return updated
    }
    return existing
  }
  const personality = normalizeName(basename(cwd))
  assertName(personality, 'cwd basename')
  const profile: PeerProfile = {
    personality,
    runtime: RUNTIME,
    runtimes: [RUNTIME],
    description: '',
    intelligence: defaultIntelligenceForRuntime(RUNTIME),
  }
  writePeerProfile(cwd, profile)
  return profile
}

function telegramInterface(profile: PeerProfile): TelegramInterface {
  const interfaces = profile.interfaces
  const telegram = interfaces?.telegram
  if (!telegram || typeof telegram !== 'object') return {}
  return telegram as TelegramInterface
}

function setTelegramInterface(profile: PeerProfile, patch: TelegramInterface): PeerProfile {
  const current = telegramInterface(profile)
  return {
    ...profile,
    interfaces: {
      ...(profile.interfaces ?? {}),
      telegram: {
        ...current,
        ...patch,
      },
    },
  }
}

/** Resolve a peer's bot catalog key — the value that names its credential dir and
 *  keys inbound/outbound routing. Source is `bot_username` (normalized). Returns
 *  undefined for a peer with no telegram bot (e.g. the human owner). */
export function peerBotKey(profile: PeerProfile): string | undefined {
  const telegram = telegramInterface(profile)
  if (telegram.bot_username) return normalizeBotUsername(String(telegram.bot_username))
  return undefined
}

function readPeersIndex(): PeersIndex {
  const index = readJsonFile<PeersIndex>(peersIndexPath()) ?? { version: 1, peers: [] }
  // Resolve each registry peer's effective runtime: prefer `default_runtime`, fall back
  // to the legacy `runtime` mirror. Keeps PeerRecord.runtime populated even for a peer
  // that never hydrates (missing cwd/local profile) once the foundation drops the mirror.
  const peers = (Array.isArray(index.peers) ? index.peers : []).map(peer => {
    const dr = (peer as { default_runtime?: unknown }).default_runtime
    const runtime = typeof dr === 'string' && dr ? dr : peer.runtime
    return runtime === peer.runtime ? peer : { ...peer, runtime }
  })
  return { ...index, peers }
}

function hydratePeerRecord(peer: PeerRecord): PeerRecord {
  if (!peer.cwd) return peer
  const profile = readPeerProfile(peer.cwd)
  if (!profile) return peer
  return {
    ...peer,
    personality: profile.personality,
    runtime: profile.runtime,
    runtimes: profile.runtimes,
    description: profile.description,
    intelligence: profile.intelligence,
    interfaces: profile.interfaces,
  }
}

function readPeerDirectory(): PeerDirectory {
  const index = readPeersIndex()
  const peers = (Array.isArray(index.peers) ? index.peers : []).map(hydratePeerRecord)
  const byPersonality = new Map<string, PeerRecord>()
  const byTelegramBot = new Map<string, PeerRecord>()
  for (const peer of peers) {
    if (!peer || typeof peer.personality !== 'string') continue
    byPersonality.set(peer.personality, peer)
    const key = peerBotKey(peer)
    if (key) byTelegramBot.set(key, peer)
  }
  return { peers, byPersonality, byTelegramBot }
}

function findPeerProfilePath(personality: string): string {
  const current = readPeerProfile(process.cwd())
  if (current?.personality === personality) return peerProfilePath(process.cwd())
  const record = readPeerDirectory().byPersonality.get(personality)
  if (!record?.cwd) {
    throw new TelegramRuntimeError(
      `peer "${personality}" is not in ${peersIndexPath()}; run IAP from that peer cwd first`,
    )
  }
  return peerProfilePath(record.cwd)
}

function readEnvFile(path: string): Record<string, string> {
  const out: Record<string, string> = {}
  if (!existsSync(path)) return out
  for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
    const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line)
    if (m) out[m[1]] = m[2]
  }
  return out
}

function writeEnvFile(path: string, env: Record<string, string>): void {
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
  const lines = Object.entries(env)
    .filter(([, value]) => value !== undefined)
    .map(([key, value]) => `${key}=${value}`)
  const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`
  writeFileSync(tmp, `${lines.join('\n')}\n`, { mode: 0o600 })
  renameSync(tmp, path)
  chmodSync(path, 0o600)
}

function botDir(botKey: string): string {
  return join(botsRoot(), botKey)
}

function botEnvPath(botKey: string): string {
  return join(botDir(botKey), '.env')
}

function botLockPath(botKey: string): string {
  return join(botDir(botKey), 'runtime.lock')
}

function processIsAlive(pid: number): boolean {
  if (!Number.isInteger(pid) || pid <= 0) return false
  try {
    process.kill(pid, 0)
    return true
  } catch (err) {
    if (
      err &&
      typeof err === 'object' &&
      'code' in err &&
      (err as { code?: unknown }).code === 'EPERM'
    ) {
      return true
    }
    return false
  }
}

function acquireBotLock(botKey: string): ReleaseLock {
  const path = botLockPath(botKey)
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
  try {
    const previousPid = Number(readFileSync(path, 'utf8').trim())
    if (processIsAlive(previousPid)) {
      throw new TelegramRuntimeError(`bot "${botKey}" is already owned by pid ${previousPid}`)
    }
    rmSync(path, { force: true })
  } catch (err) {
    if (err instanceof TelegramRuntimeError) throw err
  }

  let fd: number
  try {
    fd = openSync(path, 'wx', 0o600)
  } catch (err) {
    throw new TelegramRuntimeError(
      `bot "${botKey}" lock is busy at ${path}: ${formatError(err)}`,
    )
  }
  writeFileSync(fd, `${process.pid}\n`)
  closeSync(fd)
  return () => {
    try {
      if (readFileSync(path, 'utf8').trim() === String(process.pid)) unlinkSync(path)
    } catch {
      // Best-effort cleanup only; stale lock detection runs on next startup.
    }
  }
}

function loadCredential(botKey: string): BotCredential {
  const env = readEnvFile(botEnvPath(botKey))
  const token = env.TELEGRAM_BOT_TOKEN
  if (!token) {
    throw new TelegramRuntimeError(`${botEnvPath(botKey)} missing TELEGRAM_BOT_TOKEN`)
  }
  return {
    key: botKey,
    token,
    role: env.TELEGRAM_BOT_ROLE?.trim() || undefined,
  }
}

function listBotKeys(): string[] {
  try {
    return readdirSync(botsRoot(), { withFileTypes: true })
      // A credential dir is keyed by the bot @username (BOT_USERNAME_RE).
      .filter(entry => entry.isDirectory() && BOT_USERNAME_RE.test(entry.name))
      .map(entry => entry.name)
      .sort()
  } catch {
    return []
  }
}

function runtimeFetch(url: string, init?: RequestInit): Promise<Response> {
  const proxy =
    process.env.TELEGRAM_RUNTIME_PROXY ??
    process.env.HTTPS_PROXY ??
    process.env.HTTP_PROXY ??
    ''
  return proxy
    ? fetch(url, { ...(init ?? {}), proxy } as RequestInit & { proxy: string })
    : fetch(url, init)
}

// ── getMe probe for `bot add` ───────────────────────────────────────────
// The token's truth lives in Telegram, not in operator flags: `bot add` validates
// the token via getMe and persists the REAL @username into the bot's .env
// (TELEGRAM_BOT_USERNAME) — the connect-flow activation line («напишите боту
// @<username>») reads it from there. The two failure modes are deliberately
// distinct: `invalid-token` (Telegram answered and said no) is always fatal,
// `network` (Telegram unreachable) can be overridden with an explicit
// --username (offline escape hatch). getMe is read-only and does NOT consume
// updates — safe to call while the live poller holds the same token.
export type BotIdentityProbe =
  | { ok: true; username: string }
  | { ok: false; reason: 'invalid-token' | 'network'; detail: string }

export async function probeBotIdentity(
  token: string,
  fetchImpl: (url: string, init?: RequestInit) => Promise<Response> = runtimeFetch,
): Promise<BotIdentityProbe> {
  let response: Response
  try {
    // Scoped one-shot timeout (NOT global in runtimeFetch — long-polling getUpdates
    // legitimately hangs ~30s; this signal lives and dies with this single call).
    response = await fetchImpl(`https://api.telegram.org/bot${token}/getMe`, {
      signal: AbortSignal.timeout(10_000),
    })
  } catch (error) {
    return { ok: false, reason: 'network', detail: error instanceof Error ? error.message : String(error) }
  }
  let body: { ok?: boolean; description?: string; result?: { username?: string } } | undefined
  try {
    body = (await response.json()) as typeof body
  } catch {
    body = undefined
  }
  if (!body?.ok) {
    return {
      ok: false,
      reason: 'invalid-token',
      detail: body?.description ?? `HTTP ${response.status}`,
    }
  }
  const username = body.result?.username
  if (!username) {
    return { ok: false, reason: 'invalid-token', detail: 'getMe result has no username' }
  }
  return { ok: true, username }
}

function chunkText(text: string): string[] {
  if (text.length <= MAX_TELEGRAM_TEXT) return [text]
  const out: string[] = []
  let rest = text
  while (rest.length > MAX_TELEGRAM_TEXT) {
    let cut = rest.lastIndexOf('\n\n', MAX_TELEGRAM_TEXT)
    if (cut < MAX_TELEGRAM_TEXT / 2) cut = rest.lastIndexOf('\n', MAX_TELEGRAM_TEXT)
    if (cut < MAX_TELEGRAM_TEXT / 2) cut = rest.lastIndexOf(' ', MAX_TELEGRAM_TEXT)
    if (cut <= 0) cut = MAX_TELEGRAM_TEXT
    out.push(rest.slice(0, cut))
    rest = rest.slice(cut).replace(/^\n+/, '')
  }
  if (rest) out.push(rest)
  return out
}

// ── Markdown → Telegram MarkdownV2 ──────────────────────────────────────
// Agents write GitHub-Flavored Markdown (GFM) — the "natural" markdown they
// know. Telegram's MarkdownV2 is stricter: every special char outside a
// formatting span (_ * [ ] ( ) ~ ` > # + - = | { } . !) must be backslash-
// escaped or the Bot API rejects the WHOLE message (400 "can't parse
// entities") — it never arrives. This tokenizer translates GFM → valid
// MarkdownV2: bold/italic/strike/code/links/spoilers map to TG syntax, every
// other special char is escaped. Ported from the legacy telegram-connect
// channel, where it was proven in production. On a parse error the send path
// falls back to plain text (see sendChunkResilient), so even a converter miss
// never loses the message — at worst it arrives unformatted instead of lost.
const MDV2_PLAIN_SPECIAL = '_*[]()~`>#+-=|{}.!'

function isWordChar(ch: string | undefined): boolean {
  if (!ch) return false
  return /[\p{L}\p{N}_]/u.test(ch)
}

function escapeCodeContent(text: string): string {
  // Inside code spans Telegram MarkdownV2 only requires escaping `\` and `` ` ``.
  let out = ''
  for (const c of text) {
    if (c === '\\' || c === '`') out += '\\' + c
    else out += c
  }
  return out
}

// MarkdownV2 has no heading syntax — render GFM headings (`# Title`) as bold so
// they don't arrive as a literal "# Title". Runs before the tokenizer; the
// emitted **Title** is then mapped to TG bold by escapeMarkdownV2Auto.
function headingsToBold(text: string): string {
  return text.replace(
    /^[ \t]{0,3}(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/gm,
    (_m, _hashes, title) => `**${title}**`,
  )
}

function escapeMarkdownV2Auto(text: string): string {
  let out = ''
  let i = 0
  const n = text.length

  while (i < n) {
    const c = text[i]
    const prev = i > 0 ? text[i - 1] : undefined

    // 1. Fenced code block: ```...```
    if (c === '`' && text.startsWith('```', i)) {
      const end = text.indexOf('```', i + 3)
      if (end !== -1) {
        const inner = text.slice(i + 3, end)
        out += '```' + escapeCodeContent(inner) + '```'
        i = end + 3
        continue
      }
    }

    // 2. Inline code: `...`
    if (c === '`') {
      const end = text.indexOf('`', i + 1)
      if (end !== -1 && end !== i + 1) {
        const inner = text.slice(i + 1, end)
        out += '`' + escapeCodeContent(inner) + '`'
        i = end + 1
        continue
      }
    }

    // 3. Spoiler: ||...||
    if (c === '|' && text.startsWith('||', i)) {
      const end = text.indexOf('||', i + 2)
      if (end !== -1) {
        const inner = text.slice(i + 2, end)
        out += '||' + escapeMarkdownV2Auto(inner) + '||'
        i = end + 2
        continue
      }
    }

    // 4. Link: [text](url) with balanced parens inside url.
    if (c === '[') {
      const closeBracket = text.indexOf(']', i + 1)
      if (closeBracket !== -1 && text[closeBracket + 1] === '(') {
        let depth = 1
        let j = closeBracket + 2
        while (j < n && depth > 0) {
          if (text[j] === '\\' && j + 1 < n) {
            j += 2
            continue
          }
          if (text[j] === '(') depth++
          else if (text[j] === ')') depth--
          if (depth === 0) break
          j++
        }
        if (depth === 0 && j < n) {
          const linkText = text.slice(i + 1, closeBracket)
          const url = text.slice(closeBracket + 2, j)
          // Inside link text — recurse for nested formatting.
          // Inside URL — escape only `\` and `)` per Telegram spec.
          const urlEscaped = url.replace(/[\\)]/g, '\\$&')
          out += '[' + escapeMarkdownV2Auto(linkText) + '](' + urlEscaped + ')'
          i = j + 1
          continue
        }
      }
    }

    // 5. Bold: **text** (GFM double-asterisk). Single * stays literal.
    if (c === '*' && text[i + 1] === '*') {
      const end = text.indexOf('**', i + 2)
      if (end !== -1 && end !== i + 2) {
        const inner = text.slice(i + 2, end)
        out += '*' + escapeMarkdownV2Auto(inner) + '*'
        i = end + 2
        continue
      }
    }

    // 6. Strikethrough: ~~text~~ (GFM double-tilde). Single ~ stays literal.
    if (c === '~' && text[i + 1] === '~') {
      const end = text.indexOf('~~', i + 2)
      if (end !== -1 && end !== i + 2) {
        const inner = text.slice(i + 2, end)
        out += '~' + escapeMarkdownV2Auto(inner) + '~'
        i = end + 2
        continue
      }
    }

    // 7. Italic: _..._ ONLY at word boundary. snake_case_var stays literal.
    if (c === '_' && !isWordChar(prev)) {
      let matched = false
      for (let j = i + 1; j < n; j++) {
        if (text[j] === '\\') {
          j++
          continue
        }
        if (text[j] === '_') {
          const after = j + 1 < n ? text[j + 1] : undefined
          if (!isWordChar(after) && j > i + 1) {
            const inner = text.slice(i + 1, j)
            out += '_' + escapeMarkdownV2Auto(inner) + '_'
            i = j + 1
            matched = true
          }
          break
        }
      }
      if (matched) continue
    }

    // 8. Plain char — escape if special, else pass through.
    if (c === '\\') {
      // Pre-escaped pair from caller? Pass through.
      if (i + 1 < n && MDV2_PLAIN_SPECIAL.includes(text[i + 1])) {
        out += '\\' + text[i + 1]
        i += 2
        continue
      }
      // Lone backslash → escape it.
      out += '\\\\'
      i++
      continue
    }
    if (MDV2_PLAIN_SPECIAL.includes(c)) {
      out += '\\' + c
    } else {
      out += c
    }
    i++
  }
  return out
}

// Public entry: agent text (GFM) → valid Telegram MarkdownV2.
export function toTelegramMarkdownV2(text: string): string {
  return escapeMarkdownV2Auto(headingsToBold(text))
}

// A client-side 400 from the Bot API on the MarkdownV2 attempt — the signal to
// resend the chunk as PLAIN text instead of losing it. Two formatting-class
// failures qualify: the converter produced something MarkdownV2 can't parse
// ("can't parse entities"), or backslash-escaping pushed the chunk past
// Telegram's 4096-char cap ("...is too long" — chunkText splits the ORIGINAL
// text at 4096, but escaping only adds characters, so a tag/punctuation-heavy
// chunk can overflow once escaped). Both are cured by sending the original
// (shorter, unescaped) text plain. Transport failures (timeout/network/429/5xx)
// are NOT cured by a plain resend and must bubble to the retry loop, so they
// are deliberately excluded here.
function isFormattingError(err: unknown): boolean {
  return (
    err instanceof GrammyError &&
    err.error_code === 400 &&
    /can'?t parse entities|is too long/i.test(err.description ?? '')
  )
}

function safeFilePart(value: string): string {
  return value.replace(/[^a-zA-Z0-9_-]/g, '') || 'file'
}

async function downloadTelegramFile(args: {
  bot: Bot
  token: string
  botKey: string
  fileId: string
  uniqueId?: string
  kind: string
}): Promise<string> {
  const file = await args.bot.api.getFile(args.fileId)
  if (!file.file_path) throw new TelegramRuntimeError('Telegram returned no file_path')
  const url = `https://api.telegram.org/file/bot${args.token}/${file.file_path}`
  const response = await runtimeFetch(url)
  if (!response.ok) {
    throw new TelegramRuntimeError(`Telegram file download failed: HTTP ${response.status}`)
  }
  const rawExt = file.file_path.includes('.') ? file.file_path.split('.').pop()! : args.kind
  const ext = rawExt.replace(/[^a-zA-Z0-9]/g, '') || args.kind
  const dir = join(inboxRoot(), args.botKey)
  mkdirSync(dir, { recursive: true, mode: 0o700 })
  const path = join(
    dir,
    `${Date.now()}-${safeFilePart(args.uniqueId ?? file.file_unique_id ?? args.fileId)}.${ext}`,
  )
  writeFileSync(path, Buffer.from(await response.arrayBuffer()), { mode: 0o600 })
  return path
}

// Map a (lowercased) file extension to the Telegram Bot API send method and the
// multipart field name it expects. Pure + exported so the routing is unit-testable
// without touching the network.
export function selectSendMethod(ext: string): {
  method: 'sendVoice' | 'sendAudio' | 'sendAnimation' | 'sendPhoto' | 'sendDocument'
  field: 'voice' | 'audio' | 'animation' | 'photo' | 'document'
} {
  if (VOICE_EXTS.has(ext)) return { method: 'sendVoice', field: 'voice' }
  if (AUDIO_EXTS.has(ext)) return { method: 'sendAudio', field: 'audio' }
  if (ANIMATION_EXTS.has(ext)) return { method: 'sendAnimation', field: 'animation' }
  if (PHOTO_EXTS.has(ext)) return { method: 'sendPhoto', field: 'photo' }
  return { method: 'sendDocument', field: 'document' }
}

async function sendFileViaRawApi(args: {
  credential: BotCredential
  chatId: string
  filePath: string
}): Promise<number> {
  const stat = statSync(args.filePath)
  if (stat.size > 50 * 1024 * 1024) {
    throw new TelegramRuntimeError(`file too large for Telegram Bot API: ${args.filePath}`)
  }
  const ext = extname(args.filePath).toLowerCase()
  const { method, field } = selectSendMethod(ext)
  logOutbound('attachment.send', { file: basename(args.filePath), ext, method, bytes: stat.size })
  const form = new FormData()
  form.append('chat_id', args.chatId)
  form.append(field, new File([readFileSync(args.filePath)], basename(args.filePath)))
  const response = await runtimeFetch(
    `https://api.telegram.org/bot${args.credential.token}/${method}`,
    { method: 'POST', body: form },
  )
  const json = (await response.json()) as {
    ok?: boolean
    description?: string
    result?: { message_id?: number }
  }
  if (!json.ok) {
    throw new TelegramRuntimeError(`${method} failed: ${json.description ?? 'unknown error'}`)
  }
  return Number(json.result?.message_id ?? 0)
}

// ─────────────────────────────────────────────────────────────────────────────
// IAP envelope parser — near-verbatim port of the core codec's decoder half
// (iapeer src/codec/index.ts, envelope-compaction F, 0.4.86): CDATA-aware scans
// on BOTH tags (В37), В38 open-tag validation, read-both attribute/tag names,
// name-anchored attr lookup. ONE deliberate divergence: the CR→LF fold stays in
// parseIapEnvelope — the core moved it out as a transport concern, and this
// parser IS the telegram transport adapter (raw pty stdin surfaces bare CRs).
// ─────────────────────────────────────────────────────────────────────────────

/** NAME-ANCHORED attribute lookup. The anchor `(^|\s)` is load-bearing: the old
 *  unanchored regex made `runtime="` match the TAIL of `from-runtime="…"` (and
 *  `intelligence="` the tail of `from-intelligence="…"`) — a latent mine the
 *  read-both decode below would have stepped on (the short-name lookup must NOT
 *  be satisfied by a legacy long-name attribute). */
function attrValue(attrs: string, name: string): string | undefined {
  const re = new RegExp(`(?:^|\\s)${name}="([^"]*)"`)
  const m = re.exec(attrs)
  return m ? unescapeAttr(m[1]) : undefined
}

function unescapeAttr(value: string): string {
  // Reverse of the sender's escapeAttr. Order matters: &amp; LAST so an escaped
  // "&amp;lt;" in the source does not get double-decoded.
  return value
    .replace(/&quot;/g, '"')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&amp;/g, '&')
}

const CDATA_OPEN = '<![CDATA['
const CDATA_CLOSE = ']]>'

/**
 * Scan from `start` and return the index of the first occurrence of `needle`
 * that lies OUTSIDE any CDATA section, or -1 if none (or if an unterminated
 * CDATA section is hit — meaning the buffer is incomplete and the caller should
 * wait for more input). CDATA sections are skipped wholesale: `]]>` inside them
 * is the section terminator, never a match for `needle`. The IAP sender
 * CDATA-wraps the message and attachments, so a body that literally contains
 * "</message>" or "</iap>" (an agent quoting the envelope's own tag names) must
 * NOT be mistaken for the structural closing tag.
 */
function indexOfOutsideCdata(buffer: string, needle: string, start: number): number {
  let i = start
  while (i < buffer.length) {
    if (buffer.startsWith(CDATA_OPEN, i)) {
      const term = buffer.indexOf(CDATA_CLOSE, i + CDATA_OPEN.length)
      if (term < 0) return -1 // unterminated CDATA → incomplete buffer
      i = term + CDATA_CLOSE.length
      continue
    }
    if (buffer.startsWith(needle, i)) return i
    i++
  }
  return -1
}

/**
 * Extract and decode the content of `<tag>…</tag>`, treating the body as a
 * sequence of CDATA sections (and any stray raw text) and concatenating them.
 * Concatenating adjacent CDATA sections is exactly what reverses the
 * `]]>` → `]]]]><![CDATA[>` split the sender performs, so this both
 * (a) ignores `</tag>` / `</iap>` that appear inside CDATA, and
 * (b) reconstructs a literal `]]>` in the original payload.
 * В37 — the OPEN tag is located CDATA-aware too (the close-side already was): a
 * message whose CDATA body QUOTES `<attachments>…</attachments>` otherwise
 * minted phantom attachments, and a quoted `<message>` corrupted the decoded
 * message. Returns undefined when the open tag is absent or the close tag is
 * never reached outside CDATA.
 */
function readTagContent(xml: string, tag: string): string | undefined {
  const open = `<${tag}>`
  const close = `</${tag}>`
  const openIdx = indexOfOutsideCdata(xml, open, 0)
  if (openIdx < 0) return undefined
  let i = openIdx + open.length
  let out = ''
  while (i < xml.length) {
    if (xml.startsWith(CDATA_OPEN, i)) {
      const term = xml.indexOf(CDATA_CLOSE, i + CDATA_OPEN.length)
      if (term < 0) {
        // Unterminated CDATA — malformed; treat the remainder as raw content.
        out += xml.slice(i + CDATA_OPEN.length)
        return out
      }
      out += xml.slice(i + CDATA_OPEN.length, term)
      i = term + CDATA_CLOSE.length
      continue
    }
    if (xml.startsWith(close, i)) return out
    out += xml[i]
    i++
  }
  return undefined // close tag never found
}

/** READ-BOTH decoder: accepts the legacy wire names (`from-personality` /
 *  `from-runtime` / `from-intelligence`, `<message>`) AND the compact
 *  presentation names (`from` / `runtime` / `intelligence`, `<msg>`) — so any
 *  envelope form ever emitted on this host decodes, and a future wire flip to
 *  the short names lands here as a no-op. Short names win when both are present. */
export function parseIapEnvelope(xml: string): IapEnvelope {
  // Normalize line endings before parsing (transport concern — see the section
  // header): a raw-mode pty stdin can surface bare CRs instead of LFs, and
  // Telegram does not render \r as a line break, so multi-line replies (and code
  // blocks) would collapse to one paragraph. Fold \r\n and lone \r → \n once,
  // over the whole envelope: message and attachments both come out LF-terminated.
  xml = xml.replace(/\r\n?/g, '\n')
  const open = /^<iap\s+([^>]*)>/.exec(xml.trim())
  if (!open) throw new TelegramRuntimeError('invalid IAP envelope: missing <iap ...>')
  const fromPersonality = attrValue(open[1], 'from') ?? attrValue(open[1], 'from-personality')
  const fromRuntime = attrValue(open[1], 'runtime') ?? attrValue(open[1], 'from-runtime')
  if (!fromPersonality || !fromRuntime) {
    throw new TelegramRuntimeError('invalid IAP envelope: missing from-personality/from-runtime')
  }
  const fromIntelligence = normalizeIntelligenceValue(
    attrValue(open[1], 'intelligence') ?? attrValue(open[1], 'from-intelligence'),
  )
  const message = readTagContent(xml, 'message') ?? readTagContent(xml, 'msg')
  if (message === undefined) throw new TelegramRuntimeError('invalid IAP envelope: missing message')
  const attachmentsRaw = readTagContent(xml, 'attachments')
  const sentAt = attrValue(open[1], 'ts')
  return {
    fromPersonality,
    fromRuntime,
    ...(fromIntelligence ? { fromIntelligence } : {}),
    ...(sentAt ? { sentAt } : {}),
    topic: attrValue(open[1], 'topic'),
    attachments: attachmentsRaw
      ? attachmentsRaw.split(/\r?\n/).map(item => item.trim()).filter(Boolean)
      : [],
    message,
  }
}

// В38 — a real envelope's open tag is bounded: personality/runtime ≤32 chars each,
// topic ≤200, intelligence one word, plus attr names and escaping. 1 KiB is far above
// any legitimate open tag; a longer '>'-less run after `<iap ` is prose, not a tag.
const MAX_OPEN_TAG_LEN = 1024

/** Classify the text at a `<iap ` occurrence: a complete VALID open tag (required attrs
 *  present), a complete-but-INVALID one / overlong tagless run (prose that merely contains
 *  the marker), or INCOMPLETE (no '>' yet within bounds — wait for the next chunk). */
function openTagVerdict(candidate: string): 'valid' | 'invalid' | 'incomplete' {
  const m = /^<iap\s+([^>]*)>/.exec(candidate)
  if (!m) {
    const unclosed = candidate.indexOf('>') < 0
    return unclosed && candidate.length <= MAX_OPEN_TAG_LEN ? 'incomplete' : 'invalid'
  }
  if (m[0].length > MAX_OPEN_TAG_LEN) return 'invalid'
  // Read-both: a real open tag carries the required pair under EITHER naming —
  // legacy wire (from-personality/from-runtime) or compact presentation
  // (from/runtime, name-anchored so a legacy tail never satisfies it).
  const hasPersonality = m[1].includes('from-personality="') || /(?:^|\s)from="/.test(m[1])
  const hasRuntime = m[1].includes('from-runtime="') || /(?:^|\s)runtime="/.test(m[1])
  return hasPersonality && hasRuntime ? 'valid' : 'invalid'
}

/**
 * Pull complete `<iap …>…</iap>` envelopes out of a streaming buffer.
 * The closing `</iap>` is located CDATA-aware, so an envelope whose message
 * body contains the literal text `</iap>` is not truncated. `rest` holds the
 * trailing bytes that do not yet form a complete envelope (incl. an envelope
 * still mid-CDATA), to be prepended to the next chunk.
 *
 * В38 — a FALSE start (prose that merely contains `<iap `, e.g. a quoted tool
 * description) is never committed to: the open tag is validated first, and a
 * candidate whose decode fails RESYNCS one char forward instead of being kept
 * (which used to either swallow the NEXT real envelope into a mis-attributed
 * blob, or park the buffer forever waiting for a close that never comes).
 */
export function extractIapEnvelopes(buffer: string): { envelopes: string[]; rest: string } {
  const envelopes: string[] = []
  let rest = buffer
  while (true) {
    const start = rest.indexOf('<iap ')
    if (start < 0) {
      // Keep a small tail in case `<iap ` is split across chunk boundaries.
      return { envelopes, rest: rest.slice(Math.max(0, rest.length - '<iap '.length)) }
    }
    if (start > 0) rest = rest.slice(start)
    const verdict = openTagVerdict(rest)
    if (verdict === 'incomplete') return { envelopes, rest } // open tag split across chunks → wait
    if (verdict === 'invalid') {
      rest = rest.slice(1) // false start: skip past this '<' and rescan for the next '<iap '
      continue
    }
    const close = indexOfOutsideCdata(rest, '</iap>', '<iap '.length)
    if (close < 0) return { envelopes, rest } // incomplete (or mid-CDATA) → wait
    const envelopeEnd = close + '</iap>'.length
    const candidate = rest.slice(0, envelopeEnd)
    try {
      parseIapEnvelope(candidate)
    } catch {
      // Structurally envelope-shaped but undecodable → a false start after all;
      // resync so a REAL envelope inside/behind the candidate is still found.
      rest = rest.slice(1)
      continue
    }
    envelopes.push(candidate)
    rest = rest.slice(envelopeEnd)
  }
}

function iapEnv(owner: PeerProfile): NodeJS.ProcessEnv {
  return {
    ...process.env,
    PEER_PERSONALITY: owner.personality,
    PEER_RUNTIME: RUNTIME,
    PEER_IDENTITY: `${RUNTIME}-${owner.personality}`,
  }
}

export type IapSendResult =
  | { ok: true; stdout: string; stderr: string }
  | { ok: false; detail: string; stdout: string; stderr: string; timedOut?: boolean; timeoutMs?: number }

function cleanIapSendDetail(detail: string): string {
  return detail
    .replace(/^\s*iapeer\s+send:\s*/i, '')
    .replace(/^\s*iap\s+send\s+failed:\s*/i, '')
    .replace(/\s+/g, ' ')
    .trim()
}

function parseLogfmtValue(line: string, key: string): string | undefined {
  const re = new RegExp(`(?:^|\\s)${key}=(?:"((?:\\\\.|[^"\\\\])*)"|(\\S+))`)
  const m = re.exec(line)
  if (!m) return undefined
  if (m[1] !== undefined) return m[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\')
  return m[2]
}

// Accept both today's CLI shape (non-zero + stderr "iapeer send: <err>") and
// the lower-level/result-shaped vocabulary from the daemon/log path
// (ok=false err=..., or a JSON line with ok:false). This keeps the bridge on the
// contract word ("ok=false + err") instead of one concrete CLI presentation.
function parseIapSendNotOk(stdout: string, stderr: string): string | null {
  const combined = `${stdout}\n${stderr}`
  for (const rawLine of combined.split(/\r?\n/)) {
    const line = rawLine.trim()
    if (!line) continue
    if (line.startsWith('{') && line.endsWith('}')) {
      try {
        const obj = JSON.parse(line) as Record<string, unknown>
        if (obj.ok === false) {
          const detail = obj.err ?? obj.error ?? obj.reason ?? obj.detail
          return cleanIapSendDetail(typeof detail === 'string' && detail ? detail : 'delivery failed')
        }
      } catch {}
    }
    if (/\bok=false\b/.test(line)) {
      const detail =
        parseLogfmtValue(line, 'err') ??
        parseLogfmtValue(line, 'error') ??
        parseLogfmtValue(line, 'reason') ??
        parseLogfmtValue(line, 'detail')
      return cleanIapSendDetail(detail ?? 'delivery failed')
    }
  }
  return null
}

// Parse the `woke` flag from a SUCCESSFUL `iapeer send` (the result line
// `{"ok":true,...,"woke":<bool>}`, or the logfmt `ok=true woke=<bool>` shape).
// woke=false is the LIVE-session pty-injection path — iapeer pastes the envelope
// into the already-running session and confirms landing by an mtime-advance
// proxy that an ALREADY-ACTIVE target turn can satisfy falsely (the false-OK
// behind the 2026-06-23 silent-loss incident: a paste dropped at a turn boundary
// while the target's own turn kept the pane-log/transcript advancing). The bridge
// cannot re-verify delivery, but logging `woke` makes that at-risk path auditable.
// Returns undefined when stdout carries no parseable result.
export function parseIapSendWoke(stdout: string): boolean | undefined {
  for (const rawLine of stdout.split(/\r?\n/)) {
    const line = rawLine.trim()
    if (!line) continue
    if (line.startsWith('{') && line.endsWith('}')) {
      try {
        const obj = JSON.parse(line) as Record<string, unknown>
        if (typeof obj.woke === 'boolean') return obj.woke
      } catch {}
    }
    const lf = parseLogfmtValue(line, 'woke')
    if (lf === 'true') return true
    if (lf === 'false') return false
  }
  return undefined
}

function formatTimeoutSeconds(ms: number): number {
  return Math.max(1, Math.ceil(ms / 1000))
}

export function iapDeliveryFailureVerdict(result: Extract<IapSendResult, { ok: false }>): string {
  if (result.timedOut) {
    return `not delivered: delivery timed out after ${formatTimeoutSeconds(result.timeoutMs ?? IAP_SEND_TIMEOUT_MS)}s — check the session`
  }
  return `not delivered: ${cleanIapSendDetail(result.detail) || 'delivery failed'}`
}

export async function runIapSendCommand(args: {
  bin: string
  cwd: string
  env: NodeJS.ProcessEnv
  targetPersonality: string
  message: string
  attachments: string[]
  timeoutMs?: number
}): Promise<IapSendResult> {
  const timeoutMs = args.timeoutMs ?? IAP_SEND_TIMEOUT_MS
  const tmp = join(tmpdir(), `telegram-runtime-${process.pid}-${Date.now()}-${randomUUID()}.txt`)
  writeFileSync(tmp, args.message || '(message)', { mode: 0o600 })
  try {
    const argv = ['send', args.targetPersonality, '--message-file', tmp]
    for (const attachment of args.attachments) {
      argv.push('--attachment', attachment)
    }
    return await new Promise<IapSendResult>(resolve => {
      let stdout = ''
      let stderr = ''
      let settled = false
      let child: ReturnType<typeof spawn> | undefined
      const finish = (result: IapSendResult) => {
        if (settled) return
        settled = true
        clearTimeout(timer)
        resolve(result)
      }
      const timer = setTimeout(() => {
        try {
          child?.kill('SIGKILL')
        } catch {}
        finish({
          ok: false,
          detail: `delivery timed out after ${formatTimeoutSeconds(timeoutMs)}s`,
          stdout,
          stderr,
          timedOut: true,
          timeoutMs,
        })
      }, timeoutMs)
      try {
        child = spawn(args.bin, argv, {
          cwd: args.cwd,
          env: args.env,
          stdio: ['ignore', 'pipe', 'pipe'],
        })
      } catch (err) {
        finish({
          ok: false,
          detail: err instanceof Error ? err.message : String(err),
          stdout,
          stderr,
        })
        return
      }
      child.stdout?.on('data', data => (stdout += String(data)))
      child.stderr?.on('data', data => (stderr += String(data)))
      child.on('error', err => finish({ ok: false, detail: err.message, stdout, stderr }))
      child.on('close', status => {
        const notOk = parseIapSendNotOk(stdout, stderr)
        if (notOk) {
          finish({ ok: false, detail: notOk, stdout, stderr })
          return
        }
        if (status === 0) {
          finish({ ok: true, stdout, stderr })
          return
        }
        const detail = cleanIapSendDetail((stderr || stdout || `exit ${status}`).trim())
        finish({ ok: false, detail, stdout, stderr })
      })
    })
  } finally {
    rmSync(tmp, { force: true })
  }
}

function runIapSend(
  ctx: RuntimeContext,
  targetPersonality: string,
  message: string,
  attachments: string[],
): Promise<IapSendResult> {
  return runIapSendCommand({
    bin: ctx.iapBin,
    cwd: ctx.cwd,
    env: iapEnv(ctx.owner),
    targetPersonality,
    message,
    attachments,
  })
}

let inboundIapQueue = Promise.resolve()

function enqueueIapSend(task: () => Promise<void> | void): void {
  inboundIapQueue = inboundIapQueue
    .then(async () => task())
    .catch(err => {
      process.stderr.write(`telegram-runtime: inbound delivery failed: ${formatError(err)}\n`)
    })
}

let outboundTelegramQueue = Promise.resolve()
// Depth of the serial outbound queue: incremented at enqueue, decremented when
// the task settles. A growing depth with no matching `queue.dequeue` is the
// fingerprint of a wedged queue (a never-settling send blocking everything
// behind it) — exactly the failure that returns ok to send_to_peer while the
// human receives nothing.
let outboundQueueDepth = 0

function enqueueOutbound(task: () => Promise<void> | void): void {
  outboundQueueDepth++
  logOutbound('queue.enqueue', { depth: outboundQueueDepth })
  outboundTelegramQueue = outboundTelegramQueue
    .then(async () => task())
    .catch(err => {
      process.stderr.write(`telegram-runtime: outbound delivery failed: ${formatError(err)}\n`)
    })
    .finally(() => {
      outboundQueueDepth--
      logOutbound('queue.dequeue', { depth: outboundQueueDepth })
    })
}

function formatError(err: unknown): string {
  return err instanceof Error ? err.message : String(err)
}

// Structured observability for the outbound Telegram path. Every send emits a
// timestamped one-line JSON event to stderr (captured by launchd in
// .iapeer/logs/persistent-peer/launchd-stderr.log). This is the channel that
// was empty during the outbound-stall incidents — so the absence of a
// `chunk.error`/`chunk.timeout` after a `chunk.start` is itself the signal that
// the call hung without ever throwing (vs. a thrown 429, which would surface as
// `chunk.error` with tgCode:429). Disable with TELEGRAM_OUTBOUND_LOG=0.
const OUTBOUND_LOG_ENABLED = process.env.TELEGRAM_OUTBOUND_LOG !== '0'

function logOutbound(event: string, fields: Record<string, unknown> = {}): void {
  if (!OUTBOUND_LOG_ENABLED) return
  const payload: Record<string, unknown> = { ts: new Date().toISOString(), evt: event }
  for (const [k, v] of Object.entries(fields)) {
    if (v !== undefined) payload[k] = v
  }
  process.stderr.write(`telegram-runtime outbound ${JSON.stringify(payload)}\n`)
}

// Structured observability for the INBOUND (Telegram → IAP) path — the mirror of
// logOutbound that was MISSING. Before this, the inbound delivery path emitted
// nothing on success and only an unstructured stderr line on failure, so a lost
// inbound message left NO trace in telegram-<peer>.log (the exact "found no
// inbound records" symptom of the 2026-06-23 incident). Every inbound delivery
// now logs its attempt and outcome (incl. `woke`: the live-injection path that
// can be silently lost downstream). Disable with TELEGRAM_INBOUND_LOG=0.
const INBOUND_LOG_ENABLED = process.env.TELEGRAM_INBOUND_LOG !== '0'

function logInbound(event: string, fields: Record<string, unknown> = {}): void {
  if (!INBOUND_LOG_ENABLED) return
  const payload: Record<string, unknown> = { ts: new Date().toISOString(), evt: event }
  for (const [k, v] of Object.entries(fields)) {
    if (v !== undefined) payload[k] = v
  }
  process.stderr.write(`telegram-runtime inbound ${JSON.stringify(payload)}\n`)
}

// Classify an outbound send failure so the log distinguishes the failure modes
// that matter for the stall diagnosis: a Telegram API rejection (e.g. 429 with
// retry_after) vs. a client-side timeout (AbortSignal fired) vs. a raw network
// error. grammy wraps an aborted fetch in HttpError, so the abort is checked
// both directly and through the HttpError wrapper.
function classifyOutboundError(err: unknown): {
  kind: 'api' | 'timeout' | 'network' | 'unknown'
  tgCode?: number
  retryAfter?: number
  detail: string
} {
  const detail = formatError(err)
  if (err instanceof GrammyError) {
    return { kind: 'api', tgCode: err.error_code, retryAfter: err.parameters?.retry_after, detail }
  }
  const isAbort = (e: unknown): boolean => {
    const n = (e as { name?: string } | null)?.name
    return n === 'TimeoutError' || n === 'AbortError'
  }
  if (isAbort(err) || /abort|timed out|timeout/i.test(detail)) {
    return { kind: 'timeout', detail }
  }
  if (err instanceof HttpError) {
    return { kind: isAbort((err as { error?: unknown }).error) ? 'timeout' : 'network', detail }
  }
  return { kind: 'unknown', detail }
}

// Telegram "typing…" while the target peer is actively processing a turn.
// A working peer emits output continuously (the spinner / elapsed-time / token
// counter tick ~1Hz) and goes quiescent when idle — so "the peer's pane-log
// advanced since the last poll" (paneLogAgeMs < PANELOG_BUSY_MS) is a
// runtime-agnostic "still working" signal (verified for both Claude and Codex).
// Typing is refreshed every poll (the Telegram indicator lapses in ~5s) until the
// pane-log goes quiescent or the cap is hit; TYPING_MIN_MS keeps it on through the
// brief startup window before the peer's first byte. One watcher per peer. Disable
// with TELEGRAM_TYPING=0.
const TYPING_POLL_MS = Number(process.env.TELEGRAM_TYPING_POLL_MS ?? '') || 3000
// Anti-runaway backstop, NOT a turn limit: the real stop is the pane going
// static (turn done). High enough that a genuine long reply is never cut; only
// a pathological never-static pane would hit it. 30 min default.
const TYPING_CAP_MS = Number(process.env.TELEGRAM_TYPING_CAP_MS ?? '') || 1_800_000
const TYPING_MIN_MS = Number(process.env.TELEGRAM_TYPING_MIN_MS ?? '') || 5000
// Busy/idle threshold for the pane-log occupancy source (see paneLogAgeMs). A
// peer is "still working" while its pane-log advanced within this window; the
// turn is over once output goes quiescent past it. Verified live (14.06): a busy
// peer's mtime never stalled >~1s (spinner/elapsed-timer repaint ~1Hz), idle
// peers froze for minutes-to-hours — so 4s cleanly separates the two without
// fragmenting a turn on thinking pauses. Tunable; raise if a runtime ever proves
// to emit on a slower cadence mid-turn.
const PANELOG_BUSY_MS = Number(process.env.TELEGRAM_PANELOG_BUSY_MS ?? '') || 4000
// One watcher per peer, shared by the typing indicator and the activity stream
// (they poll the same pane on the same cadence — see watchPeerTurn).
const activeWatchers = new Set<string>()

// The ONE coupling point between the (otherwise independent) activity channel and
// the outbound path (v0.7). Problem: the activity status is a single message
// edited in place, so when a peer sends a normal outbound message mid-turn it
// lands BELOW the status — the stream gets stuck ABOVE the answers (actions on
// top, answers below — illogical). Fix: a peer's outbound send finalizes that
// peer's current status (collapse to "✓ N шагов" in place) and resets it, so the
// NEXT tool call opens a FRESH status message — which lands below the answer.
// Series [activity → answer → activity] then reads top-to-bottom in real order:
// статус1 ✓ → answer → статус2 (new, below). Keyed by personality; a callback is
// registered only while a watcher with an active activity stream is live.
const activityCheckpoints = new Map<string, () => void>()

// Signal the live activity watcher for `personality` (if any) to finalize+reset
// its status message. Best-effort and non-blocking — never gates delivery.
function checkpointActivity(personality: string): void {
  activityCheckpoints.get(personality)?.()
}

// Occupancy source for both progress channels (typing + the activity stream). Under
// pty hosting the iapeer supervisor writes each hosted peer's raw child byte-stream to
// ~/.iapeer/logs/lifecycle/<runtime>-<personality>.log; this poller keys off that
// file's mtime (see paneLogAgeMs) to gate the turn lifecycle — typing directly, and
// the tool-call stream because watchPeerTurn's `done` gate is owned here (no live
// output signal → instant turn-end → activity-loop stops). Keyed by identity. Honours
// IAPEER_ROOT (sandbox/test). LOAD-BEARING cross-package contract owned by iapeer's pty
// supervisor — its path/format is the seam; any change is coordinated with iapeer.
// Pure: pick the runtime whose pane-log is freshest (largest mtime). Candidates whose
// pane-log is absent (mtimeMs === null) are skipped; if none exist, fall back. This is
// the selection at the heart of liveRuntime() — isolated for testing.
export function freshestRuntime(
  candidates: { runtime: string; mtimeMs: number | null }[],
  fallback: string,
): string {
  let best: { runtime: string; mtimeMs: number } | null = null
  for (const c of candidates) {
    if (c.mtimeMs === null) continue
    if (!best || c.mtimeMs > best.mtimeMs) best = { runtime: c.runtime, mtimeMs: c.mtimeMs }
  }
  return best?.runtime ?? fallback
}

// The peer's CURRENTLY-ACTIVE runtime — NOT profile.default_runtime, which is only the
// wake DEFAULT, never the live truth. The iapeer pty-supervisor writes ONLY the live
// runtime's pane-log `<rt>-<personality>.log`; during a turn that file is, by definition,
// the freshest (repainted ~1Hz by the spinner/elapsed timer). So the live runtime = the
// candidate (peer.runtimes) whose pane-log exists and is freshest. Authoritative source
// confirmed with iapeer 2026-06-22: the alive pty-host session (foundation
// listHostedPeers) IS the truth, and this resolves the very file that session writes.
// Without this, a peer running a NON-default runtime (e.g. codex while
// default_runtime=claude) keyed EVERY consumer — typing's pane-log mtime + the transcript
// path + the line parser — to the wrong, absent/stale claude artifacts, so BOTH the
// typing and the tool-use/activity indicators silently died. Falls back to the declared
// default when no pane-log exists yet (a never-run peer, for which no indicator fires).
//
// This is a CHEAP HEURISTIC LAYER ONLY (mtime, no pid check) — it is what backs the
// per-tick busy-gate and can legitimately go stale for a few seconds around a runtime
// flip (see resolveLiveRuntime below for the authoritative, verb-first resolution used
// at turn start and to detect a flip mid-turn).
export function liveRuntime(target: PeerRecord): string {
  const candidates = (target.runtimes?.length ? target.runtimes : [target.runtime]).map(
    runtime => {
      let mtimeMs: number | null = null
      try {
        mtimeMs = statSync(
          join(iapeerRoot(), 'logs', 'lifecycle', `${runtime}-${target.personality}.log`),
        ).mtimeMs
      } catch {}
      return { runtime, mtimeMs }
    },
  )
  return freshestRuntime(candidates, target.runtime)
}

// Pure: decide the peer's verified-live runtime from an `iapeer live-runtime <p>`
// result. Isolated for testing — see resolveLiveRuntime for the caching/subprocess
// wrapper. `declared` is the peer's declared runtime set (target.runtimes, or
// [target.runtime] when empty); `fallback` is normally liveRuntime(target) (the
// mtime heuristic) computed by the caller.
//   - error / timedOut → the verb itself is broken: fall back, verbBroken=true (this
//     is what trips the negative host-cache window in resolveLiveRuntime).
//   - status 0 + non-empty trimmed stdout that IS one of `declared` → the verb's
//     verdict wins, verbBroken=false.
//   - status 0 + non-empty stdout that is NOT one of `declared` → defensive: the verb
//     ran fine but answered something we don't recognize for this peer, so we don't
//     trust it — fall back, but this is not a verb breakage (verbBroken=false).
//   - status 1, or status 0 with empty stdout → a VALID "no live session" answer
//     (peer asleep/unknown), not a breakage — fall back, verbBroken=false.
//   - any other exit code → unexpected — fall back, verbBroken=true.
export function pickVerifiedRuntime(
  verb: { status: number | null; stdout: string; error?: Error; timedOut?: boolean },
  declared: string[],
  fallback: string,
): { runtime: string; source: 'verb' | 'fallback'; verbBroken: boolean } {
  if (verb.error || verb.timedOut) {
    return { runtime: fallback, source: 'fallback', verbBroken: true }
  }
  const stdout = verb.stdout.trim()
  if (verb.status === 0 && stdout) {
    if (declared.includes(stdout)) {
      return { runtime: stdout, source: 'verb', verbBroken: false }
    }
    return { runtime: fallback, source: 'fallback', verbBroken: false }
  }
  if (verb.status === 1 || (verb.status === 0 && !stdout)) {
    return { runtime: fallback, source: 'fallback', verbBroken: false }
  }
  return { runtime: fallback, source: 'fallback', verbBroken: true }
}

// TTL for the positive per-peer cache below: a verb answer is trusted this long
// before the next resolveLiveRuntime call pays for another subprocess.
const LIVE_RUNTIME_TTL_MS = 5000
// TTL for the negative host-wide cache: once the verb itself proves broken (missing
// binary, crash, timeout, unexpected exit code — NOT a normal "no live session"
// exit 1), stop paying a subprocess per turn for a window and just use the mtime
// heuristic — protects a foundation host that doesn't have `live-runtime` yet (or
// is otherwise unhealthy) from a subprocess on every single turn.
const LIVE_RUNTIME_VERB_FAIL_TTL_MS = 60_000
const liveRuntimeCache = new Map<string, { runtime: string; at: number }>()
let liveRuntimeVerbFailedUntil = 0

// Authoritative live-runtime resolution: verb-first (`iapeer live-runtime <p>`,
// foundation ≥0.4.22 — reads pid-alive sessions, not mtime), falling back to the
// cheap liveRuntime() heuristic when the verb is on cooldown or answers "no live
// session". This is what closes the flip-race the mtime-only heuristic cannot: at
// the instant a runtime switch happens, the just-died runtime's pane-log is
// momentarily still the freshest by mtime, but the verb already knows it's gone.
// Called at turn start (watchPeerTurn) and mid-turn to detect a flip; the TTL
// caches bound how often the subprocess actually runs.
export async function resolveLiveRuntime(target: PeerRecord): Promise<string> {
  const cached = liveRuntimeCache.get(target.personality)
  if (cached && Date.now() - cached.at < LIVE_RUNTIME_TTL_MS) return cached.runtime
  if (Date.now() < liveRuntimeVerbFailedUntil) return liveRuntime(target)
  const verb = await runControlBinary(resolveIapeerBin(), ['live-runtime', target.personality], 4000)
  const declared = target.runtimes?.length ? target.runtimes : [target.runtime]
  const picked = pickVerifiedRuntime(verb, declared, liveRuntime(target))
  if (picked.verbBroken) {
    liveRuntimeVerbFailedUntil = Date.now() + LIVE_RUNTIME_VERB_FAIL_TTL_MS
    return picked.runtime
  }
  if (picked.source === 'verb') {
    liveRuntimeCache.set(target.personality, { runtime: picked.runtime, at: Date.now() })
  }
  return picked.runtime
}

function paneLogPath(target: PeerRecord, rt: string = liveRuntime(target)): string {
  return join(iapeerRoot(), 'logs', 'lifecycle', `${rt}-${target.personality}.log`)
}

// Milliseconds since the peer's pane-log last advanced (= since its last byte of
// output) — the busy/idle proxy that replaces diffing pane CONTENT. A small age =
// the peer is actively working (spinner/elapsed-timer repaint keeps it fresh,
// including through thinking pauses); a large/growing age = the turn has ended and
// the prompt is quiescent. null if the log is missing/unreadable — callers treat
// that as idle (a missing log = no live peer output to stream).
function paneLogAgeMs(target: PeerRecord, rt: string = liveRuntime(target)): number | null {
  try {
    return Date.now() - statSync(paneLogPath(target, rt)).mtimeMs
  } catch {
    return null
  }
}

// ── Agent-activity progress channel ─────────────────────────────────────
// A SECOND, separate channel from send_to_peer: while a peer works on a turn,
// telegram-runtime tails the peer's own transcript (the same file Claude/Codex
// already write natively), extracts the sequence of tool calls, and renders
// them into ONE editable status message in the operator's chat ("▸ Read
// README ▸ Bash npm test ▸ send_to_peer <peer> …"). On turn end the
// message is finalized (collapsed to a ✓ summary). send_to_peer is untouched —
// answers still arrive exactly as before; this is best-effort and never blocks
// or shares a queue with the answer path.
//
// Source = polling the transcript, NOT a PostToolUse hook (design decision,
// owner-sanctioned 02.06): the hook needs a carrier plugin + per-agent setup
// and is dead without telegram-runtime; polling lives entirely here and works
// uniformly for claude and codex without touching the agents.
const ACTIVITY_MASTER_ON = process.env.TELEGRAM_ACTIVITY !== '0'
// Default for peers that were never toggled (interfaces.telegram.activity is
// undefined): ON in CODE — a product property, not host configuration (a
// product decision: the fleet's work is visible out of the box; before this the
// default lived in an env var nobody set, so every new peer required a manual
// /activity). Priority ladder: per-peer /activity toggle (strongest, both
// directions) → TELEGRAM_ACTIVITY=0 master kill / TELEGRAM_ACTIVITY_DEFAULT=0
// default opt-out (env, host-level escape hatches) → this code default.
const ACTIVITY_DEFAULT_ON = process.env.TELEGRAM_ACTIVITY_DEFAULT !== '0'
// Realtime cadence (release v0.5). The activity stream runs on its OWN fast
// poller (watchPeerTurn), decoupled from the 3s typing poll — lowering the
// shared typing cadence would over-send sendChatAction. We poll the transcript
// every ACTIVITY_POLL_MS for snappy detection, but edit at most once per
// ACTIVITY_EDIT_INTERVAL_MS: Telegram documents ~1 message/sec to a single chat
// (core.telegram.org/bots/faq) and edits are subject to the same flood limit;
// 1s is the practical per-chat ceiling. On a 429 we honour parameters.retry_after
// (backoff until then) — see flush() in watchPeerTurn.
const ACTIVITY_POLL_MS = Number(process.env.TELEGRAM_ACTIVITY_POLL_MS ?? '') || 500
const ACTIVITY_EDIT_INTERVAL_MS = Number(process.env.TELEGRAM_ACTIVITY_EDIT_MS ?? '') || 1000
// How many trailing gesture lines to keep visible (older ones collapse behind a
// leading "⋯"). Bounds message length under Telegram's 4096 cap; the final
// collapse drops the list entirely anyway.
const ACTIVITY_MAX_STEPS = Number(process.env.TELEGRAM_ACTIVITY_MAX_STEPS ?? '') || 30
const ACTIVITY_LOG_ENABLED = process.env.TELEGRAM_ACTIVITY_LOG !== '0'
// Codex sessions are date-partitioned and keyed by session id, not cwd, so the
// active rollout is located by scanning recently-modified rollout files and
// matching session_meta.cwd. Bound the scan to sessions touched this recently.
const CODEX_SESSION_WINDOW_MS = Number(process.env.TELEGRAM_CODEX_WINDOW_MS ?? '') || 6 * 3600_000

type ToolEvent = { tool: string; label?: string }

function logActivity(event: string, fields: Record<string, unknown> = {}): void {
  if (!ACTIVITY_LOG_ENABLED) return
  const payload: Record<string, unknown> = { ts: new Date().toISOString(), evt: event }
  for (const [k, v] of Object.entries(fields)) {
    if (v !== undefined) payload[k] = v
  }
  process.stderr.write(`telegram-runtime activity ${JSON.stringify(payload)}\n`)
}

function activityEnabledForPeer(target: PeerRecord): boolean {
  if (!ACTIVITY_MASTER_ON) return false
  const tg = telegramInterface(target)
  // Read fresh from the (hydrated) profile every turn → toggling on/off, or a
  // brand-new peer with interfaces.telegram, takes effect with no runtime restart.
  if (typeof tg.activity === 'boolean') return tg.activity
  return ACTIVITY_DEFAULT_ON
}

// Strip an MCP tool's mcp__<server>__<tool> prefix down to the bare tool name
// (the gesture the operator recognises): mcp__plugin_…_iap__send_to_peer →
// send_to_peer. Non-MCP tool names pass through unchanged.
export function shortToolName(name: string): string {
  if (name.startsWith('mcp__')) {
    const idx = name.lastIndexOf('__')
    if (idx >= 0 && idx + 2 < name.length) return name.slice(idx + 2)
  }
  return name
}

export function isOutboundGesture(name: string): boolean {
  return shortToolName(name) === 'send_to_peer'
}

// Decide whether to HIDE a send_to_peer gesture, target-aware (v0.8.2):
//   • to the human OPERATOR of the chat → HIDE. That send is already shown as the
//     delivered message, AND it is the one whose transcript gesture raced the
//     outbound checkpoint into a hung post-status (the v0.8.1 desync). isOperator
//     identifies operator (human) targets.
//   • to another AGENT (agent→agent, e.g. boris→linus) → KEEP. The owner wants
//     cross-agent communication visible in the stream.
// Non-send tools are never hidden. With NO operator predicate (pure callers /
// tests) ALL send_to_peer is hidden — the conservative v0.8.1 default. A
// send_to_peer with no resolvable target is hidden too (cannot prove it is agent-
// bound, and it is the noisier/riskier case).
function hideSendGesture(
  name: string,
  target: string | undefined,
  isOperator?: (personality: string) => boolean,
): boolean {
  if (!isOutboundGesture(name)) return false
  if (!isOperator) return true
  return !target || isOperator(target)
}

// Extract the send_to_peer target personality from a Codex function_call's
// JSON-string arguments (v0.8.2).
function codexSendTarget(args: unknown): string | undefined {
  if (typeof args !== 'string' || !args) return undefined
  try {
    const parsed = JSON.parse(args) as Record<string, unknown>
    return typeof parsed.personality === 'string' ? parsed.personality : undefined
  } catch {
    return undefined
  }
}

// Normalize a label to a single line (newlines/runs of whitespace → one space).
// NO length truncation (v0.6): the operator wanted the full label text — it all
// collapses to "✓ N шагов" at turn end anyway, so clipping mid-turn just hides
// substance. The only length guard is at the whole-message level in
// renderActivity (Telegram's 4096 cap), not per-label.
function oneLine(value: string): string {
  return value.replace(/\s+/g, ' ').trim()
}

// A gesture, NOT content: a non-private hint (filename / peer name / the agent's
// own description). Never the full file body or large argument blobs — those can
// be big and private (design constraint). For Bash we use the agent's own
// description (a deliberate, safe summary), falling back to the command verb only.
// For Skill we surface the skill name (NOT its `args`, which carry the full task
// text and are private). For the subagent spawn (Agent — the live tool name in
// claude transcripts; older harnesses named it Task) we surface the subagent type
// or the agent's own short description, NEVER the prompt (large, private). v0.7.
export function toolLabel(name: string, input: unknown): string | undefined {
  if (!input || typeof input !== 'object') return undefined
  const inp = input as Record<string, unknown>
  const short = shortToolName(name)
  const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined)
  if (short === 'Bash') {
    const desc = str(inp.description)
    if (desc) return oneLine(desc)
    const cmd = str(inp.command)
    if (cmd) return oneLine(cmd.trim().split(/\s+/)[0] ?? '')
    return undefined
  }
  if (short === 'Read' || short === 'Edit' || short === 'Write' || short === 'NotebookEdit') {
    const fp = str(inp.file_path) ?? str(inp.notebook_path)
    return fp ? oneLine(basename(fp)) : undefined
  }
  if (short === 'send_to_peer') return str(inp.personality)
  if (short === 'Skill') {
    const skill = str(inp.skill) ?? str(inp.name)
    return skill ? oneLine(skill) : undefined
  }
  if (short === 'Task' || short === 'Agent') {
    const sub = str(inp.subagent_type) ?? str(inp.description)
    return sub ? oneLine(sub) : undefined
  }
  const generic = str(inp.query) ?? str(inp.pattern) ?? str(inp.description)
  return generic ? oneLine(generic) : undefined
}

// Extract tool-call gestures from one parsed Claude transcript line. Skips
// sidechain (subagent) lines — those are the Task agent's own internal context;
// the parent's gesture stream shows the Task spawn itself, not its internals.
// isOperator (v0.8.2) lets operator-bound send_to_peer be hidden while agent→agent
// sends stay visible — see hideSendGesture.
export function claudeLineEvents(
  obj: unknown,
  isOperator?: (personality: string) => boolean,
): ToolEvent[] {
  const o = obj as Record<string, any> | null
  if (!o || o.type !== 'assistant' || o.isSidechain) return []
  const content = o.message?.content
  if (!Array.isArray(content)) return []
  const out: ToolEvent[] = []
  for (const c of content) {
    if (c && c.type === 'tool_use' && typeof c.name === 'string') {
      const target = typeof c.input?.personality === 'string' ? c.input.personality : undefined
      if (hideSendGesture(c.name, target, isOperator)) continue // operator-bound send (v0.8.1/.2)
      const label = toolLabel(c.name, c.input)
      out.push(label ? { tool: shortToolName(c.name), label } : { tool: shortToolName(c.name) })
    }
  }
  return out
}

// Current context occupancy in tokens for the completion line (v0.8): the prompt
// size of the most recent MAIN-chain assistant turn (input + both cache buckets).
// A raw count, NOT a percent — so it is context-window-INDEPENDENT and renders
// identically regardless of the peer's model (200k vs 1M) and cross-runtime.
// Sidechain (subagent) usage is skipped: the PARENT thread's context is what fills.
export function claudeContextTokens(obj: unknown): number | null {
  const o = obj as Record<string, any> | null
  if (!o || o.type !== 'assistant' || o.isSidechain) return null
  const u = o.message?.usage
  if (!u || typeof u !== 'object') return null
  const n = (v: unknown): number => (typeof v === 'number' && v > 0 ? v : 0)
  const total =
    n(u.input_tokens) + n(u.cache_read_input_tokens) + n(u.cache_creation_input_tokens)
  return total > 0 ? total : null
}

function codexCallLabel(name: string, args: unknown): string | undefined {
  if (typeof args !== 'string' || !args) return undefined
  let parsed: Record<string, unknown>
  try {
    parsed = JSON.parse(args) as Record<string, unknown>
  } catch {
    return undefined
  }
  const cmd = parsed.cmd ?? parsed.command
  if ((name === 'exec_command' || name === 'shell' || name === 'bash') && typeof cmd === 'string') {
    return oneLine(cmd.trim().split(/\s+/)[0] ?? '')
  }
  const fp = parsed.file_path ?? parsed.path
  if (typeof fp === 'string') return basename(fp)
  return undefined
}

// Extract tool-call gestures from one parsed Codex rollout line. Codex records
// tool calls as response_item payloads (function_call / custom_tool_call /
// tool_search_call) — a different schema from Claude's tool_use blocks.
// isOperator (v0.8.2): see hideSendGesture / claudeLineEvents.
export function codexLineEvents(
  obj: unknown,
  isOperator?: (personality: string) => boolean,
): ToolEvent[] {
  const o = obj as Record<string, any> | null
  if (!o || o.type !== 'response_item') return []
  const p = o.payload
  if (!p || typeof p !== 'object') return []
  const t = p.type
  if (t === 'function_call' && typeof p.name === 'string') {
    if (isOutboundGesture(p.name)) {
      const target = codexSendTarget(p.arguments)
      if (hideSendGesture(p.name, target, isOperator)) return [] // operator-bound (v0.8.1/.2)
      return [{ tool: 'send_to_peer', label: target }] // agent→agent — keep, label = target
    }
    const label = codexCallLabel(p.name, p.arguments)
    const tool = shortToolName(p.name)
    return [label ? { tool, label } : { tool }]
  }
  if ((t === 'custom_tool_call' || t === 'local_shell_call') && typeof p.name === 'string') {
    if (hideSendGesture(p.name, undefined, isOperator)) return [] // operator-bound (v0.8.1/.2)
    return [{ tool: shortToolName(p.name) }]
  }
  if (t === 'local_shell_call') return [{ tool: 'shell' }]
  if (t === 'tool_search_call') return [{ tool: 'tool_search' }]
  if (t === 'image_generation_call') return [{ tool: 'image_generation' }]
  return []
}

// Codex equivalent of claudeContextTokens (v0.8). Codex emits token_count
// payloads carrying info.last_token_usage (this turn) and info.total_token_usage
// (session cumulative). Context OCCUPANCY = the last turn's input size
// (input_tokens already includes the cached portion) — the same "prompt size now"
// semantic as the claude path, so the "· N tokens" suffix means the same thing on
// both runtimes. (Codex also reports info.model_context_window, deliberately
// unused: we show the raw count, never a window-relative %.)
export function codexContextTokens(obj: unknown): number | null {
  const p = (obj as Record<string, any> | null)?.payload
  if (!p || typeof p !== 'object' || p.type !== 'token_count') return null
  const v = p.info?.last_token_usage?.input_tokens
  return typeof v === 'number' && v > 0 ? v : null
}

// Past-tense of a splash gerund for the completion line (v0.8): "Pondering" →
// "Pondered", to mirror claude-code's TUI finish ("Churned for 1m 22s"). The
// splash set is all "-ing" forms; strip "ing" and apply the regular rule
// (consonant+y → "ied", else "+ed" — "+ed" also restores the silent-e of -ate/-e
// stems, since "Bak"+"ed" = "Baked", "Forg"+"ed" = "Forged"). The handful of
// English irregulars are overridden by hand.
const PAST_TENSE_IRREGULAR: Record<string, string> = {
  Doing: 'Did',
  Spinning: 'Spun',
  Thinking: 'Thought',
}
export function pastTenseVerb(gerund: string): string {
  const override = PAST_TENSE_IRREGULAR[gerund]
  if (override) return override
  const stem = gerund.endsWith('ing') ? gerund.slice(0, -3) : gerund
  if (/[^aeiou]y$/i.test(stem)) return `${stem.slice(0, -1)}ied`
  return `${stem}ed`
}

// Turn duration in the claude-code style: "45s", "1m 22s", "1h 5m 3s" (v0.8).
// Floors to whole seconds, never below 1s (a turn always took some time).
export function formatTurnDuration(ms: number): string {
  const total = Math.max(1, Math.floor(ms / 1000))
  const h = Math.floor(total / 3600)
  const m = Math.floor((total % 3600) / 60)
  const s = total % 60
  if (h > 0) return `${h}h ${m}m ${s}s`
  if (m > 0) return `${m}m ${s}s`
  return `${s}s`
}

const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))

// Exponential backoff for the bounded outbound send-retry loops: 1s, 2s, 4s… capped
// at 5s. Defined once and shared by sendRichResilient + sendChunkResilient so the
// retry schedule cannot drift between the rich and chunked paths.
const backoffMs = (attempt: number): number => Math.min(1000 * 2 ** attempt, 5000)

// claude-code "splash" working-verbs. One is picked at random at the start of a
// turn and stays STATIC for the whole turn (a new turn → a new word). Purely
// cosmetic header — replaces the old ⚙️ + peer-name line (the operator already
// knows which bot's chat they are in).
const SPLASH_VERBS = [
  'Accomplishing', 'Actioning', 'Actualizing', 'Baking', 'Brewing', 'Calculating',
  'Cerebrating', 'Channelling', 'Churning', 'Clauding', 'Coalescing', 'Cogitating',
  'Computing', 'Concocting', 'Conjuring', 'Considering', 'Cooking', 'Crafting',
  'Creating', 'Crunching', 'Deciphering', 'Deliberating', 'Determining', 'Doing',
  'Effecting', 'Elucidating', 'Enchanting', 'Envisioning', 'Finagling', 'Forging',
  'Forming', 'Generating', 'Germinating', 'Hatching', 'Herding', 'Honking',
  'Hustling', 'Ideating', 'Imagining', 'Incubating', 'Inferring', 'Manifesting',
  'Marinating', 'Meandering', 'Moseying', 'Mulling', 'Mustering', 'Musing',
  'Noodling', 'Percolating', 'Pondering', 'Processing', 'Puttering', 'Puzzling',
  'Reticulating', 'Ruminating', 'Scheming', 'Schlepping', 'Shimmying', 'Simmering',
  'Smooshing', 'Spelunking', 'Spinning', 'Stewing', 'Sussing', 'Synthesizing',
  'Thinking', 'Tinkering', 'Transmuting', 'Unfurling', 'Vibing', 'Wandering',
  'Whirring', 'Wibbling', 'Wizarding', 'Working', 'Wrangling',
]

export function pickSplash(): string {
  return SPLASH_VERBS[Math.floor(Math.random() * SPLASH_VERBS.length)] ?? 'Working'
}

// v0.7 flush gates (pure). Return true to SKIP a status send entirely:
//   • final collapse (active=false) with no status open → never CREATE a status
//     from scratch, so a turn that ends right after a checkpoint leaves no orphan
//     "✓ 0 шагов" tail;
//   • active frame with no status open, no events yet, and splash-only no longer
//     allowed (we are past the turn-start instant splash, i.e. after a checkpoint)
//     → wait for the next real tool call before opening the next status message,
//     so статус2 is born with substance and lands below the answer.
export function skipStatusFlush(
  active: boolean,
  hasStatus: boolean,
  hasEvents: boolean,
  allowSplashOnly: boolean,
): boolean {
  if (!active && !hasStatus) return true
  if (active && !hasStatus && !hasEvents && !allowSplashOnly) return true
  return false
}

// One gesture per line: "▸ <tool>: <label>", or "▸ <tool>" only when the tool
// has no characteristic label (true fallback). The labels ARE the value of the
// feature — they show the substance of each call (see toolLabel) — so they are
// preserved, never stripped wholesale.
function gestureLine(e: ToolEvent): string {
  return e.label ? `▸ ${e.tool}: ${e.label}` : `▸ ${e.tool}`
}

// Render the single status message. Plain text (no parse_mode): tool names like
// send_to_peer contain underscores that MarkdownV2 would choke on, and an
// ephemeral status line must never fail to parse.
//   active: a splash verb on top (re-picked per tool call by the caller), then
//           the (tail of the) gesture list, one per line, growing in realtime.
//           With no tools yet it is just the splash — shown instantly at turn
//           start, before the first tool_use (v0.6).
//   final:  FULL collapse to one line in claude-code's finish style — the splash
//           verb in PAST tense + the turn duration + the context size in tokens
//           ("Pondered for 32s · 680958 tokens", v0.8). The gesture list is
//           dropped so no wall of text stays in history; durationMs is the status
//           message's lifetime (creation → collapse); contextTokens is the raw
//           context-occupancy count (window-independent) or null to omit it.
export function renderActivity(
  splash: string,
  events: ToolEvent[],
  active: boolean,
  durationMs = 0,
  contextTokens: number | null = null,
): string {
  if (!active) {
    const base = `${pastTenseVerb(splash)} for ${formatTurnDuration(durationMs)}`
    return contextTokens != null ? `${base} · ${contextTokens} tokens` : base
  }
  const head = `${splash}…`
  if (events.length === 0) return head
  const build = (evts: ToolEvent[], trunc: boolean): string =>
    [head, ...(trunc ? ['⋯'] : []), ...evts.map(gestureLine)].join('\n')
  let shown = events.slice(-ACTIVITY_MAX_STEPS)
  let truncated = events.length > shown.length
  let text = build(shown, truncated)
  // Whole-message guard for Telegram's 4096 cap. Labels are NOT clipped (v0.6),
  // so an unusually long one could push the message over the limit; drop the
  // oldest lines until it fits rather than let the edit be rejected.
  while (text.length > MAX_TELEGRAM_TEXT && shown.length > 1) {
    shown = shown.slice(1)
    truncated = true
    text = build(shown, truncated)
  }
  return text
}

// Claude stores each project's transcripts under ~/.claude/projects/<dir>, where
// <dir> is the cwd with every non-alphanumeric char replaced by '-' (verified
// e.g. /Users/alice/Projects/app → -Users-alice-Projects-app; dots map too,
// no run-collapsing).
export function claudeProjectDirName(cwd: string): string {
  return cwd.replace(/[^a-zA-Z0-9]/g, '-')
}

function latestJsonl(dir: string): string | null {
  let best: { path: string; mtime: number } | null = null
  try {
    for (const e of readdirSync(dir, { withFileTypes: true })) {
      if (!e.isFile() || !e.name.endsWith('.jsonl')) continue
      const p = join(dir, e.name)
      try {
        const m = statSync(p).mtimeMs
        if (!best || m > best.mtime) best = { path: p, mtime: m }
      } catch {}
    }
  } catch {
    return null
  }
  return best?.path ?? null
}

function claudeTranscriptPath(cwd: string): string | null {
  return latestJsonl(join(homedir(), '.claude', 'projects', claudeProjectDirName(cwd)))
}

function codexTranscriptPath(cwd: string): string | null {
  const root = join(homedir(), '.codex', 'sessions')
  const want = cwd.replace(/\/+$/, '').toLowerCase()
  const cutoff = Date.now() - CODEX_SESSION_WINDOW_MS
  const candidates: { path: string; mtime: number }[] = []
  const walk = (dir: string, depth: number): void => {
    if (depth > 4) return
    let entries
    try {
      entries = readdirSync(dir, { withFileTypes: true })
    } catch {
      return
    }
    for (const e of entries) {
      const p = join(dir, e.name)
      if (e.isDirectory()) {
        walk(p, depth + 1)
      } else if (e.isFile() && e.name.startsWith('rollout-') && e.name.endsWith('.jsonl')) {
        try {
          const m = statSync(p).mtimeMs
          if (m >= cutoff) candidates.push({ path: p, mtime: m })
        } catch {}
      }
    }
  }
  walk(root, 0)
  candidates.sort((a, b) => b.mtime - a.mtime)
  for (const c of candidates) {
    try {
      const firstLine = readFileSync(c.path, 'utf8').split('\n', 1)[0]
      const meta = JSON.parse(firstLine)
      const metaCwd = meta?.payload?.cwd ?? meta?.cwd
      if (typeof metaCwd === 'string' && metaCwd.replace(/\/+$/, '').toLowerCase() === want) {
        return c.path
      }
    } catch {}
  }
  return null
}

// rt defaults to the LIVE runtime (not target.runtime/default_runtime); createTranscriptReader
// resolves it once and threads it so the path branch and the line parser cannot diverge.
export function activeTranscriptPath(target: PeerRecord, rt: string = liveRuntime(target)): string | null {
  if (!target.cwd) return null
  if (rt === 'codex') return codexTranscriptPath(target.cwd)
  // claude (and any future runtime writing Claude-style JSONL projects)
  return claudeTranscriptPath(target.cwd)
}

type TranscriptReader = { poll: () => ToolEvent[]; contextTokens: () => number | null }

// Tail the peer's transcript for tool-call gestures appended since the last
// poll. Byte-offset tailing (not re-reading the whole file): the offset is
// always advanced to a newline boundary, so each read starts on a clean line;
// a trailing partial line (write in flight) is left for the next poll. Returns
// null if no transcript can be located (→ activity silently disabled this turn).
// Alongside gestures, each poll updates the latest context-token count seen
// (contextTokens) for the v0.8 completion line — same parse pass, no extra read.
function createTranscriptReader(
  target: PeerRecord,
  isOperator?: (personality: string) => boolean,
  rt: string = liveRuntime(target),
): TranscriptReader | null {
  const path = activeTranscriptPath(target, rt)
  if (!path) return null
  const parse = rt === 'codex' ? codexLineEvents : claudeLineEvents
  const tokensOf = rt === 'codex' ? codexContextTokens : claudeContextTokens
  let offset = 0
  let lastTokens: number | null = null
  try {
    offset = statSync(path).size
  } catch {
    return null
  }
  return {
    contextTokens: () => lastTokens,
    poll(): ToolEvent[] {
      try {
        const size = statSync(path).size
        if (size < offset) offset = 0 // file rotated/truncated
        if (size <= offset) return []
        const len = size - offset
        const buf = Buffer.allocUnsafe(len)
        const fd = openSync(path, 'r')
        try {
          readSync(fd, buf, 0, len, offset)
        } finally {
          closeSync(fd)
        }
        const text = buf.toString('utf8')
        const lastNl = text.lastIndexOf('\n')
        if (lastNl < 0) return [] // no complete line yet
        const consumed = text.slice(0, lastNl + 1)
        offset += Buffer.byteLength(consumed, 'utf8')
        const events: ToolEvent[] = []
        for (const line of consumed.split('\n')) {
          if (!line.trim()) continue
          let obj: unknown
          try {
            obj = JSON.parse(line)
          } catch {
            continue
          }
          for (const e of parse(obj, isOperator)) events.push(e)
          const t = tokensOf(obj)
          if (t !== null) lastTokens = t
        }
        return events
      } catch {
        return []
      }
    },
  }
}

// Per-peer turn watcher. TWO concurrent loops, one guard, started together,
// stopped together:
//   • Pane loop (3s) — owns turn lifetime + the "typing…" indicator. Pane
//     changed-since-last-poll = still working; pane static = turn done. Runs
//     even when typing is disabled, because it is also the turn-end detector for
//     the activity collapse. Its cadence is NOT lowered (would over-send typing).
//   • Activity loop (ACTIVITY_POLL_MS, ~0.5s) — tails the transcript fast for a
//     realtime stream, but edits at most once per ACTIVITY_EDIT_INTERVAL_MS to
//     respect Telegram's ~1 edit/sec-per-chat flood limit, and backs off on 429.
// Fire-and-forget; all activity sends are best-effort and isolated from the
// send_to_peer outbound queue (a failed/throttled edit never blocks delivery).
async function watchPeerTurn(
  bot: Bot,
  chatId: string,
  target: PeerRecord,
  isOperator?: (personality: string) => boolean,
): Promise<void> {
  const typingOn = process.env.TELEGRAM_TYPING !== '0'
  const activityOn = activityEnabledForPeer(target)
  if (!typingOn && !activityOn) return
  if (activeWatchers.has(target.personality)) return
  activeWatchers.add(target.personality)

  // Authoritative resolution ONCE at turn start (verb-first, TTL-cached) — closes the
  // flip-race the mtime-only liveRuntime() heuristic cannot (see resolveLiveRuntime).
  // Threaded explicitly through the reader + busy-gate below so both key off the SAME
  // runtime; re-checked mid-turn further down (a flip mid-turn re-resolves `rt`).
  let rt = await resolveLiveRuntime(target)
  let reader = activityOn ? createTranscriptReader(target, isOperator, rt) : null
  if (activityOn && !reader) {
    logActivity('reader.none', { peer: target.personality, runtime: rt, cwd: target.cwd })
  }
  // Splash verb shown at the top. Picked now so it appears the instant the turn
  // starts (before any tool_use — like the typing indicator), and re-picked on
  // every new tool call below for a livelier feel (v0.6).
  let splash = pickSplash()
  const events: ToolEvent[] = []
  let statusMessageId: number | null = null
  // When the CURRENT status message was created (creation → collapse = the
  // duration shown in the completion line, v0.8). Reset per segment so статус2
  // after a checkpoint times its own lifetime, not the whole turn.
  let statusStartedAt = 0
  let lastRendered = ''
  let lastEditAt = 0
  let throttledUntil = 0
  let done = false
  // Set by checkpointActivity (via the registered callback) when this peer sends
  // an outbound message; honoured at the top of the activity loop (v0.7).
  let checkpointRequested = false
  // A status message may be created splash-only (no tool yet) ONLY at turn start
  // — the instant "still working" frame (v0.6). After a checkpoint reset this is
  // off: the next status message is born from a real tool call, never a bare
  // splash, so a trailing answer leaves no empty "✓ 0 шагов" stub behind (v0.7).
  let allowSplashOnly = true
  if (reader) activityCheckpoints.set(target.personality, () => (checkpointRequested = true))

  const flush = async (active: boolean): Promise<void> => {
    if (!reader) return // events may be empty: the active frame is then splash-only
    // v0.7 gates: no orphan "✓ 0 шагов", and post-checkpoint статус2 waits for a
    // real tool call before it is opened (see skipStatusFlush).
    if (skipStatusFlush(active, statusMessageId !== null, events.length > 0, allowSplashOnly)) return
    // Completion line (active=false) shows this status's lifetime + the peer's
    // current context size in tokens; both unused (0 / null) while active.
    const durationMs = !active && statusStartedAt ? Date.now() - statusStartedAt : 0
    const contextTokens = active ? null : reader.contextTokens()
    const text = renderActivity(splash, events, active, durationMs, contextTokens)
    if (active && text === lastRendered) return // skip "message is not modified"
    const now = Date.now()
    if (active) {
      // Throttle: honour any 429 backoff window and the ~1/sec per-chat ceiling.
      if (now < throttledUntil || now - lastEditAt < ACTIVITY_EDIT_INTERVAL_MS) return
    } else if (now < throttledUntil) {
      // The collapse is the final frame — wait out the backoff so the chat never
      // ends on a stale active state, then send it.
      await sleep(throttledUntil - now)
    }
    try {
      if (statusMessageId === null) {
        const msg = await bot.api.sendMessage(
          chatId,
          text,
          undefined,
          AbortSignal.timeout(OUTBOUND_SEND_TIMEOUT_MS),
        )
        statusMessageId = msg.message_id
        statusStartedAt = Date.now() // anchor for this segment's duration (v0.8)
      } else {
        await bot.api.editMessageText(
          chatId,
          statusMessageId,
          text,
          undefined,
          AbortSignal.timeout(OUTBOUND_SEND_TIMEOUT_MS),
        )
      }
      lastRendered = text
      lastEditAt = Date.now()
      logActivity('edit', { peer: target.personality, steps: events.length, active })
    } catch (err) {
      // Ephemeral channel: a failed edit must never break delivery. On a 429,
      // respect retry_after so we stop hammering; otherwise log and move on (the
      // next poll re-renders the latest state).
      const c = classifyOutboundError(err)
      if (c.kind === 'api' && c.tgCode === 429) {
        throttledUntil = Date.now() + (c.retryAfter ?? 1) * 1000 + 250
        logActivity('throttled', { peer: target.personality, retryAfter: c.retryAfter })
      } else {
        logActivity('edit.error', { peer: target.personality, detail: c.detail })
      }
    }
  }

  // Collapse the CURRENT status to its completion line ("Pondered for 32s") IN
  // PLACE, then drop the message id and the running tally so the next tool call
  // opens a fresh status message below the answer the peer just sent (v0.7 — see
  // activityCheckpoints). No-op if no status exists yet (nothing is stuck above
  // the answer). One last poll first so any trailing WORK gesture lands in THIS
  // segment, not the next (send_to_peer itself is no longer a gesture — v0.8.1).
  const finalizeAndReset = async (): Promise<void> => {
    if (statusMessageId === null) return
    try {
      events.push(...reader!.poll())
    } catch {}
    await flush(false)
    statusMessageId = null
    statusStartedAt = 0 // статус2 re-anchors its duration on its own creation (v0.8)
    events.length = 0
    lastRendered = ''
    allowSplashOnly = false
    splash = pickSplash()
    logActivity('checkpoint', { peer: target.personality })
  }

  // Fast activity poller — decoupled from the typing cadence. Stops when the
  // pane loop marks the turn done.
  const activityLoop = reader
    ? (async () => {
        while (!done) {
          // Honour a pending outbound checkpoint BEFORE rendering new activity, so
          // post-answer tool calls never bleed into the now-finalized status.
          if (checkpointRequested) {
            checkpointRequested = false
            await finalizeAndReset()
          }
          const fresh = reader.poll()
          if (fresh.length) {
            events.push(...fresh)
            splash = pickSplash() // a fresh verb on each new tool call (v0.6)
          }
          await flush(true)
          await sleep(ACTIVITY_POLL_MS)
        }
      })()
    : null

  const started = Date.now()
  try {
    if (typingOn) await bot.api.sendChatAction(chatId, 'typing').catch(() => {})
    while (Date.now() - started < TYPING_CAP_MS) {
      await sleep(TYPING_POLL_MS)
      // Busy = the peer's pane-log advanced within PANELOG_BUSY_MS (it emits
      // output — spinner/tool — roughly every second while working). This poller
      // owns the turn-lifecycle `done` gate (set in finally), under which the
      // activity-loop tails the transcript for gestures — so keeping it alive for
      // the whole turn is what re-enables BOTH channels. The TYPING_MIN_MS grace
      // covers the startup window before the peer's first byte; a missing log
      // (age null) reads as idle, exactly as the old null pane did.
      const age = paneLogAgeMs(target, rt)
      const busy = age !== null && age < PANELOG_BUSY_MS
      // Cheap sync suspicion that `rt` has gone stale (a runtime flip happened
      // mid-turn): the mtime heuristic now disagrees with the runtime we resolved
      // at turn start. Reverify with the authoritative verb before acting on it —
      // resolveLiveRuntime's own TTL cache bounds how often that subprocess runs.
      if (liveRuntime(target) !== rt) {
        const verified = await resolveLiveRuntime(target)
        if (verified !== rt) {
          logActivity('runtime-flip', { peer: target.personality, from: rt, to: verified })
          rt = verified
          if (reader) {
            // Re-tail the NEW runtime's transcript from its current EOF. Any
            // gestures the new runtime emitted before this swap are deliberately
            // lost — this channel is a live indicator, not a durable journal, and
            // mixing two runtimes' events in one status message would be worse.
            const next = createTranscriptReader(target, isOperator, rt)
            // A failed re-create keeps the stale reader alive: a stale-but-open
            // tail beats killing the channel outright mid-turn.
            if (next) reader = next
          }
        }
      }
      if (busy || Date.now() - started < TYPING_MIN_MS) {
        if (typingOn) await bot.api.sendChatAction(chatId, 'typing').catch(() => {})
      } else {
        break
      }
    }
  } finally {
    done = true
    if (activityLoop) await activityLoop // let the in-flight poll/edit settle
    activeWatchers.delete(target.personality)
    activityCheckpoints.delete(target.personality)
    if (reader) {
      // Capture any trailing tool calls (e.g. the final send_to_peer) then
      // collapse the status message to its single-line ✓ summary. If the turn
      // ended right after a checkpoint (no status open), flush(false) is a no-op.
      try {
        events.push(...reader.poll())
      } catch {}
      await flush(false)
    }
  }
}

// Operator control command for the activity channel, applied to the peer bound
// to the bot the operator messaged. `/activity` → status; `/activity on|off` →
// toggle (persisted in the target peer's interfaces.telegram.activity).
export function parseActivityCommand(text: string): 'on' | 'off' | 'status' | null {
  const m = /^\/activity(?:@\w+)?(?:\s+(on|off|status))?$/i.exec(text.trim())
  if (!m) return null
  const arg = (m[1] ?? 'status').toLowerCase()
  return arg as 'on' | 'off' | 'status'
}

// CONTROL detection: a single bare "stop" word addressed to the bound peer.
//
// Returns true ONLY for an exact one-word stop token — «стоп» / «stop» (any
// case) or the «/stop» slash-command — after trimming surrounding whitespace
// and a trailing run of !/./? punctuation. This is the operator's remote
// "shut up" for a peer that is spewing from Telegram; the caller maps it to
// `iapeer interrupt`.
//
// FLEET-SAFETY: the match is surgical. Anything multi-word ("стоп подожди",
// "стоп!! что ты делаешь", "стоп, не надо") returns false and flows through the
// UNCHANGED envelope/delivery path — this runtime carries the whole fleet's
// live channel, so a normal message misclassified as control would break it.
// When in doubt we err toward delivery (e.g. a trailing comma is NOT stripped,
// so "стоп," is treated as a normal message). `iu` flags fold Cyrillic case
// (СТОП↔стоп). Telegram may append @botname to slash-commands — tolerated for
// /stop, mirroring /activity.
export function isStopCommand(text: string): boolean {
  const normalized = text.trim().replace(/[\s!.?]+$/u, '')
  return /^(?:стоп|stop|\/stop(?:@\w+)?)$/iu.test(normalized)
}

// Lifecycle control commands (контракт двухуровневой модели, 12.06.2026): a
// BARE slash command — exactly `/new` or `/compact`, nothing else — is ALWAYS
// control, never a prompt. These are the emergency handles: an unconditional
// fresh-session / context-compact for a peer whose prompt channel is wedged
// (hung turn, dead session), where a prompt-path shortcut cannot reach it.
// The prompt-path shortcuts live under `/alias_*` (expansion.aliases) and are
// NOT affected. FLEET-SAFETY mirrors isStopCommand: only the pure command
// (optionally with Telegram's appended @botname) is control; any argument or
// trailing text fails the match and flows through normal delivery.
export function parseLifecycleCommand(text: string): 'new' | 'compact' | null {
  const t = text.trim()
  if (/^\/new(?:@\w+)?$/i.test(t)) return 'new'
  if (/^\/compact(?:@\w+)?$/i.test(t)) return 'compact'
  return null
}

// The agent runtimes a peer can be HARD-switched to from Telegram. `telegram` is a
// presence runtime (the human side), never a switch target. A bare `/<runtime>`
// (e.g. `/codex`) is a CONTROL command in the two-level model — a reserved bare-slash
// name, distinct from the `/alias_*` prompt namespace.
const RUNTIME_SWITCH_RUNTIMES = ['claude', 'codex'] as const

// Parse a hard runtime-switch control command: a BARE `/claude` / `/codex` (optionally
// with Telegram's appended @botname), nothing else. Returns the target runtime, or null
// when the text is not a known-runtime switch. Validation that the TARGET peer actually
// declares the runtime happens in the handler (clean per-peer feedback). Same
// fleet-safety as the lifecycle/stop parsers: any argument or trailing text fails.
export function parseRuntimeSwitchCommand(text: string): string | null {
  const m = /^\/([a-z]+)(?:@\w+)?$/.exec(text.trim().toLowerCase())
  if (!m) return null
  return (RUNTIME_SWITCH_RUNTIMES as readonly string[]).includes(m[1]) ? m[1] : null
}

async function handleActivityCommand(
  bot: Bot,
  chatId: string,
  target: PeerRecord,
  cmd: 'on' | 'off' | 'status',
): Promise<void> {
  if (cmd === 'status') {
    const on = activityEnabledForPeer(target)
    await bot.api
      .sendMessage(chatId, `ℹ️ activity-стрим для ${target.personality}: ${on ? 'вкл' : 'выкл'}`)
      .catch(() => {})
    return
  }
  const want = cmd === 'on'
  try {
    const profile = readPeerProfile(target.cwd)
    if (!profile) {
      throw new TelegramRuntimeError(`profile for ${target.personality} not found at ${target.cwd}`)
    }
    writePeerProfile(target.cwd, setTelegramInterface(profile, { activity: want }))
    logActivity('toggle', { peer: target.personality, on: want })
    await bot.api
      .sendMessage(
        chatId,
        `${want ? '🟢' : '⚪️'} activity-стрим для ${target.personality} ${want ? 'включён' : 'выключен'}`,
      )
      .catch(() => {})
  } catch (err) {
    await bot.api
      .sendMessage(chatId, `⚠️ activity для ${target.personality}: ${formatError(err)}`)
      .catch(() => {})
  }
}

// Resolve the `iapeer` control binary. This is a DIFFERENT binary from the IAP
// send bin (`ctx.iapBin`, default `iap`): `iapeer interrupt …` lives at
// ~/.local/bin/iapeer. Prefer an explicit env override, then the absolute
// install path (so we never depend on launchd's PATH), then bare `iapeer`.
function resolveIapeerBin(): string {
  const override = (process.env.TELEGRAM_RUNTIME_IAPEER_BIN ?? '').trim()
  if (override) return override
  const installed = join(homedir(), '.local', 'bin', 'iapeer')
  if (existsSync(installed)) return installed
  return 'iapeer'
}

// Spawn `iapeer interrupt <personality> <runtime>` for the peer bound to this
// bot and feed the result back to the operator. The contract (fixed by iapeer):
//   exit 0 → interrupted (stdout «interrupt → <p> (<rt>)»); context intact.
//   exit 1 → peer not in a live session (stderr «… peer offline: <p> (<rt>)»).
// Runtime is passed EXPLICITLY (we know the chat→peer→runtime binding via the
// peer record), avoiding the CLI's "specify runtime" error when 2+ runtimes are
// live. Idempotent and safe to repeat. Any spawn/parse failure is surfaced to
// the operator and swallowed — a control command must never throw into the
// delivery path.
export async function handleStopCommand(bot: Bot, chatId: string, target: PeerRecord): Promise<void> {
  const bin = resolveIapeerBin()
  const result = spawnSync(bin, ['interrupt', target.personality, target.runtime], {
    encoding: 'utf8',
  })
  const stdout = (result.stdout ?? '').trim()
  const stderr = (result.stderr ?? '').trim()
  logActivity('stop', {
    peer: target.personality,
    runtime: target.runtime,
    status: result.status,
    error: result.error?.message,
  })
  // Mechanics speak plain dry English, no emoji, no peer-name prefix (a UX
  // decision) — same voice as the lifecycle verdicts above: the verdict
  // lands in the peer's own chat, the name would duplicate the chat itself.
  let feedback: string
  if (result.error) {
    feedback = `interrupt failed: ${result.error.message}`
  } else if (result.status === 0) {
    feedback = 'interrupted'
  } else if (/offline/i.test(stderr) || /offline/i.test(stdout)) {
    feedback = 'not in an active session'
  } else {
    feedback = `interrupt failed: ${stderr || stdout || `exit ${result.status}`}`
  }
  await bot.api.sendMessage(chatId, feedback).catch(() => {})
}

// Run a control binary WITHOUT blocking the event loop. `iapeer new` is a real
// TUI boot — typically 5–30s, bootDeadline ceiling 240s (iapeer 0.2.43 contract)
// — and a spawnSync of that length would freeze EVERY bot's polling and the
// whole outbound queue (this runtime carries the fleet). Timeout kills the
// child and resolves; never rejects.
export function runControlBinary(
  bin: string,
  args: string[],
  timeoutMs: number,
): Promise<{ status: number | null; stdout: string; stderr: string; error?: Error; timedOut?: boolean }> {
  return new Promise(resolve => {
    let stdout = ''
    let stderr = ''
    let settled = false
    const finish = (r: { status: number | null; error?: Error; timedOut?: boolean }) => {
      if (settled) return
      settled = true
      clearTimeout(timer)
      resolve({ stdout, stderr, ...r })
    }
    let child: ReturnType<typeof spawn>
    const timer = setTimeout(() => {
      try {
        child?.kill('SIGKILL')
      } catch {}
      finish({ status: null, timedOut: true })
    }, timeoutMs)
    try {
      child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] })
    } catch (err) {
      finish({ status: null, error: err as Error })
      return
    }
    child.stdout?.on('data', d => (stdout += String(d)))
    child.stderr?.on('data', d => (stderr += String(d)))
    child.on('error', err => finish({ status: null, error: err }))
    child.on('close', code => finish({ status: code }))
  })
}

// Generous external backstops over iapeer's own internal ceilings, so iapeer
// returns its truthful verdict before this channel timeout would SIGKILL the
// process — both default to 300s:
//   - new: a cold-resume/ready gate (bootDeadline 240s) can elapse before the
//     control keystroke even lands.
//   - compact: `iapeer compact` now BLOCKS on its waitForCompactDone gate until
//     the runtime writes a structured completion marker (Claude `compact_boundary`
//     / Codex `context_compacted`) — it no longer returns the instant `/compact`
//     was sent. That gate's own timeout (290s) sits deliberately INSIDE our 300s,
//     so a hung compact surfaces as iapeer's honest failure text, not our timeout.
const LIFECYCLE_TIMEOUT_MS: Record<'new' | 'compact', number> = {
  new: Number(process.env.TELEGRAM_NEW_TIMEOUT_MS ?? '') || 300_000,
  compact: Number(process.env.TELEGRAM_COMPACT_TIMEOUT_MS ?? '') || 300_000,
}

// Spawn `iapeer <new|compact> <personality> <runtime>` for the peer bound to
// this bot and feed the result back to the operator — the same shape as
// handleStopCommand (`iapeer interrupt`). Contract fixed with iapeer (topic
// control-commands, 12.06, his half live in 0.2.43):
//   new:     exit 0 ⟺ a fresh session is UP and READY (ready-gate verified) —
//            for a sleeping, dead AND hung peer alike; an explicit operator
//            /new also clears a C1 stop-parking (operator wins). stdout:
//            «new: <rt>-<p> fresh session up».
//   compact: exit 0 → the command landed in the dialogue; stdout may be
//            «compact → <peer> (<runtime>) after resume» when a clean-asleep
//            peer was resumed first. exit != 0 + stable token
//            «nothing-to-compact» → normal no-op (fresh context / no resumable
//            transcript), not a failure. Legacy/race «peer offline» still maps
//            to "not in an active session". Codex /compact exists since codex
//            0.138 and is mapped in iapeer's adapter from 0.2.43.
// Runtime is passed EXPLICITLY from the chat→peer→runtime binding. Repeats are
// idempotent-safe (wake-lock serialized on iapeer's side). Any failure is
// surfaced and swallowed — control must never throw into delivery.
export async function handleLifecycleCommand(
  bot: Bot,
  chatId: string,
  target: PeerRecord,
  op: 'new' | 'compact',
): Promise<void> {
  const bin = resolveIapeerBin()
  if (op === 'new') {
    // A real boot takes 5–30s — acknowledge immediately so the operator knows
    // the handle was grabbed; the verdict follows when the session is READY.
    // Mechanics speak plain dry English, no emoji, and NO peer-name prefix
    // (a UX decision): the verdict lands in the peer's own chat from
    // the peer's own bot — the name would duplicate what the chat already says.
    await bot.api.sendMessage(chatId, 'restarting session...').catch(() => {})
  }
  const result = await runControlBinary(
    bin,
    [op, target.personality, target.runtime],
    LIFECYCLE_TIMEOUT_MS[op],
  )
  const stdout = result.stdout.trim()
  const stderr = result.stderr.trim()
  logActivity(op, {
    peer: target.personality,
    runtime: target.runtime,
    status: result.status,
    timedOut: result.timedOut,
    error: result.error?.message,
  })
  let feedback: string
  if (result.error) {
    feedback = `${op} failed: ${result.error.message}`
  } else if (result.timedOut) {
    feedback = `${op} timed out after ${Math.round(LIFECYCLE_TIMEOUT_MS[op] / 1000)}s — check the session`
  } else if (result.status === 0) {
    feedback = op === 'new' ? 'fresh session up' : 'context compacted'
  } else if (op === 'compact' && /nothing-to-compact/i.test(`${stderr}\n${stdout}`)) {
    feedback = 'nothing to compact — context is fresh'
  } else if (/offline/i.test(stderr) || /offline/i.test(stdout)) {
    feedback = 'not in an active session'
  } else {
    feedback = `${op} failed: ${stderr || stdout || `exit ${result.status}`}`
  }
  await bot.api.sendMessage(chatId, feedback).catch(() => {})
}

// PERMANENT runtime switch (Telegram /<runtime>): make <toRuntime> the peer's DEFAULT (so it
// survives idle-reap/wake) AND restart the peer onto it now. Two ordered iapeer verbs:
//   1. `default-runtime <rt> --peer <p>` — PERSISTS: writes the local peer-profile `runtime`
//      (the source of truth) THEN reindexes the registry projection — atomic, no-clobber,
//      idempotent (contract confirmed with iapeer 2026-06-22; don't hand-write the profile —
//      a local- or registry-only edit desyncs against reindexFromLocals).
//   2. `new <p> <rt>` — one-shot restart onto it (stays generic; persistence is verb #1's job).
// Persist FIRST and ABORT the restart on its failure: restarting without a persisted default
// would bring the peer up on <rt> once and silently revert to the old default on the next wake
// (exactly the bug this fixes — /<rt> used to be `new` alone). Pre-check the peer DECLARES the
// runtime for clean feedback. Any failure is surfaced and swallowed — control must never throw
// into the delivery path.
export async function handleRuntimeSwitchCommand(
  bot: Bot,
  chatId: string,
  target: PeerRecord,
  toRuntime: string,
): Promise<void> {
  // No-op ONLY when <toRuntime> is already BOTH the default (target.runtime = default_runtime)
  // AND the live runtime. A peer live-on-X but default-still-Y still needs the persist, so the
  // live check alone is insufficient for the permanent-switch semantic.
  if (target.runtime === toRuntime && (await resolveLiveRuntime(target)) === toRuntime) {
    await bot.api.sendMessage(chatId, `already on ${toRuntime}`).catch(() => {})
    return
  }
  const declared = target.runtimes?.length ? target.runtimes : [target.runtime]
  if (!declared.includes(toRuntime)) {
    await bot.api
      .sendMessage(chatId, `${target.personality} does not declare runtime ${toRuntime} (has: ${declared.join(', ')})`)
      .catch(() => {})
    return
  }
  const bin = resolveIapeerBin()
  await bot.api.sendMessage(chatId, `switching to ${toRuntime}...`).catch(() => {})

  // 1. Persist the default (skip when already the default — the verb is idempotent, but avoid
  //    a needless reindex). On any failure, abort BEFORE restarting (see header).
  if (target.runtime !== toRuntime) {
    const persist = await runControlBinary(
      bin,
      ['default-runtime', toRuntime, '--peer', target.personality],
      LIFECYCLE_TIMEOUT_MS.new,
    )
    logActivity('runtime-switch', {
      peer: target.personality,
      from: target.runtime,
      to: toRuntime,
      phase: 'persist-default',
      status: persist.status,
      timedOut: persist.timedOut,
      error: persist.error?.message,
    })
    if (persist.error || persist.timedOut || persist.status !== 0) {
      const why = persist.error?.message
        ? persist.error.message
        : persist.timedOut
          ? 'timed out'
          : persist.stderr.trim() || persist.stdout.trim() || `exit ${persist.status}`
      await bot.api
        .sendMessage(chatId, `switch to ${toRuntime} aborted — could not set default runtime: ${why}`)
        .catch(() => {})
      return
    }
  }

  // 2. Restart onto the (now-default) runtime.
  const result = await runControlBinary(bin, ['new', target.personality, toRuntime], LIFECYCLE_TIMEOUT_MS.new)
  const stdout = result.stdout.trim()
  const stderr = result.stderr.trim()
  logActivity('runtime-switch', {
    peer: target.personality,
    from: target.runtime,
    to: toRuntime,
    phase: 'restart',
    status: result.status,
    timedOut: result.timedOut,
    error: result.error?.message,
  })
  let feedback: string
  if (result.error) {
    feedback = `default set to ${toRuntime}, but restart failed: ${result.error.message}`
  } else if (result.timedOut) {
    feedback = `default set to ${toRuntime}; restart timed out after ${Math.round(LIFECYCLE_TIMEOUT_MS.new / 1000)}s — check the session`
  } else if (result.status === 0) {
    feedback = `switched to ${toRuntime} (now default) — fresh session up`
  } else if (/offline/i.test(stderr) || /offline/i.test(stdout)) {
    feedback = `default set to ${toRuntime}; not in an active session`
  } else {
    feedback = `default set to ${toRuntime}, but restart failed: ${stderr || stdout || `exit ${result.status}`}`
  }
  await bot.api.sendMessage(chatId, feedback).catch(() => {})
}

// Transcribe an inbound voice file to text. Tiered, all knobs via env:
//   1. TELEGRAM_STT_ENDPOINT — OpenAI-compatible POST /v1/audio/transcriptions
//      (e.g. speaches / faster-whisper-server). Direct fetch, NOT runtimeFetch:
//      the STT box is a LAN/operator service, not external Telegram traffic
//      behind the killswitch proxy.
//   2. TELEGRAM_STT_FALLBACK_CMD (default `mlx_whisper`, empty to disable) —
//      local CLI fallback; writes <name>.txt to a temp --output-dir.
//   3. null — caller delivers the raw audio as an attachment (degrade safely).
// TELEGRAM_STT_PROMPT (optional) primes the decoder for both tiers — see below.
async function transcribeVoice(filePath: string): Promise<string | null> {
  const lang = (process.env.TELEGRAM_STT_LANGUAGE ?? '').trim()
  // Optional decoder-priming prompt. Not transcribed into the output; it biases
  // punctuation, casing and term spelling (e.g. "Claude Code"/"Gemini" stay in
  // Latin instead of being phonetically mangled). OpenAI-compatible `prompt`
  // field for /v1/audio/transcriptions; `--initial-prompt` for mlx_whisper.
  const prompt = (process.env.TELEGRAM_STT_PROMPT ?? '').trim()
  const timeoutMs = Number(process.env.TELEGRAM_STT_TIMEOUT_MS ?? '') || 30000

  const endpoint = (process.env.TELEGRAM_STT_ENDPOINT ?? '').trim()
  if (endpoint) {
    try {
      const form = new FormData()
      form.append('file', new File([readFileSync(filePath)], basename(filePath)))
      const model = (process.env.TELEGRAM_STT_MODEL ?? '').trim()
      if (model) form.append('model', model)
      if (lang) form.append('language', lang)
      if (prompt) form.append('prompt', prompt)
      form.append('response_format', 'text')
      const res = await fetch(endpoint, {
        method: 'POST',
        body: form,
        signal: AbortSignal.timeout(timeoutMs),
      })
      if (res.ok) {
        const text = (await res.text()).trim()
        if (text) return text
        process.stderr.write('telegram-runtime: STT endpoint returned empty text\n')
      } else {
        process.stderr.write(`telegram-runtime: STT endpoint HTTP ${res.status}\n`)
      }
    } catch (err) {
      process.stderr.write(`telegram-runtime: STT endpoint failed: ${formatError(err)}\n`)
    }
  }

  const cmd =
    process.env.TELEGRAM_STT_FALLBACK_CMD === undefined
      ? 'mlx_whisper'
      : process.env.TELEGRAM_STT_FALLBACK_CMD.trim()
  if (cmd) {
    const outDir = join(tmpdir(), `tg-stt-${randomUUID()}`)
    try {
      mkdirSync(outDir, { recursive: true })
      const cmdArgs = [filePath, '--output-format', 'txt', '--output-dir', outDir]
      const fbModel = (process.env.TELEGRAM_STT_FALLBACK_MODEL ?? '').trim()
      if (fbModel) cmdArgs.push('--model', fbModel)
      if (lang) cmdArgs.push('--language', lang)
      if (prompt) cmdArgs.push('--initial-prompt', prompt)
      const r = spawnSync(cmd, cmdArgs, { encoding: 'utf8', timeout: timeoutMs * 4 })
      if (r.status === 0) {
        const txtPath = join(outDir, basename(filePath).replace(/\.[^.]+$/, '') + '.txt')
        if (existsSync(txtPath)) {
          const text = readFileSync(txtPath, 'utf8').trim()
          if (text) return text
        }
      } else {
        process.stderr.write(
          `telegram-runtime: STT fallback "${cmd}" exit ${r.status ?? 'signal'}\n`,
        )
      }
    } catch (err) {
      process.stderr.write(`telegram-runtime: STT fallback error: ${formatError(err)}\n`)
    } finally {
      try {
        rmSync(outDir, { recursive: true, force: true })
      } catch {}
    }
  }

  return null
}

async function handleInboundMessage(args: {
  ctx: RuntimeContext
  botKey: string
  bot: Bot
  credential: BotCredential
  telegramCtx: any
  text: string
  attachment?: {
    kind: string
    fileId: string
    uniqueId?: string
  }
}): Promise<void> {
  const fromId = String(args.telegramCtx.from?.id ?? '')
  if (fromId !== args.ctx.ownerUserId) return

  const directory = readPeerDirectory()
  const target = directory.byTelegramBot.get(args.botKey)
  if (!target) {
    await args.bot.api.sendMessage(
      args.ctx.ownerUserId,
      `telegram-runtime: bot "${args.botKey}" is not mapped to a peer`,
    ).catch(() => {})
    return
  }

  // Activity-channel control command, scoped to the peer bound to this bot.
  // Intercepted before alias expansion and IAP delivery so the peer-LLM never
  // sees `/activity`. The fromId === ownerUserId guard above already restricts
  // this to the human operator.
  if (!args.attachment) {
    const activityCmd = parseActivityCommand(args.text)
    if (activityCmd) {
      await handleActivityCommand(args.bot, args.ctx.ownerUserId, target, activityCmd)
      return
    }
    // Single-word "стоп"/"stop"/"/stop" → CONTROL, not a message. Intercepted
    // here (before alias expansion and IAP delivery) so it interrupts the peer's
    // live turn instead of being delivered as text. Multi-word stop phrases fail
    // isStopCommand and fall through to the normal delivery path below.
    if (isStopCommand(args.text)) {
      await handleStopCommand(args.bot, args.ctx.ownerUserId, target)
      return
    }
    // Bare `/new` / `/compact` → lifecycle CONTROL (emergency handle for a
    // wedged peer), intercepted before alias expansion and IAP delivery by the
    // two-level contract: a pure slash command is never a prompt. The
    // `/alias_*` prompt shortcuts are untouched (they expand below).
    const lifecycleCmd = parseLifecycleCommand(args.text)
    if (lifecycleCmd) {
      await handleLifecycleCommand(args.bot, args.ctx.ownerUserId, target, lifecycleCmd)
      return
    }
    // Bare `/claude` / `/codex` → HARD runtime switch CONTROL (mechanical restart of the
    // peer on the target runtime via `iapeer new <peer> <rt>`), intercepted before alias
    // expansion by the two-level contract. The soft, cooperative switch is an owner
    // `/alias_*` whose prompt does handoff → `iapeer default-runtime` → `iapeer self-fresh`.
    const switchRuntime = parseRuntimeSwitchCommand(args.text)
    if (switchRuntime) {
      await handleRuntimeSwitchCommand(args.bot, args.ctx.ownerUserId, target, switchRuntime)
      return
    }
  }

  const attachments: string[] = []
  let voiceTranscript: string | null = null
  if (args.attachment) {
    const filePath = await downloadTelegramFile({
      bot: args.bot,
      token: args.credential.token,
      botKey: args.botKey,
      fileId: args.attachment.fileId,
      uniqueId: args.attachment.uniqueId,
      kind: args.attachment.kind,
    })
    if (args.attachment.kind === 'voice') {
      // Deliver voice as a transcript, not an audio attachment. On STT failure
      // fall back to forwarding the file so the voice is never lost silently.
      // TELEGRAM_STT_KEEP_FILE=1 attaches the audio alongside the transcript.
      voiceTranscript = await transcribeVoice(filePath)
      if (voiceTranscript === null || process.env.TELEGRAM_STT_KEEP_FILE === '1') {
        attachments.push(filePath)
      }
    } else {
      attachments.push(filePath)
    }
  }

  // Operator slash-command expansion per §3.5 IAPeer DECISIONS.
  //
  // The source filter `fromId === ctx.ownerUserId` above already guarantees the
  // message is from a human operator (Telegram-side identity), so the human-only
  // condition of §3.5 is satisfied implicitly. Look up the target peer's profile
  // and substitute the text if it matches an alias key.
  //
  // Reads the profile fresh on every inbound message so operator edits to
  // the alias map take effect without restarting telegram-runtime. The alias
  // source is the top-level expansion.aliases section — see resolveAliases().
  const baseText = voiceTranscript !== null ? `🎤 [voice] ${voiceTranscript}` : args.text
  let deliveredText = baseText
  try {
    const targetProfile = readPeerProfile(target.cwd)
    deliveredText = expandAlias(baseText, resolveAliases(targetProfile))
  } catch (err) {
    // A malformed target profile must not block delivery — log and pass text as-is.
    process.stderr.write(
      `telegram-runtime: cannot read aliases for ${target.personality}: ${formatError(err)}\n`,
    )
  }

  // Piggyback a slash-menu re-sync on owner interaction: the profile was just read, so
  // an alias/runtime edit reflects in the menu immediately (not only on the periodic
  // tick). Best-effort and a no-op when the menu is unchanged.
  void syncBotCommands(args.ctx, args.botKey, args.bot)

  const inboundLen = deliveredText.length
  const inboundAtt = attachments.length
  logInbound('inbound.start', {
    peer: target.personality,
    botKey: args.botKey,
    len: inboundLen,
    att: inboundAtt,
  })
  enqueueIapSend(async () => {
    const startedAt = Date.now()
    const result = await runIapSend(args.ctx, target.personality, deliveredText, attachments)
    const ms = Date.now() - startedAt
    if (!result.ok) {
      logInbound('inbound.fail', {
        peer: target.personality,
        detail: cleanIapSendDetail(result.detail),
        timedOut: result.timedOut ?? false,
        ms,
        len: inboundLen,
        att: inboundAtt,
      })
      const verdict = iapDeliveryFailureVerdict(result)
      process.stderr.write(`telegram-runtime: inbound delivery failed: ${result.detail}\n`)
      await args.bot.api.sendMessage(args.ctx.ownerUserId, verdict).catch(err => {
        process.stderr.write(
          `telegram-runtime: inbound delivery verdict failed: ${formatError(err)}\n`,
        )
      })
      return
    }
    // ok=true from `iapeer send` is NOT a landing guarantee on the woke=false
    // (live-injection) path — see parseIapSendWoke. Record `woke` so a delivery
    // that iapeer confirmed only by an mtime-advance proxy (the false-OK class)
    // is at least visible in the log for after-the-fact correlation.
    logInbound('inbound.ok', {
      peer: target.personality,
      woke: parseIapSendWoke(result.stdout),
      ms,
      len: inboundLen,
      att: inboundAtt,
    })
  })

  // Surface the peer's work while it processes this turn. Fire-and-forget: one
  // watcher polls the peer's pane and refreshes the "typing…" indicator until
  // the pane goes static (turn done) or the cap is hit, AND — if the activity
  // channel is enabled for this peer — tails its transcript into a single
  // editable status message (a second channel).
  // isOperator identifies the human operator(s) so send_to_peer to THEM is hidden
  // from the stream (already the delivered message; the 0.8.1 race) while
  // agent→agent sends stay visible (v0.8.2). Built from the directory read above.
  const operatorTargets = new Set(
    // Operators are HUMAN peers — intelligence `natural` (the foundation normalizes
    // any legacy value at its boundary, so only the current vocabulary arrives here).
    directory.peers
      .filter(p => p.intelligence === 'natural')
      .map(p => p.personality),
  )
  const isOperator = (personality: string): boolean => operatorTargets.has(personality)
  void watchPeerTurn(args.bot, args.ctx.ownerUserId, target, isOperator)
}

// ─── Slash-menu auto-registration (setMyCommands) ────────────────────────────
// Each bot's Telegram slash menu is auto-built from the commands that ALREADY work in
// the channel — control + hard runtime-switch + the peer's `/alias_*` prompt shortcuts —
// so the owner gets a discoverable, autocompleted menu instead of having to know them by
// heart. Scoped to the owner's private chat (BotCommandScopeChat) so non-owners never
// see control. It surfaces the existing two-level model; it adds no new semantics.

// Telegram allows ONLY [a-z0-9_], 1–32 chars, in a REGISTERED command name (hyphen is
// rejected — core.telegram.org/bots/features). Aliases live under `/alias_*`; any key
// that still violates the grammar is skipped (logged) so one bad alias cannot make
// setMyCommands reject the whole batch.
function isValidCommandName(name: string): boolean {
  return /^[a-z0-9_]{1,32}$/.test(name)
}

// A Telegram command description is 1–256 chars. Collapse the alias prompt to one line
// and truncate — a readable hint of what the alias does.
function commandDescription(text: string): string {
  const oneLine = text.replace(/\s+/g, ' ').trim()
  const d = oneLine.length > 0 ? oneLine : 'prompt alias'
  return d.length > 256 ? `${d.slice(0, 253)}...` : d
}

// Build a bot's slash menu for the peer bound to it: control commands + hard
// runtime-switch (one per DECLARED agent runtime, only when ≥2 so there is a real
// choice) + the peer's prompt aliases (canonical expansion.aliases via resolveAliases).
// Invalid command names are filtered.
export function buildBotCommands(
  target: PeerRecord,
  profile: PeerProfile | null,
): { command: string; description: string }[] {
  const cmds: { command: string; description: string }[] = [
    { command: 'new', description: 'restart the session (fresh, unconditional)' },
    { command: 'compact', description: 'compact the session context' },
    { command: 'stop', description: 'interrupt the current turn' },
    { command: 'activity', description: 'toggle the activity stream (on/off/status)' },
  ]
  const declaredAgentRuntimes = (target.runtimes?.length ? target.runtimes : [target.runtime]).filter(rt =>
    (RUNTIME_SWITCH_RUNTIMES as readonly string[]).includes(rt),
  )
  if (declaredAgentRuntimes.length >= 2) {
    for (const rt of declaredAgentRuntimes) {
      cmds.push({ command: rt, description: `switch this peer to ${rt} (restart)` })
    }
  }
  const aliases = resolveAliases(profile)
  if (aliases) {
    for (const [key, prompt] of Object.entries(aliases)) {
      const name = key.startsWith('/') ? key.slice(1) : key
      if (!isValidCommandName(name)) {
        process.stderr.write(
          `telegram-runtime: skipping alias "${key}" — not a valid Telegram command name [a-z0-9_]{1,32}\n`,
        )
        continue
      }
      cmds.push({ command: name, description: commandDescription(prompt) })
    }
  }
  return cmds
}

// Last-registered command signature per bot, so a sync calls setMyCommands ONLY when the
// menu changed. Start, periodic re-sync, and inbound piggyback all funnel through here.
const registeredCommands = new Map<string, string>()

// Register (or refresh) a bot's owner-scoped slash menu. Best-effort: a missing target,
// unreadable profile, or Telegram API error is logged and swallowed — the menu is a
// convenience and must NEVER break polling or delivery.
async function syncBotCommands(ctx: RuntimeContext, botKey: string, bot: Bot): Promise<void> {
  try {
    const target = readPeerDirectory().byTelegramBot.get(botKey)
    if (!target) return
    let profile: PeerProfile | null = null
    try {
      profile = readPeerProfile(target.cwd)
    } catch {
      // malformed profile → still register control + runtime-switch (aliases skipped)
    }
    const commands = buildBotCommands(target, profile)
    const signature = JSON.stringify(commands)
    if (registeredCommands.get(botKey) === signature) return
    await bot.api.setMyCommands(commands, { scope: { type: 'chat', chat_id: Number(ctx.ownerUserId) } })
    registeredCommands.set(botKey, signature)
    logActivity('commands-sync', { bot: botKey, peer: target.personality, count: commands.length })
  } catch (err) {
    process.stderr.write(`telegram-runtime: setMyCommands failed for ${botKey}: ${formatError(err)}\n`)
  }
}

// ── Owner-facing fleet faces (Ф3, docs/17 + docs/19) ────────────────────────
// telegram-runtime is a FACE on two daemon surfaces, both carried by ONE fleet SSE
// subscription (fleetFace.ts):
//
//   • APPROVALS (docs/17) — a card per pending request; a button tap posts the resolution
//     back to the broker. All decision logic and fail-safety live in the broker/hook —
//     this layer only shows a card and relays a click.
//   • NOTICES (docs/19) — a message when a peer goes MUTE (an API error left it alive but
//     unable to answer: exhausted model limit, overload, stale auth). One-way: no buttons.
//     It reaches the owner "по принципу аппрува" — on HIS surface, routed the same way a
//     card is, which is the whole point: a faceless Implementer peer has no Telegram
//     dialog of its own, so without this fan-out its muting is unreportable BY ANYONE
//     (the muted peer itself is, definitionally, the one that cannot speak). That is
//     exactly what happened fleet-wide on 15.07.2026 when the fable bucket emptied and
//     14 peers went silent with every health signal green.
//
// The two are gated SEPARATELY (TELEGRAM_APPROVAL / TELEGRAM_NOTICES): silencing cards
// must never silence mute-reporting, which is a diagnostic channel of last resort.
const APPROVAL_ENABLED = process.env.TELEGRAM_APPROVAL !== '0'
const APPROVAL_LOG_ENABLED = process.env.TELEGRAM_APPROVAL_LOG !== '0'
const NOTICES_ENABLED = process.env.TELEGRAM_NOTICES !== '0'
const NOTICE_LOG_ENABLED = process.env.TELEGRAM_NOTICE_LOG !== '0'
/** Credential role value marking the shared approval bot (Ф3 U4). It is a pure
 *  telegram-runtime service-bot — NOT a foundation peer — so faceless approval delivery
 *  never depends on the registry / an intelligence classification (validated with iapeer
 *  2026-07-06: the approval path is queue-driven; send_to_peer(approval) is off the
 *  delivery critical path). Provisioned via `onboard-approval`, polled by the run-loop
 *  like any credential, resolved to for faceless peers by pickApprovalRoute. */
const APPROVAL_BOT_ROLE = 'approval'

/** Structured stderr log for the fleet faces. `evt` is FULLY QUALIFIED and its first
 *  segment names the surface (`approval.*` | `notice.*` | `fleet.*` for the shared
 *  connection), which selects both the kill switch and the log's surface word. The face
 *  emits qualified names directly; the helpers below keep the local call sites terse.
 *
 *  `fleet.*` events describe the connection BOTH surfaces ride, so they survive while
 *  EITHER surface's log is on — silencing approval logs must not blind notice debugging
 *  to the stream's own connect/error events. */
function logFleet(evt: string, fields: Record<string, unknown> = {}): void {
  const surface = evt.split('.')[0]!
  const enabled =
    surface === 'notice' ? NOTICE_LOG_ENABLED
    : surface === 'approval' ? APPROVAL_LOG_ENABLED
    : NOTICE_LOG_ENABLED || APPROVAL_LOG_ENABLED
  if (!enabled) return
  const payload: Record<string, unknown> = { ts: new Date().toISOString(), evt }
  for (const [k, v] of Object.entries(fields)) if (v !== undefined) payload[k] = v
  process.stderr.write(`telegram-runtime ${surface} ${JSON.stringify(payload)}\n`)
}

function logApproval(event: string, fields: Record<string, unknown> = {}): void {
  logFleet(`approval.${event}`, fields)
}

function logNotice(event: string, fields: Record<string, unknown> = {}): void {
  logFleet(`notice.${event}`, fields)
}

type ApprovalCardEntry = { chatId: string; botKey: string; messageId: number; item: PendingApproval }

/** PURE routing decision for an approval card (docs/17 §2b, criterion #3/#4). Exported for
 *  tests — the faced/faceless/no-route split is the crux of the routing contract:
 *  - FACED peer (its own bot loaded) → its own bot ('faced'): the owner's single TG dialog
 *    with that peer, which IS the "same dialog" criterion #3 asks for;
 *  - FACELESS peer (no own bot) → the shared approval SERVICE-bot ('faceless', U4) — the
 *    loaded credential whose role=approval, NOT a foundation peer lookup (decoupled from
 *    the registry per the 2026-07-06 iapeer validation);
 *  - neither → null: no Telegram route, the bar/CLI still hold the request (not lost). */
export function pickApprovalRoute(
  personality: string,
  byPersonality: Map<string, { interfaces?: { telegram?: { bot_username?: string } } }>,
  hasBot: (botKey: string) => boolean,
  approvalBotKey: string | undefined,
): { botKey: string; kind: 'faced' | 'faceless' } | null {
  const peer = byPersonality.get(personality)
  const ownKey = peer ? peerBotKey(peer as PeerProfile) : undefined
  if (ownKey && hasBot(ownKey)) return { botKey: ownKey, kind: 'faced' }
  if (approvalBotKey && hasBot(approvalBotKey)) return { botKey: approvalBotKey, kind: 'faceless' }
  return null
}

/** Bind the pure route to this runtime's live bots + owner dialog.
 *
 *  SHARED BY BOTH SURFACES: a notice (docs/19) takes the identical route to a card, which
 *  is precisely what "уведомлять по принципу аппрува" means — the owner hears about a peer
 *  wherever he already talks to (or about) that peer, and a peer with no face of its own
 *  still reaches him via the shared service-bot. The `approval` naming here is HISTORY
 *  (approvals came first) and stops at the wire: `role=approval` is a provisioned
 *  credential value on a live host, so renaming it would be a breaking change to
 *  provisioning for zero behavioural gain. Read it as "the owner's service bot". */
function resolveApprovalRoute(
  ctx: RuntimeContext,
  personality: string,
): { bot: Bot; botKey: string; kind: 'faced' | 'faceless'; chatId: string } | null {
  const directory = readPeerDirectory()
  const route = pickApprovalRoute(
    personality,
    directory.byPersonality,
    key => ctx.bots.has(key),
    ctx.approvalBotKey,
  )
  if (!route) return null
  return { bot: ctx.bots.get(route.botKey)!, botKey: route.botKey, kind: route.kind, chatId: ctx.ownerUserId }
}

/** Start the fleet face when the daemon advertises the fleet surface AND at least one of
 *  our two surfaces is both enabled and actually served. Returns the running face (for
 *  cleanup) or null (everything off / pre-fleet daemon).
 *
 *  Each surface is probed LIVE, not just feature-flagged: `fleet:1` in router.json only
 *  says the Fleet API exists, and a daemon older than 0.4.94 serves approvals while 404ing
 *  notices. Probing keeps such a daemon from spraying a 404 through every reconcile.
 *
 *  A probe disables a surface ONLY on a definitive answer (`absent`). An `unreachable`
 *  daemon leaves the surface ON: router.json outlives a non-graceful daemon exit, so a
 *  runtime that boots before the daemon listens sees `fleet:1` + a refused connection —
 *  and treating that as "not served" would kill approvals AND notices for the whole
 *  process lifetime. The face's reconnect loop is what heals that, so let it.
 *
 *  This runs on the STARTUP path, ahead of Telegram polling, so the probes go out
 *  concurrently under a short deadline: a wedged daemon must cost the owner's bridge a
 *  couple of seconds, not two full FLEET_CALL_TIMEOUT_MS in series.
 *
 *  The card registry is closed over here — its `onResolved` edit is the SINGLE
 *  authoritative card edit, so a resolution from ANY channel (button, CLI, tray, timeout)
 *  converges on it. Notices have no such registry: they are never edited. */
const FACE_PROBE_TIMEOUT_MS = 2_000

async function startFleetFace(ctx: RuntimeContext): Promise<FleetFace | null> {
  if (!fleetAvailable()) {
    logFleet('fleet.face.off', { reason: 'no fleet in router.json (pre-fleet daemon)' })
    return null
  }
  const base = resolveFleetBase()
  ctx.approvalBase = base

  let approvalOn = APPROVAL_ENABLED
  let noticesOn = NOTICES_ENABLED
  if (!approvalOn) logApproval('face.off', { reason: 'TELEGRAM_APPROVAL=0' })
  if (!noticesOn) logNotice('face.off', { reason: 'TELEGRAM_NOTICES=0' })

  const probe = (path: string, on: boolean): Promise<SurfaceState | null> =>
    on ? probeSurface(base, path, { timeoutMs: FACE_PROBE_TIMEOUT_MS }) : Promise.resolve(null)
  const [approvalState, noticeState] = await Promise.all([
    probe(APPROVALS_PATH, approvalOn),
    probe(NOTICES_PATH, noticesOn),
  ])

  if (approvalState === 'absent') {
    approvalOn = false
    logApproval('face.off', { reason: `GET ${base}${APPROVALS_PATH} not 200 (pre-approval daemon)` })
  }
  if (noticeState === 'absent') {
    noticesOn = false
    logNotice('face.off', { reason: `GET ${base}${NOTICES_PATH} not 200 (daemon < 0.4.94)` })
  }
  if (approvalState === 'unreachable' || noticeState === 'unreachable') {
    // Deliberately NOT a reason to disable — see the doc comment above.
    logFleet('fleet.face.probe.unreachable', { base, note: 'daemon not listening yet; the face loop will retry' })
  }

  if (!approvalOn && !noticesOn) return null

  const cards = new Map<string, ApprovalCardEntry>()
  const face = new FleetFace({
    base,
    log: logFleet,
    notice: noticesOn ? { onNotice: item => sendNotice(ctx, item) } : undefined,
    approval: !approvalOn ? undefined : {
      onRequest: async item => {
        const route = resolveApprovalRoute(ctx, item.personality)
        if (!route) {
          logApproval('noroute', { id: item.id, personality: item.personality })
          return
        }
        try {
          const sent = await route.bot.api.sendMessage(route.chatId, buildCardText(item), {
            parse_mode: 'HTML',
            reply_markup: buildKeyboard(item.id),
          })
          cards.set(item.id, { chatId: route.chatId, botKey: route.botKey, messageId: sent.message_id, item })
          logApproval('card.sent', {
            id: item.id,
            personality: item.personality,
            route: route.kind,
            botKey: route.botKey,
            messageId: sent.message_id,
          })
        } catch (err) {
          logApproval('card.send.error', { id: item.id, reason: formatError(err) })
        }
      },
      onResolved: async info => {
        const entry = cards.get(info.id)
        if (!entry) return // a card we never rendered (faceless noroute / already gone)
        cards.delete(info.id)
        const bot = ctx.bots.get(entry.botKey)
        if (!bot) return
        try {
          await bot.api.editMessageText(entry.chatId, entry.messageId, buildResolvedText(entry.item, info), {
            parse_mode: 'HTML',
          })
          logApproval('card.resolved', { id: info.id, decision: info.decision, via: info.via, by: info.by })
        } catch (err) {
          logApproval('card.edit.error', { id: info.id, reason: formatError(err) })
        }
      },
    },
  })
  face.start()
  logFleet('fleet.face.on', {
    base,
    approval: approvalOn,
    notices: noticesOn,
    approvalBot: ctx.approvalBotKey ?? '(none — faceless→bar/CLI)',
  })
  return face
}

/** Send ONE peer-mute notice to the owner (docs/19). Deliberately thin: render, send, log.
 *  No card registry (nothing ever edits it), no buttons (nothing to decide), no retry
 *  beyond the send itself — a notice the owner missed is re-raised by the daemon after its
 *  TTL, which is a better reminder than anything this layer could invent.
 *
 *  A `noroute` here is the one case where the owner still learns nothing, so it is logged
 *  loudly: it means neither the muted peer nor a shared service-bot can reach him, and the
 *  fix is provisioning (`onboard-approval`), not code. */
async function sendNotice(ctx: RuntimeContext, item: FleetNotice): Promise<void> {
  const route = resolveApprovalRoute(ctx, item.personality)
  if (!route) {
    logNotice('noroute', {
      id: item.id,
      personality: item.personality,
      hint: 'no own bot and no role=approval service bot — run `telegram-runtime onboard-approval`',
    })
    return
  }
  try {
    const sent = await route.bot.api.sendMessage(route.chatId, buildNoticeText(item), { parse_mode: 'HTML' })
    logNotice('sent', {
      id: item.id,
      personality: item.personality,
      runtime: item.runtime,
      kind: item.kind,
      errorType: item.errorType,
      model: item.model,
      // Logged as an explicit null (not omitted) so the audit trail proves we KNEW the
      // runtime stated no reset, rather than leaving it ambiguous whether we simply lost it.
      resetsAtMs: item.resetsAtMs ?? null,
      count: item.count,
      route: route.kind,
      botKey: route.botKey,
      messageId: sent.message_id,
    })
  } catch (err) {
    logNotice('send.error', { id: item.id, personality: item.personality, reason: formatError(err) })
  }
}

/** Handle a tap on an approval card button. Owner-gated (only the owner may resolve — the
 *  same fromId gate as inbound). Posts the resolution to the broker and answers the
 *  callback; the authoritative card EDIT is left to the approval-resolved SSE event
 *  (onResolved) so every channel converges on one rendering. */
async function handleApprovalCallback(ctx: RuntimeContext, cbCtx: Context): Promise<void> {
  const parsed = parseCallbackData(cbCtx.callbackQuery?.data)
  if (!parsed) return // a button that isn't ours — ignore cleanly (shared callback channel)
  const fromId = String(cbCtx.from?.id ?? '')
  if (fromId !== ctx.ownerUserId) {
    await cbCtx.answerCallbackQuery({ text: 'Не для вас' }).catch(() => {})
    logApproval('callback.denied-nonowner', { id: parsed.id, fromId })
    return
  }
  const base = ctx.approvalBase ?? resolveFleetBase()
  const approver = ctx.owner.personality
  const body = parsed.action === 'deny' ? { approver, reason: DENY_REASON } : { approver }
  try {
    const outcome = await resolveApproval(base, parsed.id, parsed.action, body)
    await cbCtx
      .answerCallbackQuery({
        text:
          outcome === 'gone'
            ? 'Уже обработано'
            : parsed.action === 'approve'
              ? '✅ Разрешено'
              : '⛔ Отклонено',
      })
      .catch(() => {})
    logApproval('callback', { id: parsed.id, action: parsed.action, outcome })
  } catch (err) {
    await cbCtx.answerCallbackQuery({ text: 'Ошибка, повторите' }).catch(() => {})
    logApproval('callback.error', { id: parsed.id, reason: formatError(err) })
  }
}

function installBotHandlers(ctx: RuntimeContext, botKey: string, bot: Bot, credential: BotCredential): void {
  bot.on('callback_query:data', async telegramCtx => {
    await handleApprovalCallback(ctx, telegramCtx)
  })
  bot.on('message:text', async telegramCtx => {
    await handleInboundMessage({
      ctx,
      botKey,
      bot,
      credential,
      telegramCtx,
      text: telegramCtx.message.text,
    })
  })
  bot.on('message:photo', async telegramCtx => {
    const photos = telegramCtx.message.photo
    const best = photos[photos.length - 1]
    await handleInboundMessage({
      ctx,
      botKey,
      bot,
      credential,
      telegramCtx,
      text: telegramCtx.message.caption ?? '(photo)',
      attachment: { kind: 'photo', fileId: best.file_id, uniqueId: best.file_unique_id },
    })
  })
  bot.on('message:document', async telegramCtx => {
    const doc = telegramCtx.message.document
    await handleInboundMessage({
      ctx,
      botKey,
      bot,
      credential,
      telegramCtx,
      text: telegramCtx.message.caption ?? `(document: ${doc.file_name ?? 'file'})`,
      attachment: { kind: 'document', fileId: doc.file_id, uniqueId: doc.file_unique_id },
    })
  })
  bot.on('message:voice', async telegramCtx => {
    const voice = telegramCtx.message.voice
    await handleInboundMessage({
      ctx,
      botKey,
      bot,
      credential,
      telegramCtx,
      text: telegramCtx.message.caption ?? '(voice message)',
      attachment: { kind: 'voice', fileId: voice.file_id, uniqueId: voice.file_unique_id },
    })
  })
  bot.on('message:audio', async telegramCtx => {
    const audio = telegramCtx.message.audio
    await handleInboundMessage({
      ctx,
      botKey,
      bot,
      credential,
      telegramCtx,
      text: telegramCtx.message.caption ?? `(audio: ${audio.title ?? audio.file_name ?? 'audio'})`,
      attachment: { kind: 'audio', fileId: audio.file_id, uniqueId: audio.file_unique_id },
    })
  })
  bot.on('message:video', async telegramCtx => {
    const video = telegramCtx.message.video
    await handleInboundMessage({
      ctx,
      botKey,
      bot,
      credential,
      telegramCtx,
      text: telegramCtx.message.caption ?? '(video)',
      attachment: { kind: 'video', fileId: video.file_id, uniqueId: video.file_unique_id },
    })
  })
  bot.on('message:sticker', async telegramCtx => {
    const sticker = telegramCtx.message.sticker
    await handleInboundMessage({
      ctx,
      botKey,
      bot,
      credential,
      telegramCtx,
      text: `(sticker${sticker.emoji ? ` ${sticker.emoji}` : ''})`,
      attachment: { kind: 'sticker', fileId: sticker.file_id, uniqueId: sticker.file_unique_id },
    })
  })
  bot.catch(err => {
    process.stderr.write(`telegram-runtime: bot ${botKey} handler error: ${err.error}\n`)
  })
}

async function startPolling(botKey: string, bot: Bot): Promise<void> {
  for (let attempt = 1; ; attempt++) {
    try {
      await bot.start({
        onStart: info => {
          attempt = 0
          process.stderr.write(`telegram-runtime: polling ${botKey} as @${info.username}\n`)
        },
      })
      return
    } catch (err) {
      const delay = Math.min(1000 * attempt, 15000)
      process.stderr.write(
        `telegram-runtime: polling ${botKey} failed: ${formatError(err)}, retrying in ${delay / 1000}s\n`,
      )
      await sleep(delay)
    }
  }
}

// A line that BELONGS to a plain text paragraph — i.e. not a structural GFM
// marker. Only plain↔plain boundaries get a spacer: headings, tables, lists,
// quotes, fences, dividers, footnote definitions and HTML-tag lines render
// with their own spacing, and a spacer next to them would add ugly empty
// blocks (worse than the defect, per the owner's acceptance bar).
function isPlainParagraphLine(line: string): boolean {
  const t = line.trim()
  if (!t) return false
  return !/^(#{1,6}\s|\||>|```|~~~|(-\s*){3,}$|(\*\s*){3,}$|(_\s*){3,}$|[-*+]\s|\d+[.)]\s|\[\^|<)/.test(t)
}

// Insert a spacer paragraph between adjacent PLAIN paragraphs so Telegram
// clients show vertical air (see RICH_SPACER_ENABLED). Fence-aware: blank
// lines INSIDE ``` / ~~~ code fences are code, not paragraph breaks — they
// pass through verbatim. Exported for tests.
export function spaceRichParagraphs(text: string): string {
  const lines = text.split('\n')
  const out: string[] = []
  let inFence = false
  let i = 0
  while (i < lines.length) {
    const line = lines[i]
    if (/^\s*(```|~~~)/.test(line)) inFence = !inFence
    out.push(line)
    i++
    if (inFence) continue
    // At a blank run outside a fence: peek the next non-blank line; spacer only
    // when BOTH neighbours are plain paragraph text.
    if (line.trim() !== '') continue
    let j = i
    while (j < lines.length && lines[j].trim() === '') j++
    if (j >= lines.length) continue
    const prev = out.length >= 2 ? out[out.length - 2] : ''
    if (isPlainParagraphLine(prev) && isPlainParagraphLine(lines[j])) {
      out.push(RICH_SPACER_LINE, '')
    }
    while (i < j) {
      out.push(lines[i])
      i++
    }
  }
  return out.join('\n')
}

// Harden in-paragraph SOFT breaks into GFM HARD breaks so the rich path renders
// the agent's single \n line-by-line instead of space-joining it (see
// RICH_HARDBREAK_ENABLED). Mirrors spaceRichParagraphs' guards so the two stay
// in lockstep:
//   - fence-aware: lines inside ``` / ~~~ are code, passed through verbatim;
//   - only a PLAIN line followed by another PLAIN line is hardened. Structural
//     GFM (headings, tables, lists, quotes, dividers, fences, footnote defs,
//     HTML) already renders line-by-line via its own block semantics, and a
//     trailing hard-break marker can corrupt a table/list row — so it is left
//     untouched (isPlainParagraphLine is the shared predicate);
//   - \n\n paragraph breaks are left alone (the next line is blank, not plain),
//     so this composes cleanly after the spacer.
// The marker is two trailing spaces (RICH_HARDBREAK_MARK), not a trailing
// backslash: it is purely additive whitespace and cannot collide with line
// content that ends in '\' (which would escape to a literal backslash) nor break
// a setext heading underline. Appending unconditionally yields >=2 trailing
// spaces even when the line already ends in space, so the hard break always
// takes. Exported for tests.
export function hardenSoftBreaks(text: string): string {
  const lines = text.split('\n')
  let inFence = false
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i]
    if (/^\s*(```|~~~)/.test(line)) {
      inFence = !inFence
      continue
    }
    if (inFence) continue
    const next = lines[i + 1]
    if (next === undefined) continue
    if (isPlainParagraphLine(line) && isPlainParagraphLine(next)) {
      lines[i] = line + RICH_HARDBREAK_MARK
    }
  }
  return lines.join('\n')
}

// Try to deliver the WHOLE envelope text as one Bot API 10.1 rich message
// (`rich_message.markdown` — Telegram parses the agent's GFM server-side).
// Returns true when delivered; false hands the envelope to the legacy chunked
// path. Deterministic API rejections (4xx except 429: unknown method, markdown
// the parser refuses, over-limit) fall back immediately — a retry cannot cure
// them and the legacy path still delivers the text. Transport failures
// (timeout / network / 429 / 5xx) get the same bounded retry loop as chunks;
// when they exhaust we STILL return false rather than throw: the legacy path
// gets its own retry budget before the envelope is declared undeliverable.
// Same timeout discipline as chunks: AbortSignal.timeout scoped to this send.
export async function sendRichResilient(bot: Bot, chatId: string, text: string): Promise<boolean> {
  // Typed natively: grammy ≥1.44.0 (@grammyjs/types 3.28) ships the Bot API
  // 10.1 `sendRichMessage` raw method — the pre-1.44 name-keyed-Proxy cast is gone.
  for (let attempt = 0; attempt <= OUTBOUND_SEND_RETRIES; attempt++) {
    const startedAt = Date.now()
    logOutbound('rich.start', {
      chatId,
      len: text.length,
      attempt,
      retries: OUTBOUND_SEND_RETRIES,
      timeoutMs: OUTBOUND_SEND_TIMEOUT_MS,
      queueDepth: outboundQueueDepth,
    })
    try {
      await bot.api.raw.sendRichMessage(
        { chat_id: chatId, rich_message: { markdown: text } },
        AbortSignal.timeout(OUTBOUND_SEND_TIMEOUT_MS),
      )
      logOutbound('rich.ok', { chatId, attempt, ms: Date.now() - startedAt })
      return true
    } catch (err) {
      const c = classifyOutboundError(err)
      // The API understood the request and said no — bad markdown, over-limit,
      // or a Bot API server that predates 10.1 (404 "method not found").
      const deterministic =
        c.kind === 'api' &&
        c.tgCode !== undefined &&
        c.tgCode >= 400 &&
        c.tgCode < 500 &&
        c.tgCode !== 429
      logOutbound(
        deterministic ? 'rich.fallback' : c.kind === 'timeout' ? 'rich.timeout' : 'rich.error',
        {
          chatId,
          attempt,
          ms: Date.now() - startedAt,
          kind: c.kind,
          tgCode: c.tgCode,
          retryAfter: c.retryAfter,
          detail: c.detail,
        },
      )
      if (deterministic) return false
      if (attempt < OUTBOUND_SEND_RETRIES) {
        await sleep(backoffMs(attempt))
      }
    }
  }
  logOutbound('rich.giveup', { chatId, len: text.length })
  return false
}

// Send one chunk with a hard timeout and bounded retries. The 4th arg to
// grammy's API methods is an AbortSignal — AbortSignal.timeout aborts the
// underlying fetch, so a stuck call rejects (and the outbound queue keeps
// moving) instead of hanging the whole channel. Polling (getUpdates) is
// untouched: this timeout is scoped to outbound sends only.
async function sendChunkResilient(
  bot: Bot,
  chatId: string,
  text: string,
  meta: { chunk: number; of: number } = { chunk: 1, of: 1 },
): Promise<void> {
  // Convert agent GFM → valid Telegram MarkdownV2 once; reused across retries.
  // The converter should never throw, but if it ever does on some pathological
  // body, the message must NOT be lost: formatted=null routes the chunk straight
  // to a plain send for every attempt.
  let formatted: string | null
  try {
    formatted = toTelegramMarkdownV2(text)
  } catch (convErr) {
    formatted = null
    logOutbound('chunk.markdown_convert_error', {
      chatId,
      chunk: meta.chunk,
      of: meta.of,
      detail: formatError(convErr),
    })
  }
  let lastErr: unknown
  for (let attempt = 0; attempt <= OUTBOUND_SEND_RETRIES; attempt++) {
    const startedAt = Date.now()
    logOutbound('chunk.start', {
      chatId,
      chunk: meta.chunk,
      of: meta.of,
      len: text.length,
      attempt,
      retries: OUTBOUND_SEND_RETRIES,
      timeoutMs: OUTBOUND_SEND_TIMEOUT_MS,
      queueDepth: outboundQueueDepth,
    })
    try {
      try {
        if (formatted === null) {
          // Conversion failed above — send the original text plain.
          await bot.api.sendMessage(chatId, text, undefined, AbortSignal.timeout(OUTBOUND_SEND_TIMEOUT_MS))
        } else {
          await bot.api.sendMessage(
            chatId,
            formatted,
            { parse_mode: 'MarkdownV2' },
            AbortSignal.timeout(OUTBOUND_SEND_TIMEOUT_MS),
          )
        }
      } catch (err) {
        // MarkdownV2 is strict: a special the converter missed makes the Bot API
        // reject the whole message (400 "can't parse entities"), and escaping can
        // push a chunk past the 4096 cap ("...is too long"). Rather than lose it,
        // resend the ORIGINAL text as plain — formatting is dropped but the
        // message arrives. Only those formatting-class 400s fall back here; a
        // plain send that itself failed (formatted===null) and timeout/network/
        // 429 bubble to the retry loop below unchanged.
        if (formatted === null || !isFormattingError(err)) throw err
        logOutbound('chunk.markdown_fallback', {
          chatId,
          chunk: meta.chunk,
          of: meta.of,
          attempt,
          detail: (err as GrammyError).description,
        })
        await bot.api.sendMessage(chatId, text, undefined, AbortSignal.timeout(OUTBOUND_SEND_TIMEOUT_MS))
      }
      logOutbound('chunk.ok', {
        chatId,
        chunk: meta.chunk,
        of: meta.of,
        attempt,
        ms: Date.now() - startedAt,
      })
      return
    } catch (err) {
      lastErr = err
      const c = classifyOutboundError(err)
      logOutbound(c.kind === 'timeout' ? 'chunk.timeout' : 'chunk.error', {
        chatId,
        chunk: meta.chunk,
        of: meta.of,
        attempt,
        ms: Date.now() - startedAt,
        kind: c.kind,
        tgCode: c.tgCode,
        retryAfter: c.retryAfter,
        detail: c.detail,
      })
      if (attempt < OUTBOUND_SEND_RETRIES) {
        await sleep(backoffMs(attempt))
      }
    }
  }
  logOutbound('chunk.giveup', {
    chatId,
    chunk: meta.chunk,
    of: meta.of,
    detail: formatError(lastErr),
  })
  throw lastErr
}

async function sendOutboundToTelegram(ctx: RuntimeContext, envelope: IapEnvelope): Promise<void> {
  const directory = readPeerDirectory()
  const source = directory.byPersonality.get(envelope.fromPersonality)
  const botKey = source ? peerBotKey(source) : undefined
  if (!botKey) {
    throw new TelegramRuntimeError(
      `source peer "${envelope.fromPersonality}" has no interfaces.telegram.bot_username`,
    )
  }
  const bot = ctx.bots.get(botKey)
  const credential = ctx.credentials.get(botKey)
  if (!bot || !credential) {
    throw new TelegramRuntimeError(`bot "${botKey}" is not loaded`)
  }
  // Rich-first: one structured message via Bot API 10.1 when enabled and the
  // text fits the rich limit; ANY rich failure falls through to the legacy
  // chunked MarkdownV2→plain path, so an envelope is never lost to the rollout.
  // Spacer + soft-break hardening applied on the rich path only (the legacy
  // chunked path renders via MarkdownV2 where \n\n already shows air and a single
  // \n already breaks the line); gate on the FINAL transformed length so the text
  // that actually goes out is what was measured. Spacer first (it works on the
  // agent's original \n\n paragraph structure), then harden the single \n inside
  // paragraphs into hard breaks — the spacer's &nbsp; lines sit between blanks and
  // are left untouched by the harden pass.
  const spacedText = RICH_SPACER_ENABLED ? spaceRichParagraphs(envelope.message) : envelope.message
  const richText = RICH_HARDBREAK_ENABLED ? hardenSoftBreaks(spacedText) : spacedText
  const tryRich = RICH_OUTBOUND_ENABLED && richText.length <= MAX_RICH_TEXT
  // Newline fingerprint (counts only — zero content leak): GFM renders a blank
  // line (\n\n) as a paragraph break but space-joins a single \n (soft break),
  // so "paragraphs merged into one flowing block" downstream is distinguishable
  // by whether \n\n even REACHED the bridge — the question the paragraph-loss
  // defect (2026-06-12) could not answer without body logging.
  logOutbound('envelope.start', {
    from: envelope.fromPersonality,
    botKey,
    rich: tryRich,
    len: envelope.message.length,
    nlnl: (envelope.message.match(/\n\n/g) ?? []).length,
    nl: (envelope.message.match(/\n/g) ?? []).length,
    cr: (envelope.message.match(/\r/g) ?? []).length,
    attachments: envelope.attachments.length,
    queueDepth: outboundQueueDepth,
  })
  let mode: 'rich' | 'chunks' = 'chunks'
  let chunksSent: number | undefined
  if (tryRich && (await sendRichResilient(bot, ctx.ownerUserId, richText))) {
    mode = 'rich'
  } else {
    const chunks = chunkText(envelope.message)
    for (let i = 0; i < chunks.length; i++) {
      await sendChunkResilient(bot, ctx.ownerUserId, chunks[i], { chunk: i + 1, of: chunks.length })
    }
    chunksSent = chunks.length
  }
  for (const filePath of envelope.attachments) {
    await sendFileViaRawApi({ credential, chatId: ctx.ownerUserId, filePath })
  }
  // The answer has landed in the chat. Finalize+reset this peer's live activity
  // status so its stream doesn't stay stuck above the answer — the next tool call
  // opens a fresh status message below it (v0.7). Best-effort, never blocks.
  checkpointActivity(envelope.fromPersonality)
  logOutbound('envelope.done', {
    from: envelope.fromPersonality,
    mode,
    chunks: chunksSent,
    attachments: envelope.attachments.length,
  })
}

function installStdinEnvelopeReader(ctx: RuntimeContext): void {
  let buffer = ''
  // When stdin is a pty (under pty hosting), the canonical line-discipline caps a single
  // line at MAX_CANON (~1024B on macOS) and silently drops every overflow byte
  // (each echoed as BEL ^G), which truncates long single-line IAP envelopes and
  // wedges the channel. Raw mode disables that line-discipline. Guard on isTTY:
  // setRawMode throws on a non-tty stdin (e.g. piped input). The reader already
  // buffers raw bytes and slices on <iap>…</iap> markers, so raw mode is safe.
  if (process.stdin.isTTY) process.stdin.setRawMode(true)
  process.stdin.setEncoding('utf8')
  process.stdin.on('data', chunk => {
    buffer += String(chunk)
    const extracted = extractIapEnvelopes(buffer)
    buffer = extracted.rest
    for (const raw of extracted.envelopes) {
      enqueueOutbound(async () => {
        // No-silent-drop contract (defect track telegram-sender-policy,
        // 2026-06-11): ANY envelope this bridge gives up on MUST leave a
        // STRUCTURED `envelope.drop` event with the reason and (when parseable)
        // the sender. The queue's catch-all already wrote a plain-text stderr
        // line, but plain text is invisible to forensics grepping the
        // structured `evt:` stream — exactly how the "sender without
        // interfaces.telegram.bot" drops at 13:01Z/16:48Z went unseen while
        // the daemon reported ok=true. The error still rethrows so the
        // existing catch-all line stays as a human-readable echo.
        let envelope: IapEnvelope
        try {
          envelope = parseIapEnvelope(raw)
        } catch (err) {
          logOutbound('envelope.drop', { stage: 'parse', reason: formatError(err) })
          throw err
        }
        try {
          await sendOutboundToTelegram(ctx, envelope)
        } catch (err) {
          logOutbound('envelope.drop', {
            stage: 'send',
            from: envelope.fromPersonality,
            reason: formatError(err),
          })
          throw err
        }
      })
    }
  })
  process.stdin.resume()
}

async function prepareCommand(args: string[]): Promise<void> {
  const { flags } = parseFlags(args)
  let profile = ensureCurrentProfile()
  const userId = stringFlag(flags, 'user-id')
  if (userId) {
    profile = setTelegramInterface(profile, { user_id: userId })
    writePeerProfile(process.cwd(), profile)
  }
  process.stdout.write(`${peerProfilePath(process.cwd())}\n`)
  printJson(profile)
}

async function interfaceCommand(args: string[]): Promise<void> {
  const [sub, ...rest] = args
  const { positional, flags } = parseFlags(rest)
  if (sub === 'human') {
    const userId = stringFlag(flags, 'user-id')
    if (!userId) throw new TelegramRuntimeError('interface human requires --user-id')
    const profile = setTelegramInterface(ensureCurrentProfile(), { user_id: userId })
    writePeerProfile(process.cwd(), profile)
    printJson(profile)
    return
  }
  if (sub === 'bot') {
    const username = positional[0] ? normalizeBotUsername(positional[0]) : undefined
    const peer = stringFlag(flags, 'peer')
    if (!username || !peer) {
      throw new TelegramRuntimeError('interface bot requires <bot-username> --peer <personality>')
    }
    assertBotUsername(username, 'bot-username')
    assertName(peer, 'peer')
    const path = findPeerProfilePath(peer)
    const cwd = dirname(dirname(path))
    const profile = readPeerProfile(cwd)
    if (!profile) throw new TelegramRuntimeError(`${path} missing`)
    // The profile carries the bot @username as the natural key; it also names the
    // credential dir bots/<username>/.env.
    const updated = setTelegramInterface(profile, { bot_username: username })
    writePeerProfile(cwd, updated)
    printJson(updated)
    return
  }
  throw new TelegramRuntimeError(usage())
}

async function botCommand(args: string[]): Promise<void> {
  const [sub, ...rest] = args
  const { positional, flags } = parseFlags(rest)
  if (sub === 'add') {
    const botKey = positional[0] ? normalizeBotUsername(positional[0]) : undefined
    const token = stringFlag(flags, 'token')
    if (!botKey || !token) throw new TelegramRuntimeError('bot add requires <bot-username> --token')
    assertBotUsername(botKey, 'bot-username')
    ensureScaffold()
    const path = botEnvPath(botKey)
    const env = readEnvFile(path)
    env.TELEGRAM_BOT_TOKEN = token
    // getMe validation: Telegram is the source of truth for the @username, and the
    // @username IS the catalog key (the dir name). So the key the operator passed MUST
    // equal the bot getMe reports — a mismatch means the token belongs to a different
    // bot than the dir is named for, which would mis-route silently; that is fatal. An
    // answered token-rejection is fatal too. An unreachable API degrades to trusting the
    // passed key as the username (offline escape hatch) with a warning.
    const probe = await probeBotIdentity(token)
    if (probe.ok) {
      if (normalizeBotUsername(probe.username) !== botKey) {
        throw new TelegramRuntimeError(
          `bot add: <bot-username> "${botKey}" does not match the token's bot @${probe.username} ` +
            `(getMe); name the credential after the real @username`,
        )
      }
      env.TELEGRAM_BOT_USERNAME = probe.username
    } else if (probe.reason === 'invalid-token') {
      throw new TelegramRuntimeError(`bot add: Telegram rejected the token (getMe: ${probe.detail})`)
    } else {
      process.stderr.write(
        `warn: getMe unreachable (${probe.detail}); trusting <bot-username> "${botKey}" unvalidated\n`,
      )
      env.TELEGRAM_BOT_USERNAME = botKey
    }
    writeEnvFile(path, env)
    process.stdout.write(`wrote ${path}\n`)
    process.stdout.write(`username @${env.TELEGRAM_BOT_USERNAME}\n`)
    return
  }
  if (sub === 'remove') {
    const botKey = positional[0] ? normalizeBotUsername(positional[0]) : undefined
    if (!botKey) throw new TelegramRuntimeError('bot remove requires <bot-username>')
    assertBotUsername(botKey, 'bot-username')
    rmSync(botDir(botKey), { recursive: true, force: true })
    process.stdout.write(`removed ${botDir(botKey)}\n`)
    return
  }
  if (sub === 'list') {
    const bots = listBotKeys().map(key => {
      const env = readEnvFile(botEnvPath(key))
      return {
        key,
        env: botEnvPath(key),
        configured: Boolean(env.TELEGRAM_BOT_TOKEN),
        username: env.TELEGRAM_BOT_USERNAME ?? null,
      }
    })
    if (flags.json) printJson(bots)
    else
      for (const bot of bots)
        process.stdout.write(
          `${bot.key} ${bot.configured ? 'configured' : 'missing-token'}${bot.username ? ` @${bot.username}` : ''}\n`,
        )
    return
  }
  throw new TelegramRuntimeError(usage())
}

async function doctorCommand(args: string[]): Promise<void> {
  const { flags } = parseFlags(args)
  const problems: string[] = []
  const profile = readPeerProfile(process.cwd())
  if (!profile) problems.push(`${peerProfilePath(process.cwd())} missing`)
  const ownerUserId = profile ? telegramInterface(profile).user_id : undefined
  if (profile && !ownerUserId) problems.push('current peer missing interfaces.telegram.user_id')
  const directory = readPeerDirectory()
  const bots = listBotKeys().map(key => {
    const botEnv = readEnvFile(botEnvPath(key))
    const tokenConfigured = Boolean(botEnv.TELEGRAM_BOT_TOKEN)
    const target = directory.byTelegramBot.get(key)
    if (!tokenConfigured) problems.push(`bot ${key} missing TELEGRAM_BOT_TOKEN`)
    if (!target) problems.push(`bot ${key} is not mapped by any peer interfaces.telegram.bot_username`)
    return {
      key,
      token_configured: tokenConfigured,
      username: botEnv.TELEGRAM_BOT_USERNAME ?? null,
      target_peer: target?.personality ?? null,
    }
  })
  if (bots.length === 0) problems.push(`${botsRoot()} has no configured bots`)
  const result = {
    ok: problems.length === 0,
    cwd: process.cwd(),
    profile_path: peerProfilePath(process.cwd()),
    owner: profile
      ? {
          personality: profile.personality,
          runtime: profile.runtime,
          user_id_configured: Boolean(ownerUserId),
        }
      : null,
    bots,
    peers_index: peersIndexPath(),
    problems,
  }
  if (flags.json) printJson(result)
  else {
    process.stdout.write(`${result.ok ? 'ok' : 'not ok'}\n`)
    for (const problem of problems) process.stdout.write(`- ${problem}\n`)
  }
  if (!result.ok) process.exitCode = 1
}

async function runCommand(): Promise<void> {
  ensureScaffold()
  const owner = readPeerProfile(process.cwd())
  if (!owner) {
    throw new TelegramRuntimeError(`missing ${peerProfilePath(process.cwd())}; run telegram-runtime prepare`)
  }
  const ownerUserId = telegramInterface(owner).user_id
  if (!ownerUserId) {
    throw new TelegramRuntimeError('current peer missing interfaces.telegram.user_id')
  }
  const credentials = new Map<string, BotCredential>()
  const bots = new Map<string, Bot>()
  const releaseLocks: ReleaseLock[] = []
  let commandsSyncTimer: ReturnType<typeof setInterval> | undefined
  let fleetFace: FleetFace | null = null
  const cleanup = () => {
    if (commandsSyncTimer) clearInterval(commandsSyncTimer)
    fleetFace?.stop()
    for (const release of releaseLocks.splice(0).reverse()) release()
  }
  process.once('exit', cleanup)
  process.once('SIGINT', () => {
    cleanup()
    process.exit(130)
  })
  process.once('SIGTERM', () => {
    cleanup()
    process.exit(143)
  })
  for (const key of listBotKeys()) {
    releaseLocks.push(acquireBotLock(key))
    const credential = loadCredential(key)
    credentials.set(key, credential)
    bots.set(key, new Bot(credential.token, { client: { fetch: runtimeFetch as typeof fetch } }))
  }
  if (bots.size === 0) throw new TelegramRuntimeError(`${botsRoot()} has no configured bots`)
  // The shared approval service-bot (Ф3 U4): the loaded credential marked role=approval.
  // Faceless peers' cards route to it (pickApprovalRoute). At most one is expected; if the
  // owner declined onboarding there is none → faceless approvals stay on the bar/CLI.
  const approvalBotKey = [...credentials.values()].find(c => c.role === APPROVAL_BOT_ROLE)?.key
  const ctx: RuntimeContext = {
    cwd: process.cwd(),
    owner,
    ownerUserId,
    iapBin: process.env.TELEGRAM_RUNTIME_IAP_BIN ?? process.env.IAP_BIN ?? 'iapeer',
    bots,
    credentials,
    approvalBotKey,
  }
  for (const [botKey, bot] of bots) {
    installBotHandlers(ctx, botKey, bot, credentials.get(botKey)!)
  }
  installStdinEnvelopeReader(ctx)
  // Owner-facing fleet faces (Ф3): ONE SSE subscription serving approval cards (docs/17)
  // and peer-mute notices (docs/19). No-op per surface when the daemon does not serve it
  // or its kill switch is set — the runtime then behaves byte-identically to before the
  // feature. Awaited: each surface is live-probed before its handlers are wired.
  fleetFace = await startFleetFace(ctx)
  process.stderr.write(
    `telegram-runtime: running ${RUNTIME}-${owner.personality}; bots=${Array.from(bots.keys()).join(', ')}\n`,
  )
  // Slash-menu (setMyCommands): register each bot's owner-scoped menu on start, then
  // re-sync on an interval so an owner edit to a peer-profile's aliases/runtimes lands
  // in the menu without a runtime restart (each sync re-registers ONLY on change).
  for (const [botKey, bot] of bots) void syncBotCommands(ctx, botKey, bot)
  const commandsSyncMs = Number(process.env.TELEGRAM_COMMANDS_SYNC_MS ?? '') || 30_000
  commandsSyncTimer = setInterval(() => {
    for (const [botKey, bot] of bots) void syncBotCommands(ctx, botKey, bot)
  }, commandsSyncMs)
  commandsSyncTimer.unref?.()
  await Promise.all(Array.from(bots.entries()).map(([key, bot]) => startPolling(key, bot)))
}

// `telegram-runtime` (bare) / `self-install`: the npx self-deploy contract. Place the
// launcher bin on PATH (a self-contained compiled snapshot, overwriting any legacy
// symlink) + write the runtime manifest at <IAPEER_ROOT>/runtimes/telegram/runtime.json
// (NO peers[] — operator-add). IDEMPOTENT. This is the npx↔foundation seam — after it,
// `iapeer create <human> --runtime telegram` resolves the launcher, bakes it into the
// always-on plist (TELEGRAM_RUNTIME_BIN), and runs the per-peer self-config hook.
async function selfInstallCommand(): Promise<void> {
  const r = selfInstall({ env: process.env, sourceEntry: import.meta.path })
  process.stdout.write('telegram-runtime self-install (idempotent)\n')
  process.stdout.write(`  bin:      ${r.binPath}  (${r.binMode})\n`)
  process.stdout.write(`  manifest: ${r.manifestPath}\n`)
  process.stdout.write(`  root:     ${r.root}\n`)
  process.stdout.write(
    `  docs:     ${r.docs.copied ? r.docs.dest : `skipped (${r.docs.reason ?? 'unknown'})`}\n`,
  )
  process.stderr.write(
    `telegram-runtime ${JSON.stringify({ ts: new Date().toISOString(), evt: 'self-install', bin: r.binPath, manifest: r.manifestPath, binMode: r.binMode, root: r.root, docs: r.docs })}\n`,
  )
}

// `telegram-runtime self-config`: the PER-PEER self-config hook the foundation invokes
// inside createPeer→initPeer (cwd = peer cwd, IAPEER_PEER_* in env). Merges this human's
// telegram presence (user_id + bot_username, from env) into the local peer profile, PRESERVING
// the foundation-provisioned identity (intelligence=natural). exit 0 = configured.
async function selfConfigCommand(): Promise<void> {
  const r = runSelfConfig({ env: process.env, cwd: process.cwd() })
  process.stdout.write(`${r.profilePath}\n`)
  process.stdout.write(
    `telegram-runtime self-config: configured peer "${r.personality}"` +
      `${r.userId ? ` user_id=${r.userId}` : ''}${r.botUsername ? ` bot_username=${r.botUsername}` : ''}` +
      `${r.botEnvPath ? ` (credential ${r.botEnvPath})` : ''}\n`,
  )
  process.stderr.write(
    `telegram-runtime ${JSON.stringify({ ts: new Date().toISOString(), evt: 'self-config', personality: r.personality, userId: r.userId, bot_username: r.botUsername, profile: r.profilePath })}\n`,
  )
}

// `telegram-runtime notices [--json]`: a FACE-side read of the daemon's notice board
// (docs/19) — the operator's answer to "did my peer go mute, and does telegram-runtime
// agree with the daemon about it?". Read-only BY CONTRACT, not by choice: the surface has
// no POST and a notice has no resolution.
//
// It renders the same honest omission the Telegram message does: a missing reset prints
// "неизвестно (рантайм не сообщил)" and NEVER a substituted 5h/7d window.
async function noticesCommand(args: string[]): Promise<void> {
  const { flags } = parseFlags(args)
  const asJson = Boolean(flags.json)
  if (!fleetAvailable()) {
    const msg = `no fleet API advertised in ${routerJsonPath()} (pre-fleet daemon or daemon down)`
    if (asJson) printJson({ fleet: false, base: null, notices: [], note: msg })
    else process.stdout.write(`${msg}\n`)
    return
  }
  const base = resolveFleetBase()
  if (!(await probeNotices(base))) {
    const msg = `fleet advertised but GET ${base}/fleet/v1/notices did not return 200 (daemon < 0.4.94?)`
    if (asJson) printJson({ fleet: true, base, reachable: false, notices: [], note: msg })
    else process.stdout.write(`${msg}\n`)
    return
  }
  const notices = await listNotices(base)
  if (asJson) {
    printJson({ fleet: true, base, reachable: true, notices })
    return
  }
  if (notices.length === 0) {
    process.stdout.write(`no live notices (${base})\n`)
    return
  }
  for (const n of notices) {
    const reset =
      n.resetsAtMs === undefined
        ? 'reset unknown (runtime did not state it)'
        : `reset ${new Date(n.resetsAtMs).toISOString()}`
    const model = n.model ?? '(model unstated)'
    const repeat = n.count > 1 ? ` ×${n.count}` : ''
    process.stdout.write(
      `${n.id}  ${n.personality} · ${n.runtime} · ${n.kind} · ${n.errorType} · ${model}${repeat}  (${reset})\n` +
        `${n.content.split('\n')[0] ?? ''}\n`,
    )
  }
}

// `telegram-runtime approvals [--json]`: a FACE-side read of the daemon's human-approval
// queue (docs/17) — the U1 proof that our fleet client agrees with `iapeer approvals`. It
// feature-detects the daemon (router.json `fleet:1` + a live 200 from the approval
// endpoint) and lists the pending requests the Telegram card path (U3) will render. Read
// only: no card, no resolve — that arrives with the approval face (U2/U3).
async function approvalsCommand(args: string[]): Promise<void> {
  const { flags } = parseFlags(args)
  const asJson = Boolean(flags.json)
  if (!fleetAvailable()) {
    const msg = `no fleet API advertised in ${routerJsonPath()} (pre-approval daemon or daemon down)`
    if (asJson) printJson({ fleet: false, base: null, approvals: [], note: msg })
    else process.stdout.write(`${msg}\n`)
    return
  }
  const base = resolveFleetBase()
  if (!(await probeApprovals(base))) {
    const msg = `fleet advertised but GET ${base}/fleet/v1/approvals did not return 200`
    if (asJson) printJson({ fleet: true, base, reachable: false, approvals: [], note: msg })
    else process.stdout.write(`${msg}\n`)
    return
  }
  const approvals = await listApprovals(base)
  if (asJson) {
    printJson({ fleet: true, base, reachable: true, approvals })
    return
  }
  if (approvals.length === 0) {
    process.stdout.write(`no pending approvals (${base})\n`)
    return
  }
  for (const a of approvals) {
    const ageS = Math.max(0, Math.round((a.expiresMs - a.createdMs) / 1000))
    process.stdout.write(
      `${a.id}  ${a.personality} · ${a.runtime} · ${a.kind} · ${a.tool}  (ttl ${ageS}s)\n` +
        `    ${a.summary}\n`,
    )
  }
}

// `telegram-runtime onboard-approval …` — provision (or decline) the shared approval
// service-bot (Ф3 U4). It is a pure telegram-runtime service-bot — a credential dir with
// role=approval, polled by the run-loop, routed to for FACELESS peers' approval cards —
// NOT a foundation peer (decoupled from the registry, validated with iapeer 2026-07-06).
// The owner may decline; declining faceless Telegram delivery is a two-touch gate
// (--decline → --decline --yes) so the double warning is seen, not click-buried.
function approvalDeclineMarkerPath(): string {
  return join(globalTelegramRoot(), 'approval-onboard.declined')
}

function approvalBotKeyProvisioned(): string | undefined {
  for (const key of listBotKeys()) {
    try {
      if (loadCredential(key).role === APPROVAL_BOT_ROLE) return key
    } catch {
      /* missing token — skip */
    }
  }
  return undefined
}

async function onboardApprovalCommand(args: string[]): Promise<void> {
  const { flags } = parseFlags(args)
  const out = (s: string): void => void process.stdout.write(`${s}\n`)

  if (flags.status) {
    const provisioned = approvalBotKeyProvisioned()
    const declined = existsSync(approvalDeclineMarkerPath())
    if (provisioned) out(`approval bot: provisioned (@${provisioned})`)
    else if (declined) out('approval bot: declined (faceless approvals go to the bar/CLI only)')
    else out('approval bot: not configured (run `telegram-runtime onboard-approval` to decide)')
    return
  }

  const token = stringFlag(flags, 'token')
  if (token) {
    // Global bots registry only — NOT ensureScaffold() (which also mkdirs cwd-local
    // .iapeer dirs). onboard-approval writes to the IAPEER_ROOT-based bots registry and
    // must be cwd-clean so `npx … onboard-approval` from any directory leaves no junk.
    mkdirSync(botsRoot(), { recursive: true, mode: 0o700 })
    const probe = await probeBotIdentity(token)
    const usernameFlag = stringFlag(flags, 'username')
    let botKey: string
    if (probe.ok) {
      botKey = normalizeBotUsername(probe.username)
      if (usernameFlag && normalizeBotUsername(usernameFlag) !== botKey) {
        throw new TelegramRuntimeError(
          `--username "${usernameFlag}" does not match the token's bot @${probe.username} (getMe)`,
        )
      }
    } else if (probe.reason === 'invalid-token') {
      throw new TelegramRuntimeError(`onboard-approval: Telegram rejected the token (getMe: ${probe.detail})`)
    } else if (usernameFlag) {
      botKey = normalizeBotUsername(usernameFlag)
      process.stderr.write(`warn: getMe unreachable (${probe.detail}); trusting --username "${botKey}"\n`)
    } else {
      throw new TelegramRuntimeError(
        `onboard-approval: getMe unreachable (${probe.detail}) and no --username given to name the credential`,
      )
    }
    assertBotUsername(botKey, 'approval bot-username')
    const path = botEnvPath(botKey)
    const env = readEnvFile(path)
    env.TELEGRAM_BOT_TOKEN = token
    env.TELEGRAM_BOT_USERNAME = probe.ok ? probe.username : botKey
    env.TELEGRAM_BOT_ROLE = APPROVAL_BOT_ROLE
    writeEnvFile(path, env)
    rmSync(approvalDeclineMarkerPath(), { force: true })
    out(`approval bot provisioned: @${env.TELEGRAM_BOT_USERNAME}`)
    out(`  credential: ${path} (role=${APPROVAL_BOT_ROLE})`)
    out('  Faceless peers’ approval cards will be delivered here.')
    out('  Restart the runtime to start polling it (boris: `iapeer update-runtime telegram` / restart).')
    return
  }

  if (flags.decline) {
    if (flags.yes) {
      mkdirSync(globalTelegramRoot(), { recursive: true, mode: 0o700 }) // global-only (cwd-clean)
      writeFileSync(approvalDeclineMarkerPath(), `${new Date().toISOString()}\n`, { mode: 0o644 })
      out('⚠️  ПРЕДУПРЕЖДЕНИЕ 2/2 — отказ зафиксирован.')
      out('Faceless gated-пиры остаются БЕЗ Telegram-канала подтверждений: их запросы')
      out('видны только в хостовом баре (`iapeer approvals`) и CLI. Faced-пиры со своим')
      out('ботом работают как обычно.')
      out('Провижн позже:  telegram-runtime onboard-approval --token <TOKEN от @BotFather>')
      return
    }
    out('⚠️  ПРЕДУПРЕЖДЕНИЕ 1/2 — что теряешь, отказавшись от approval-бота:')
    out('Без него запросы подтверждения FACELESS gated-пиров (Implementer’ы, infra) НЕ')
    out('дойдут до Telegram вообще. Они будут видны ТОЛЬКО в хостовом баре')
    out('(`iapeer approvals`) и CLI. Вдали от хоста ты их не увидишь и не ответишь с')
    out('телефона — пир блокируется до таймаута (default-deny) либо до ответа у хоста.')
    out('(Faced-пиры со своим ботом НЕ затронуты — им карточка приходит в их диалог.)')
    out('')
    out('Подтвердить отказ:  telegram-runtime onboard-approval --decline --yes')
    return
  }

  // Bare invocation → the offer.
  const provisioned = approvalBotKeyProvisioned()
  if (provisioned) {
    out(`approval bot already provisioned: @${provisioned}. Re-run with --token to replace it.`)
    return
  }
  out('Approval-бот — единый Telegram-канал для запросов подтверждения от FACELESS-пиров')
  out('(Implementer’ы, infra — у них нет своего бота). Когда такой пир в режиме gated')
  out('упирается в блокирующий аппрув, карточка Allow/Deny приходит СЮДА.')
  out('')
  out('  Провижн:  telegram-runtime onboard-approval --token <TOKEN от @BotFather>')
  out('  Отказ:    telegram-runtime onboard-approval --decline')
  out('  Статус:   telegram-runtime onboard-approval --status')
}

async function main(): Promise<void> {
  const [cmd, ...rest] = process.argv.slice(2)
  if (cmd === '--help' || cmd === '-h') {
    process.stdout.write(`${usage()}\n`)
    return
  }
  if (!cmd) {
    // Bare invocation = the npx self-install contract (the foundation's
    // defaultNpxRunner runs `npx -y <package>` with NO args).
    await selfInstallCommand()
    return
  }
  switch (cmd) {
    case 'self-install':
      await selfInstallCommand()
      return
    case 'self-config':
      await selfConfigCommand()
      return
    case 'prepare':
      await prepareCommand(rest)
      return
    case 'interface':
      await interfaceCommand(rest)
      return
    case 'bot':
      await botCommand(rest)
      return
    case 'run':
      await runCommand()
      return
    case 'doctor':
      await doctorCommand(rest)
      return
    case 'approvals':
      await approvalsCommand(rest)
      return
    case 'notices':
      await noticesCommand(rest)
      return
    case 'onboard-approval':
      await onboardApprovalCommand(rest)
      return
    default:
      throw new TelegramRuntimeError(usage())
  }
}

if (import.meta.main) {
  main().catch(err => {
    process.stderr.write(`telegram-runtime: ${formatError(err)}\n`)
    process.exit(1)
  })
}
