ad305bead5
Nine surfaces (feishu, teams, slack, telegram, whatsapp_cloud, qqbot, matrix, discord, relay) each re-derived the approval choice set — [Allow Once]; session + always unless smart-denied; [Deny] — and four of them (discord, slack, teams, whatsapp_cloud) never adopted base._format_exec_approval, so header/reason/smart-deny wording and truncation budgets drifted per adapter. Three separate commits had to touch 5–9 adapters for one semantic fix. BasePlatformAdapter.send_exec_approval now builds an ExecApprovalPrompt (shared text via _format_exec_approval, shared `(label, choice, style)` rows via _exec_approval_actions) and hands it to the `_send_exec_approval_prompt` hook. Each adapter keeps only its widget mapping (~10–20 LOC); platform wording stays via the existing `_EA_*` class attrs, and a new `_exec_approval_cmd_budget` hook lets Slack/Discord budget the command against their hard message caps (3000-char section / 2000-char message) instead of computing it inline. `_EA_REASON_BUDGET` covers Slack's 500 / Discord's 300 reason caps. The runner used to detect button support by `hasattr(type(adapter), "send_exec_approval")`; that is now true for every adapter, so `_renders_exec_approval_buttons` asks `supports_exec_approval_buttons()` (hook overridden?) and keeps the duck-typed check for non-BasePlatformAdapter classes. Visible text changes (button semantics unchanged everywhere): - Discord: the smart-deny line now follows the reason (was inside the header before the fence); the truncation marker is "..." not "\n... [truncated]". - Slack: smart-deny line follows the reason instead of the header. - Teams: unchanged (same 2000-char preview, same smart-deny block). - WhatsApp Cloud: identical text; body still capped at 1024. - QQBot/relay: unchanged.