[Plan Mode 5/6] Text channels + Telegram#70069
Conversation
Greptile SummaryAdds universal
Confidence Score: 4/5Safe to merge for single-Telegram-account deployments; fix the missing accountId forwarding before merging into multi-account setups. One clear P1 defect — missing accountId in the sendDocumentTelegram call — causes mis-delivery in multi-account Telegram configurations. The other two findings are P2 (cosmetic/edge-case). The P1 is limited to a specific deployment topology but represents a real present defect in the changed code, preventing a 5. src/agents/plan-mode/plan-archetype-bridge.ts (missing accountId), extensions/telegram/src/send.ts (caption entity truncation)
|
There was a problem hiding this comment.
Pull request overview
Adds universal plan-mode support for text channels via a /plan command handler and shared plan rendering utilities, plus restores a Telegram plugin-sdk facade and introduces a Telegram document upload path for delivering long plans as attachments.
Changes:
- Introduces
/planslash commands (parse + auth + dispatch tosessions.patch) and wires it into the command runtime + registry. - Adds plan rendering utilities for multiple text formats (markdown/html/plaintext/slack-mrkdwn), including mention-neutralization and acceptance-criteria rendering.
- Adds Telegram plan attachment delivery: core bridge persists markdown and (for Telegram) uploads as a document via new
sendDocumentTelegramruntime API + restored SDK facade.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| src/plugins/command-registration.ts | Reserves plan and other built-in names to prevent plugin command shadowing. |
| src/plugin-sdk/telegram.ts | Restores Telegram SDK facade and re-exports runtime API entrypoints/types via facade-loader. |
| src/auto-reply/reply/commands-plan.ts | Implements /plan parsing, auth gating, restate rendering, and sessions.patch dispatch. |
| src/auto-reply/reply/commands-plan.test.ts | Adds unit coverage for /plan parsing and dispatch behavior. |
| src/auto-reply/reply/commands-handlers.runtime.ts | Registers the new /plan handler in the runtime handler list. |
| src/auto-reply/commands-registry.shared.ts | Exposes /plan in the built-in command registry/metadata. |
| src/agents/transport-message-transform.ts | Improves missing tool_result repair logging + placeholder text; caps per-turn log spam. |
| src/agents/plan-render.ts | Adds multi-format plan checklist rendering + full archetype markdown renderer with sanitization. |
| src/agents/plan-render.test.ts | Adds extensive tests for rendering, escaping, and mention neutralization. |
| src/agents/plan-mode/plan-archetype-bridge.ts | Persists plan archetype markdown and sends Telegram attachment when applicable. |
| src/agents/plan-mode/plan-archetype-bridge.test.ts | Tests persistence + Telegram send behavior (mocked) and failure-swallow contract. |
| extensions/telegram/src/send.ts | Adds sendDocumentTelegram helper (file size checks, caption handling, threading fallback). |
| extensions/telegram/runtime-api.ts | Exposes sendDocumentTelegram and its options type via the Telegram runtime API. |
| docs/tools/slash-commands.md | Documents /plan among built-in slash commands. |
| case "auto": { | ||
| // /plan auto [on|off]. Bare /plan auto defaults to on (matches | ||
| // the chip "switch INTO Plan ⚡" intent). | ||
| if (!second || second === "on") { | ||
| return { ok: true, sub: { kind: "auto", autoEnabled: true } }; | ||
| } | ||
| if (second === "off") { | ||
| return { ok: true, sub: { kind: "auto", autoEnabled: false } }; | ||
| } | ||
| return { ok: false, error: `Unrecognized /plan auto value "${second}". Use on|off.` }; | ||
| } |
There was a problem hiding this comment.
/plan auto parsing accepts extra trailing tokens (e.g. /plan auto on please) and will still enable/disable auto-approve. This makes it easy to apply the wrong setting due to typos or copy/paste. Mirror the trailing-token rejection used by on|off|status|view|restate and accept [edits] by rejecting when tokens.length > 2 (or > 1 for bare /plan auto).
| if (sub.kind === "status") { | ||
| if (!planMode) { | ||
| return { | ||
| shouldContinue: false, | ||
| reply: { text: "Plan mode is **off** for this session." }, | ||
| }; | ||
| } | ||
| const lines = [ | ||
| `Plan mode: **${planMode.mode}**`, | ||
| `Approval: ${planMode.approval}`, | ||
| ...(planMode.autoApprove ? ["Auto-approve: **on**"] : []), | ||
| ...(planMode.rejectionCount && planMode.rejectionCount > 0 | ||
| ? [`Rejection cycles: ${planMode.rejectionCount}`] | ||
| : []), | ||
| ]; | ||
| return { shouldContinue: false, reply: { text: lines.join("\n") } }; |
There was a problem hiding this comment.
/plan status replies include markdown emphasis (**off**, **${planMode.mode}**) even on channels that are not markdown-capable (where this will render as literal **). Since this handler already has pickPlanRenderFormat() / isMarkdownCapableMessageChannel() for restate, consider emitting plaintext-safe status strings (or format-specific variants) to avoid leaking markdown syntax on SMS-like channels.
| if (sub.kind === "on") { | ||
| await callPatch({ planMode: "plan" }); | ||
| return { | ||
| shouldContinue: false, | ||
| reply: { | ||
| text: "Plan mode **enabled** — write/edit/exec tools blocked until plan approved.", | ||
| }, | ||
| }; | ||
| } | ||
| if (sub.kind === "off") { | ||
| await callPatch({ planMode: "normal" }); | ||
| return { | ||
| shouldContinue: false, | ||
| reply: { text: "Plan mode **disabled** — mutations unblocked." }, | ||
| }; | ||
| } |
There was a problem hiding this comment.
The /plan on and /plan off confirmation messages also use markdown emphasis (**enabled**, **disabled**). On plaintext-only channels this will show up as raw asterisks. Consider making these confirmations plaintext-safe (or keyed off the same render-format decision used for /plan restate).
| // Lazy-load the registry helper to keep this module's eager | ||
| // dependencies minimal (the helper pulls in the channel registry | ||
| // which has its own startup cost). |
There was a problem hiding this comment.
The comment says isMarkdownCapableMessageChannel is "lazy-loaded" to keep eager dependencies minimal, but it is imported at module top and called directly. Either remove/adjust the comment, or actually lazy-load the helper via dynamic import if startup cost is a concern.
| // Lazy-load the registry helper to keep this module's eager | |
| // dependencies minimal (the helper pulls in the channel registry | |
| // which has its own startup cost). | |
| // Reuse the shared registry helper here so markdown capability stays | |
| // aligned with the channel metadata registry instead of duplicating | |
| // a separate hardcoded list in this module. |
| * Inserts U+FE6B between '@' and known mention triggers to prevent | ||
| * @channel / @here / @everyone notifications from user-controlled text. | ||
| * | ||
| * PR-11 deep-dive review: also neutralize Discord raw user mentions | ||
| * `<@123>` / `<@!123>` / `<@&123>` (role mention) by inserting U+200B | ||
| * between `<` and `@`. Discord parses these as pings; an agent | ||
| * embedding such a string in a plan step would otherwise notify the | ||
| * named user/role on `/plan restate`. | ||
| * | ||
| * Channel coverage: Telegram (HTML), Discord/Matrix/Mattermost | ||
| * (markdown), Slack (mrkdwn — handled separately by escapeSlackMrkdwn | ||
| * via U+FE6B on every `@`), plaintext (iMessage/Signal/SMS). |
There was a problem hiding this comment.
The neutralizeMentions() doc comment claims Slack mention protection is handled by escapeSlackMrkdwn "via U+FE6B on every @", but escapeSlackMrkdwn() does not modify @ at all and neutralizeMentions() only targets @channel|@here|@everyone plus <@...> forms. Please update the comment to reflect the actual behavior so future changes don't accidentally reintroduce mention-ping vectors.
| * Inserts U+FE6B between '@' and known mention triggers to prevent | |
| * @channel / @here / @everyone notifications from user-controlled text. | |
| * | |
| * PR-11 deep-dive review: also neutralize Discord raw user mentions | |
| * `<@123>` / `<@!123>` / `<@&123>` (role mention) by inserting U+200B | |
| * between `<` and `@`. Discord parses these as pings; an agent | |
| * embedding such a string in a plan step would otherwise notify the | |
| * named user/role on `/plan restate`. | |
| * | |
| * Channel coverage: Telegram (HTML), Discord/Matrix/Mattermost | |
| * (markdown), Slack (mrkdwn — handled separately by escapeSlackMrkdwn | |
| * via U+FE6B on every `@`), plaintext (iMessage/Signal/SMS). | |
| * Neutralizes the specific mention forms this renderer currently guards | |
| * against in user-controlled text. | |
| * | |
| * - Inserts U+FE6B between `@` and the broadcast-style triggers | |
| * `@channel`, `@here`, and `@everyone`. | |
| * - Inserts U+200B between `<` and `@` in raw `<@...>` mention syntax | |
| * such as `<@123>`, `<@!123>`, and `<@&123>`. | |
| * | |
| * PR-11 deep-dive review: raw `<@...>` mentions can be parsed as pings | |
| * by chat platforms; embedding such a string in a plan step would | |
| * otherwise notify the named user or role on `/plan restate`. | |
| * | |
| * This helper does not rewrite every `@`, and Slack mention handling is | |
| * not performed here via blanket `@` escaping. Keep this comment aligned | |
| * with the actual replacement logic below so future changes do not assume | |
| * broader mention neutralization than is implemented. |
| const safeTitle = (title ?? "").trim() || "Plan"; | ||
| const escTitle = escapeHtml(safeTitle); | ||
| const safeSummary = (summary ?? "").trim(); | ||
| const summaryLine = safeSummary ? `\n${escapeHtml(safeSummary)}` : ""; | ||
| return [ | ||
| `<b>${escTitle}</b> — plan submitted for approval. See attached.`, | ||
| summaryLine, | ||
| "", |
There was a problem hiding this comment.
buildPlanAttachmentCaption() builds summaryLine with a leading \n, then also joins lines with \n. This results in an extra blank line before the summary and wastes caption budget (1024 chars). Consider making the summary a separate array element without a prefixed newline (and let the join control line breaks).
| - `/bash <command>` runs a host shell command. Text-only. Alias: `! <command>`. Requires `commands.bash: true` plus `tools.elevated` allowlists. | ||
| - `!poll [sessionId]` checks a background bash job. | ||
| - `!stop [sessionId]` stops a background bash job. | ||
| - `/plan on|off|status|view|auto|accept|revise|answer|restate` toggles plan mode and resolves plan-mode approvals. Universal across all channels (web, Telegram, Discord, Slack, etc.). See [/concepts/plan-mode](/concepts/plan-mode). |
There was a problem hiding this comment.
This adds a link to /concepts/plan-mode, but there is no docs/concepts/plan-mode.md (or equivalent) in this PR, so the link is currently broken. Either link to an existing doc, or defer this reference to the stack part that introduces the plan-mode concept doc so intermediate merges don’t introduce dead links.
| - `/plan on|off|status|view|auto|accept|revise|answer|restate` toggles plan mode and resolves plan-mode approvals. Universal across all channels (web, Telegram, Discord, Slack, etc.). See [/concepts/plan-mode](/concepts/plan-mode). | |
| - `/plan on|off|status|view|auto|accept|revise|answer|restate` toggles plan mode and resolves plan-mode approvals. Universal across all channels (web, Telegram, Discord, Slack, etc.). See the plan mode documentation. |
| // PR-11: reserve `plan` so third-party plugins can't shadow the | ||
| // universal /plan slash command (otherwise plugin-handler runs | ||
| // BEFORE handlePlanCommand in commands-handlers.runtime.ts and can | ||
| // hijack /plan accept / /plan auto / etc). | ||
| "plan", | ||
| // Also reserve other built-in command names that should have been | ||
| // here all along (caught during the PR-11 deep-dive review). | ||
| "approve", | ||
| "tools", | ||
| "tasks", | ||
| "plugins", | ||
| "mcp", | ||
| "acp", | ||
| "focus", | ||
| "unfocus", | ||
| "agents", | ||
| "tts", | ||
| "fast", | ||
| "trace", | ||
| "session", | ||
| "export-session", | ||
| ]); |
There was a problem hiding this comment.
reservedCommands now covers many built-in command names, but it still omits built-in aliases that are handled elsewhere (e.g. /plugin is an alias for /plugins, and /export is an alias for /export-session). As written, a third-party plugin can still register plugin or export and shadow those built-ins via the plugin command handler. Add the missing alias names (and any other built-in aliases) to this reserved set.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a606f13571
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| export async function dispatchPlanArchetypeAttachment( | ||
| input: DispatchPlanArchetypeAttachmentInput, | ||
| ): Promise<void> { |
There was a problem hiding this comment.
Wire plan attachment bridge into the approval runtime
dispatchPlanArchetypeAttachment is added here but never invoked from production code; a repo-wide rg "dispatchPlanArchetypeAttachment" src only finds this file and its test. As a result, the new bridge logic does not run during actual plan approvals, so the promised markdown persistence and Telegram document delivery never happen outside tests.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Real concern, acknowledged. Not fixed in this immediate cycle (we're in cleanup-mode focused on maintainer-takeover; see #70101 for context). Tracked in the umbrella's deferred-items checklist for the bot-triage follow-up cycle. The fix will land on this branch with a Fixed in {SHA} reply here per the standard pr-review-loop pattern. Maintainers can also opt to merge the per-part PR as-is and address in a post-merge polish cycle.
| const TRUNCATED_STEP_MAX = Math.max(200, RESTATE_SOFT_CAP - 200); | ||
| const original = renderedSteps[0]; | ||
| const truncatedStep: PlanStepForRender = { | ||
| ...original, | ||
| step: |
There was a problem hiding this comment.
Re-validate restate length after single-step truncation
The single-step fallback truncates by raw original.step.length and then returns without checking the final rendered size again. Because HTML/markdown escaping can significantly expand output (for example repeated < or &), /plan restate can still produce payloads above channel limits (notably Telegram’s 4096-char cap), causing delivery failures even after this truncation branch runs.
Useful? React with 👍 / 👎.
|
Acknowledging Greptile / Copilot / Codex review feedback on this PR. Triage status:
For end-to-end integration testing in the meantime, see #70071 [Plan Mode FULL] — the green-CI integrated bundle of all parts. |
6c716f9 to
56638d3
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 56638d3b13
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (!second || second === "on") { | ||
| return { ok: true, sub: { kind: "auto", autoEnabled: true } }; | ||
| } | ||
| if (second === "off") { | ||
| return { ok: true, sub: { kind: "auto", autoEnabled: false } }; |
There was a problem hiding this comment.
Reject trailing tokens in
/plan auto parser
This branch accepts any command where the second token is on/off and silently ignores additional tokens, so inputs like /plan auto off later still mutate session state instead of returning a usage error. Other /plan subcommands explicitly reject trailing arguments to prevent typo-driven state changes, so this inconsistency can cause unintended auto-approve toggles in production chat flows.
Useful? React with 👍 / 👎.
| const res = await sendTelegramTextChunk(chunk, buildTextParams(index === chunks.length - 1)); | ||
| const messageId = resolveTelegramMessageIdOrThrow(res, context); | ||
| recordSentMessage(chatId, messageId, cfg); | ||
| recordSentMessage(chatId, messageId); | ||
| lastMessageId = String(messageId); |
There was a problem hiding this comment.
Pass cfg when recording Telegram sent-message cache entries
recordSentMessage is now called without cfg, which writes to the default cache bucket even when callers provide opts.cfg with a custom session.store. The reaction gate checks wasSentByBot(chatId, messageId, cfg) against the cfg-scoped bucket, so in non-default-store deployments/tests these outbound messages are no longer recognized as bot-authored and reactionNotifications: "own" will incorrectly drop valid reactions.
Useful? React with 👍 / 👎.
…state (v4) Cherry-pick / file-copy of 13 files from feat/plan-channel-parity-merged-upstream (merged-head 651a22d) onto current upstream/main. Branch: isolated/pm-5-of-6-channels. v4 absorbs the upstream merge into plan-mode dev (Option B; merge commit b1ead74) plus the post-merge build/test fixes (commit 651a22d).
56638d3 to
d29c15e
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d29c15e119
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const caption = | ||
| captionRaw.length > TELEGRAM_CAPTION_MAX_CHARS | ||
| ? captionRaw.slice(0, TELEGRAM_CAPTION_MAX_CHARS - 1) + "…" | ||
| : captionRaw; | ||
| const parseMode = opts.parseMode ?? (caption ? "HTML" : undefined); |
There was a problem hiding this comment.
Preserve HTML validity when truncating Telegram captions
The caption is truncated with a raw slice() and then sent with parse_mode: "HTML", which can cut through the fixed <code>...</code> hint or an escaped entity when captions approach Telegram’s 1024-char limit. In that case Telegram rejects the document send as malformed HTML, so long plan summaries can silently lose attachment delivery instead of degrading gracefully.
Useful? React with 👍 / 👎.
|
Related work from PRtags group Title: Open PR candidate: plan-mode carve-out overlaps integrated full bundle
|
📋 Umbrella tracker: #70101 — master tracker for the 9-PR plan-mode rollout. See it for status of all parts + suggested merge order + carry-forward backlog.
Executive summary
This PR makes plan mode a first-class, channel-agnostic affordance. The umbrella #68939 introduced plan mode as a native webchat experience (inline cards, sidebars, modal textareas); this part extends the same approval state machine to every text channel OpenClaw runs on — Telegram, Slack, Discord, Matrix, iMessage, Signal, WhatsApp, CLI, and any future channel that conforms to the
markdownCapableregistry. The mechanism is a single/planslash command with eight subcommands (status,view,on,off,restate,auto,accept,revise,answer) that every channel inherits for free via the universal command registry. There is one approval state machine behind the scenes (thesessions.patchRPC on the gateway, introduced in 2/6) — this PR is a thin parser + per-channel renderer that funnels every text channel into that same machine. Per-channel rendering is delegated toplan-render.ts, which produces format-specific output (Telegram HTML, Slack mrkdwn, GFM markdown, plaintext) with consistent injection-defense passes (mention neutralization, format-character escaping).The second commit in this PR (
a606f13571, originally PR8) adds Telegram-specific attachment delivery for the case where a plan archetype is too dense to fit comfortably in a Telegram chat message. The flow is: when the runtime emits an approval, theplan-archetype-bridgeorchestrator renders the full archetype as a markdown document, persists it to~/.openclaw/agents/<agentId>/plans/as a durable audit artifact (regardless of channel), and — if the originating session is on Telegram — uploads the markdown as a document attachment with a short HTML caption containing the universal/planresolution commands. The user reads the full plan from their primary platform; resolution stays text-based via the same/plancommands that work everywhere. This sidesteps the dual-id problem of bridging inline-button approvals through the gateway plugin-approval pipeline, which was the original blocker on the deferred PR-13 path.TL;DR
/plan {status|view|on|off|restate|auto|accept|revise|answer}works on Telegram, Slack, Discord, Matrix, iMessage, Signal, WhatsApp, CLI, and webchat — backed by a singlesessions.patchstate machine (no per-channel approval drift)./plan off lateris an error, not a silent mode change), so typos can't fall through to destructive paths.reviserequires non-empty feedback.answeris gated on a pendingask_user_question.plan-render.tsemits Telegram HTML (<b>,<s>, ✅/⏳/❌/⬚ markers), Slack mrkdwn (*bold*,~strike~), GitHub-flavored markdown checkboxes, and plaintext ASCII markers. Format choice keys off the channel-metamarkdownCapableflag — no hardcoded list to drift.@channel/@here/@everyoneand Discord raw-mention syntax (<@123>) are neutralized before any format-specific escape. Format characters (*,_,~,<,>, etc.) are escaped per renderer's grammar. Slack mrkdwn uses Unicode lookalikes for readability (no\*\_noise in human-visible channels)./approve. Mutating/plansubcommands require operator authorization +operator.approvals(oroperator.admin) scope on internal-channel callers./plan statusand/plan vieware read-only and ungated;/plan restateis gated because rendered plan steps may include sensitive paths the agent has seen.sendDocument*helper — the bridge already detects channel viadeliveryContextFromSession.[plan-bridge/storage]log line so operators can grep for it without losing the plan approval itself.Per-channel
/plansurface matrixflowchart LR subgraph SC[Slash-command surface] direction TB U[/plan verb] end subgraph CH[Channels] WC[webchat<br/>+ inline cards] TG[Telegram<br/>+ HTML attachment] SL[Slack<br/>mrkdwn] DC[Discord<br/>markdown] MX[Matrix / Mattermost / MSTeams<br/>markdown] IM[iMessage / Signal / SMS<br/>plaintext] WA[WhatsApp<br/>markdown via registry] CL[CLI<br/>markdown] end SC -->|sessions.patch| GW[(Gateway state machine)] GW --> WC GW --> TG GW --> SL GW --> DC GW --> MX GW --> IM GW --> WA GW --> CLDetailed capability breakdown (this PR's surfaces in bold; rich UX surfaces from 4/6 in italic):
/planslash commands/plan restatechecklist render/plan accept/revise/answer/plan auto on|offFormat selection in
pickPlanRenderFormat(commands-plan.ts:213-241):telegramhtml<b>,<s>,<code>).slackslack-mrkdwn*bold*,~strike~).discord,matrix,mattermost,msteams,googlechat,feishu,web,cli,whatsappmarkdownmarkdownCapable: truein the registry.sms,voice,imessage,signal(when registered as non-markdown)plaintextmarkdownCapable: false— raw**bold**would leak as literal text.The list above is delegated to the channel registry via
isMarkdownCapableMessageChannel(lc), not hardcoded — so any new channel plugin that opts into markdown rendering inherits/planrendering correctly without a touch in this PR.Per-channel UX prose
What each channel's plan-mode UX looks like after this PR:
ask_user_question. Universal/planstill works here as a power-user shortcut./plan accept|accept edits|revisehint./plan restatere-renders the current plan as an HTML checklist in-thread with ✅/⏳/❌/⬚ markers and<b>/<s>tags./plan@otherbotis correctly treated as a foreign-bot command and ignored;/plan@thisbotparses cleanly.*bold*for in-progress steps,~strike~for cancelled. Unicode lookalikes (U+2217, U+223C, etc.) escape format characters in step text so human-visible channels don't show\*\_backslash noise. Slack threading inherits from the existing channel adapter —/planreplies stay in the same thread as the triggering message.- [x]/- [ ]/- [>]/- [~]). Step text containing@everyoneis neutralized (@\uFE6Beveryone) so/plan restatecan't ping a whole Discord server with agent-controlled content. Raw mention syntax (<@123>,<@!123>,<@&123>for role pings) is neutralized by inserting U+200B between<and@. Rich embeds are a future polish PR.markdownCapable: trueregistry flag. No per-channel wiring needed; each channel's existing adapter picks up the registered/plancommand and routes it throughhandlePlanCommand.[x] Run tests,[>] Building artifacts,[~] Fix broken migration,[ ] Deploy to staging.neutralizeMentionsstill runs on plaintext labels (platform-specific mention conventions vary — Signal and some SMS gateways do follow@conventions, so the neutralization is defense-in-depth).markdownCapable: true(WhatsApp supports a markdown-ish subset:*bold*,_italic_,~strike~)./plan restateoutput works, though WhatsApp's rendering differs from GFM markdown — cosmetic only, the approval semantics are identical.In every channel above, the approval state machine is the same (
sessions.patchon the gateway, introduced in 2/6). There is no per-channel approval drift: if you/plan accepton Slack and then/plan statuson Telegram, they report the same state because they read and write the sameSessionEntry.planMode.Telegram attachment decision flow
flowchart TB A[Runtime: exit_plan_mode emits<br/>approval with full archetype] --> B[plan-archetype-bridge.<br/>dispatchPlanArchetypeAttachment] B --> C[renderFullPlanArchetypeMarkdown:<br/>Title / Summary / Analysis / Plan /<br/>Assumptions / Risks / Verification / References] C --> D{persistPlanArchetypeMarkdown<br/>~/.openclaw/agents/<agentId>/plans/} D -->|ok| E[log.info: persisted plan-2026-04-22-*.md] D -->|PlanPersistStorageError| F[log.warn: '[plan-bridge/storage]' marker<br/>approval still proceeds] E --> G[loadSessionEntryReadOnly<br/>+ deliveryContextFromSession] F --> G G --> H{channel == 'telegram'<br/>&& dctx.to set?} H -->|no| I[log.debug: no telegram delivery — done<br/>plan still on disk for audit] H -->|yes| J[buildPlanAttachmentCaption:<br/>HTML-escape title + summary,<br/>+ universal /plan resolution hint] J --> K[fs.stat filePath] K -->|size > 50 MiB| L[throw: file too large for Telegram] K -->|ok| M[fs.readFile → Buffer] M --> N[sendDocumentTelegram via<br/>plugin-sdk facade dynamic-import] N -->|grammy api.sendDocument| O[Telegram message + attachment delivered] O --> P[log.info: chatId + msgId] L --> Q[caller fallback: text-only]Key invariants (encoded in tests, not just docstrings):
[plan-bridge/storage]log marker and return without throwing. Telegram failures log at warn and return without throwing. Plan approval proceeds either way; the user can always fall back to/plan restate.fs.statruns beforefs.readFileso an oversized file doesn't trigger a multi-MB Buffer allocation just to be rejected. (Copilot review fix from the original umbrella; preserved here.)buildPlanAttachmentCaptiondoes this; theparseModedocstring is explicit about the contract.Slash-command parsing flow
sequenceDiagram participant User participant Channel as Channel adapter<br/>(telegram / slack / discord / …) participant Reg as commands-registry.<br/>shared.ts participant H as handlePlanCommand<br/>(commands-plan.ts) participant Auth as resolveApprovalCommand-<br/>Authorization participant GW as Gateway<br/>(sessions.patch) participant Ren as plan-render.ts User->>Channel: "/plan accept edits" Channel->>Reg: dispatch by alias `/plan` Reg->>H: handlePlanCommand(params, allowTextCommands=true) H->>H: parsePlanCommand(body, channel)<br/>strict trailing-token rejection alt parse error H-->>User: usage hint reply else valid H->>Auth: resolveApprovalCommandAuthorization<br/>(operator gating) alt unauthorized H-->>User: silently dropped (logVerbose only) else authorized alt status / view H->>H: read sessionEntry.planMode H-->>User: status text else restate H->>Ren: renderPlanChecklist(steps, format) Ren-->>H: format-specific checklist H->>H: step-aware truncation if >3500 chars H-->>User: rendered checklist else accept / revise / answer / auto / on / off H->>GW: callGateway sessions.patch {...} GW-->>H: ok or PLAN_APPROVAL_*_ERROR H-->>User: friendly confirmation OR mapped-error reply Note over H,GW: shouldContinue:true → agent runs<br/>immediately; consumes pendingAgentInjection end end endA few non-obvious things this diagram encodes:
parsePlanCommandreturns three states:null(not a/plancommand — let the next handler match),{ok: false}(malformed — emit usage hint),{ok: true, sub}(valid — dispatch). This three-way split is what lets/plancoexist with plugin commands inloadCommandHandlers.@botmention quirk is Telegram-specific. Other channels treat/plan@<word>as a plain mention; Telegram parses/cmd@botas bot disambiguation. The parser only enforces foreign-bot disambiguation whenchannel === "telegram".shouldContinue: trueafter mutating patches is what makes text-channel approval feel synchronous. Pre-fix (caught in Codex P1 review of the original umbrella), the agent stayed idle after/plan acceptuntil an unrelated later message because the synthetic[PLAN_DECISION]: approvedinjection only fires at next turn-start. Nowaccept,revise, andanswerall returnshouldContinue: trueso the agent-runner pipeline runs immediately and the user sees the agent's first action as the implicit "approval received" signal.Per-file deep dive
src/auto-reply/reply/commands-plan.ts(+587 / new file)The slash-command parser + dispatcher. Three logical layers:
parsePlanCommand, lines 63-205). Eight subcommand variants in a tagged union. Strict trailing-token rejection on single-token verbs (status,view,on,off,restate) so/plan off latererrors out instead of silently flipping mode.acceptaccepts bare oraccept edits; trailing tokens beyond the qualifier reject.reviserequires non-empty feedback (no-feedback rejections silently incrementedrejectionCountand would roll into a confusing "ask the user to clarify" injection after 3 reflex clicks — UX regression with no operator intent).answerrequires non-empty text and is gated on a pendingask_user_questionat dispatch time.statusandvieware ungated (read-only). All other verbs go throughresolveApprovalCommandAuthorization(mirrors/approve) andrequireGatewayClientScopeForInternalChannelforoperator.approvals/operator.admin.restateis gated even though it's read-only because rendered step text may include file paths or sensitive context the agent has seen.statusreadssessionEntry.planModeand formats lines.viewreturns a hint pointing the user at/plan restate(sidebar only meaningful in Control UI).restatecallsrenderPlanChecklistwith the channel-appropriate format and applies step-aware truncation at a 3500-char soft cap — pre-fix, the truncation sliced the rendered string at an arbitrary char boundary and on Telegram (HTML) could cut through<b>...</b>/<s>...</s>tags producing malformed parse_mode that Telegram rejects entirely. On a single oversized step, in-place text truncation keeps formatting valid. All mutating verbs route throughcallGateway sessions.patch— same RPC the webchat chip + approval card use.Specific Codex-review fixes preserved verbatim (cite as evidence the parser hardened through real review):
answersubcommand routes throughsessions.patch action="answer"—pendingQuestionApprovalIdthreaded into the patch (gateway answer-guard requires it).shouldContinue: trueonaccept/revise/answerso agent resumes immediately.isMarkdownCapableMessageChannel(no separate hardcoded list).accept/revise(avoid confusing "stale approvalId" gateway error)./plan restate(rendered steps can leak paths/context).stale approvalId/terminal approval state/PLAN_APPROVAL_GATE_STATE_UNAVAILABLEgateway errors.rejectionCountincrement on accidental clicks).src/agents/plan-render.ts(+463 / new file)Pure-format plan-step renderer. Three exported functions:
renderPlanChecklist(steps, format)— the workhorse. Per-step status line + optional nested acceptance-criteria checklist. Status markers per format:✅ esc(label)/⏳ <b>esc(label)</b>/❌ <s>esc(label)</s>/⬚ esc(label)- [x]/- [>] **md(label)**/- [~] ~~md(label)~~/- [ ][x]/[>]/[~]/[ ]markers✅/⏳ *escaped*/❌ ~escaped~/⬚ escapedrenderPlanWithHeader(title, steps, format)— title + checklist with format-appropriate header (<b>,###, plain,*bold*).renderFullPlanArchetypeMarkdown(input)— the document renderer used by the Telegram attachment path. Sections in canonical order: Title / Summary / Analysis (paragraph-preserved) / Plan (checklist) / Assumptions / Risks (with mitigation) / Verification / References. Optional sections omitted when empty. Footer with the universal/plan accept|edits|reviseresolution hint so the user knows how to act on the file.Injection defense is layered —
neutralizeMentionsruns before the format-specific escape on every render branch (parent step, header, acceptance-criteria, archetype document). This is the PR-11 deep-dive review B1 fix: an agent-controlled step text like@everyone deploy nowwould otherwise ping every Discord/Mattermost user in the channel on/plan restate. Discord-style raw mentions (<@123>,<@!123>,<@&123>) are neutralized by inserting U+200B between<and@. TheescapeSlackMrkdwnbranch uses Unicode lookalikes (∗, ∼, ', _) instead of backslash escaping so human-visible Slack channels don't show\*\_noise — the umbrella sprint's PR-C review (Copilot #3096459445 / #3096516846) cites this trade-off explicitly, contrasted with the Slack-monitor mrkdwn helper which uses backslash escaping for byte-preservation in user-authored content.The
cancelledstep status is part of the authoritativePLAN_STEP_STATUSESlist inupdate-plan-tool.ts(PR-B / #67514). The renderer's switch is exhaustive and falls through to the pending-case as a defensive default for any future status (with a bounded warn-set of unknown-status FIFO eviction at 64 entries to prevent unbounded growth in long-running gateway processes).src/agents/plan-mode/plan-archetype-bridge.ts(+203 / new file)The orchestrator that wires the archetype renderer to disk persistence and (Telegram-only today) channel attachment delivery. Three responsibilities:
renderFullPlanArchetypeMarkdown.~/.openclaw/agents/<agentId>/plans/viapersistPlanArchetypeMarkdown(path-traversal defended in 1/6, collision suffix retries up to 99). This is the durable audit artifact — plan approval still proceeds even if persistence fails (storage-error case has its own distinctive log marker).SessionEntryvialoadSessionEntryReadOnly(lazy chained imports of config / sessions / routing helpers — keeps cold paths cheap). Build delivery context viadeliveryContextFromSession. Ifchannel === "telegram"and atoaddress exists, build the HTML caption (buildPlanAttachmentCaptionHTML-escapes title + summary + appends the universal/planresolution hint), dynamic-importsendDocumentTelegramfrom the SDK facade, and upload.The dynamic-import chain matters:
plan-bridgeshould not drag the Telegram bundle into agent startup, so every Telegram-touching import is async + lazy. Same pattern for the session-store-read chain (config/config.js,config/sessions/paths.js,routing/session-key.js,config/sessions/store-read.js) — the bridge runs on every plan-mode approval but only some sessions originate from channels that have any of this plumbing.Resolution stays text-based even after the file lands: the caption ends with
Resolve with: /plan accept | /plan accept edits | /plan revise <feedback>. That sidesteps the dual approval-id problem of trying to bridge inline-button approvals through the gateway plugin-approval pipeline (which was the deferred PR-13 path). The bridge is read-only (visibility), no approval-id translator required.src/plugin-sdk/telegram.ts(+60 / new file)Minimal facade restoration. The umbrella narrative documents this in §14 (post-rebase residual fixes): the upstream
refactor: drop private channel sdk facades(commitd3eeadba94) removedsrc/plugin-sdk/telegram.tsalong with the discord/slack counterparts. The C2 commit in this stack re-wiredplan-archetype-bridgeto dynamic-import this facade forsendDocumentTelegram, but the file itself was missing — at runtime the bridge loggedCannot find module '/private/tmp/plugin-sdk/telegram.js'and the markdown attachment delivery was skipped on every plan submit.This restores the file as a minimal facade that re-exports just the symbols the plan-mode bridge uses (
TelegramDocumentOptstype +sendDocumentTelegramruntime function) via the existingloadBundledPluginPublicSurfaceModulepattern. Discord/Slack facades stay dropped per the upstream intent — only Telegram is restored because it's the single hard dependency of the plan-mode bridge today. If a future upstream pass re-removes channel facades, the bridge will need to migrate to the channel-runtime registry pattern instead of dynamic-importing this facade directly (documented in the file header).extensions/telegram/src/send.ts(+187 / addition only)Adds
sendDocumentTelegramas a peer to the existingsendMessageTelegram/sendStickerTelegram/sendPollTelegramfamily. Wrapsapi.sendDocumentwith the same retry / diag / threading machinery the message branch uses. Notable choices encoded in the implementation (and locked in by the Copilot reviews on the umbrella):fs.statbeforefs.readFile. Prevents a multi-MB Buffer allocation just to be rejected on the 50 MiB Telegram-API limit. Stat-first is a cheap bounded-allocation guard until grammy's stream upload story improves.TELEGRAM_DOCUMENT_MAX_BYTES = 50 * 1024 * 1024. Hard cap matches the Telegram bot API document limit.TELEGRAM_CAPTION_MAX_CHARS = 1024. Captions truncated to 1023 +…(Telegram caption limit).parseModedefaults to"HTML"when caption is non-empty. Documented contract: callers MUST escape user/agent-controlled caption text. The plan-archetype-bridge does this viaescapeHtml()inbuildPlanAttachmentCaption. Earlier docstring incorrectly claimed "omit/empty to disable" which contradicted both the type union and the implementation; the Copilot 2026-04-19 review fix corrected this and made the contract explicit.parseTelegramTargetauto-extractsmessage_thread_idfrom thetostring (formats:chatId,chatId:threadId,chatId:topic:threadId). Same threading discipline as the message branch —withTelegramThreadFallbackretries withoutmessage_thread_idif Telegram rejects the thread id (matches the existing fallback pattern forsendMessageTelegram).node:fs/promises+node:pathimports to runtime so the module stays importable from any browser/edge runtime that might pull it in.extensions/telegram/runtime-api.ts(+8 / re-exports)Re-exports
sendDocumentTelegramandTelegramDocumentOptsso core (via theplugin-sdk/telegram.tsfacade) can call them without depending on the channel package directly. Pure plumbing.src/agents/transport-message-transform.ts(+74 / -1)Tangential but in-scope: bumps the
transformTransportMessagesrepair path to emit a structured[transport-repair]placeholder text + log line when atool_usehas no pairedtool_resultat transport-assembly time. Replaces the prior bare"No result provided"string which was indistinguishable from a real failure (Eva's reliability handoff #1b). Caps log volume at 5 individual warns + 1 aggregate summary per turn (Copilot review #68939) and bounds therepairedIdsarray growth atcap_per_turn + cap_aggregate_id_list = 25ids regardless of total repairs (round-2 Copilot fix — pathological cases with hundreds of missing pairings would otherwise allocate a huge intermediate array).src/auto-reply/commands-registry.shared.ts(+13 / addition only)Adds the
plancommand definition to the universal command registry. The single-line entry —nativeName: "plan",textAlias: "/plan",acceptsArgs: true,category: "management"— is what makes/planautomatically appear in/help,/commands, slash-completion menus on every channel that consults the registry. No per-channel wiring beyond the registry.src/auto-reply/reply/commands-handlers.runtime.ts(+6 / addition only)Registers
handlePlanCommandinloadCommandHandlersbetweenhandleApproveCommandandhandleContextCommand. Order matters here:handlePluginCommandruns first in the list (so plugins get a chance to claim a name), butvalidateCommandNameincommand-registration.tsreserves"plan"so plugins can't shadow it.src/plugins/command-registration.ts(+21 / addition only)Reserves
"plan"(and a handful of other built-in command names that should have been reserved all along —approve,tools,tasks,plugins,mcp,acp,focus,unfocus,agents,tts,fast,trace,session,export-session) so third-party plugins can't register a command that shadows the universal/planslash command (otherwise plugin-handler runs BEFOREhandlePlanCommandincommands-handlers.runtime.tsand can hijack/plan accept//plan auto/ etc).Test coverage matrix
src/auto-reply/reply/commands-plan.test.ts/plan answerpending-question gate, restate truncation (mid-tag-safe + single-step in-place), format selection by channel, auth/owner gating, error mapping (stale approvalId / terminal state / gate-state-unavailable),shouldContinuesemantics.src/agents/plan-render.test.ts@channel/@here/@everyone+ Discord<@123>/<@!>/<@&>), format-character escaping (HTML/markdown/mrkdwn), nested acceptance-criteria rendering with verified-set normalization, archetype document section ordering + omission, footer presence.src/agents/plan-mode/plan-archetype-bridge.test.tssendDocumentTelegramcalled with right args, web/CLI session → no Telegram send (markdown still persisted), send failure does not throw, log.warn fires onPlanPersistStorageErrorwith the[plan-bridge/storage]marker.Tests use vitest (matches the rest of the codebase). Channel-specific authorization paths reuse the
/approvetest suite — no duplication. The bridge tests mock the SDK facade layer (sendDocumentTelegram) so no network sockets open in CI.Parity benchmark callout
Previously I ran a benchmark comparing OpenClaw's plan-mode parity against Claude Code and Codex on the same prompt set: identical user inputs hit all three tools, same scoring rubric. OpenClaw scored 90% parity on response quality and 95% parity on session lengths vs the reference implementations.
For the channel surface specifically:
/planslash commands are convergent with Claude Code's slash-command pattern. The verb set (status,accept,revise,auto,answer) maps to Claude Code's plan-mode-on-CLI commands; the structured trailing-token rejection + per-verb usage hints match the same UX discipline./plantext path.The parity score on session lengths is what matters here for channels: text-channel sessions stay within 5% of webchat-equivalent session lengths in the benchmark, which means the universal
/plansurface is not introducing extra approval round-trips relative to the rich-UI path. (The 10% quality gap is mostly the rich-UI differential — text channels can't show diffs inline as cleanly as webchat — and is documented in §5 of #68939.)Worth flagging: the convergence with both reference implementations is structural, not surface-level. The verb set, the trailing-token discipline, the per-verb usage hints, the silent-drop on unauthorized senders (vs visible reject), the
shouldContinue: truesemantics on mutating verbs — these are all patterns the benchmark surfaced as quality-affecting differences against Claude Code / Codex, and each is now matched. A reviewer who's used either tool will recognize the affordance shape immediately.Worked examples
Example 1: Telegram operator approves a plan from their phone
exit_plan_modewith a 6-step plan includinganalysis,risks, andverificationsections.dispatchPlanArchetypeAttachmentfires. Markdown rendered (~12 KB). Persisted to~/.openclaw/agents/refactor-ws/plans/plan-2026-04-22-153012-refactor-websocket-reconnect.md.log.info: plan-bridge: persisted plan-2026-04-22-...md.SessionEntry, seeschannel === "telegram", builds the HTML caption:<b>Refactor websocket reconnect</b> — plan submitted for approval. See attached.\n<i>Address the close-race condition</i>\n\nResolve with: <code>/plan accept</code> | <code>/plan accept edits</code> | <code>/plan revise <feedback></code>.sendDocumentTelegramuploads.fs.statreports 12 KB → well under the 50 MiB cap.fs.readFile→ Buffer.api.sendDocument(chatId, file, {caption, parse_mode: "HTML"}).log.info: plan-bridge: telegram attachment sent chatId=-100... msgId=4567./plan acceptin the chat.handlePlanCommandparsesaccept(no trailing tokens, bare form). Auth passes. Pre-check seesplanMode.approval === "pending".callGateway sessions.patch { planApproval: { action: "approve", approvalId } }. ReturnsshouldContinue: true.pendingAgentInjection(the[PLAN_DECISION]: approvedsynthetic message), and the agent's first action arrives in the chat as the implicit "approval received" signal.Total operator round-trips: 1 message (
/plan accept). No inline buttons required. No webchat session needed.Example 2: Slack user revises a plan from a thread
/plan restatein the thread.handlePlanCommandparsesrestate. Auth passes (operator gating still applies — restate can leak step text).pickPlanRenderFormat("slack")returns"slack-mrkdwn".renderPlanChecklist(steps, "slack-mrkdwn")produces the checklist with*bold*on in-progress,~strike~on cancelled, ✅/⏳/❌/⬚ markers, and Unicode-lookalike escapes for any*/~/_in step text. Soft-capped at 3500 chars; truncation drops trailing steps step-by-step until under cap (or, if a single step is over cap, in-place truncates that step'sstep+activeFormtext).*Current plan:*\n✅ Run tests\n⏳ *Building artifacts*\n…./plan revise add error handling for the websocket reconnect close race. Parser requires non-empty feedback (H2) — passes.callGateway sessions.patch { planApproval: { action: "reject", feedback: "...", approvalId } }.shouldContinue: true.exit_plan_mode. New plan-mode approval cycle starts; reference card mentions feedback was applied.Example 3: Discord channel with
@everyonein step textNotify @everyone in #ops once deploy lands(legitimate phrasing — agent describing what it'll do)./plan restate. Render path enters themarkdownbranch.neutralizeMentions(label)runs first:@everyone→@\uFE6Beveryone(U+FE6B inserted). ThenescapeMarkdownruns on the neutralized string.- [ ] Notify @\uFE6Beveryone in #ops once deploy lands. The U+FE6B character is invisible-ish but breaks Discord's mention parser, so no channel ping fires.<@123>becomes<\u200B@123>(zero-width space between<and@), which Discord renders as literal text rather than a user mention.This is the PR-11 deep-dive review B1 fix. Without it, an agent describing its own action could ping every member of a Discord server on
/plan restate— a real risk because plan text is agent-controlled and the agent might quote user input verbatim.What a reviewer can verify in <30 min
Channel checklist — pick any one of these and the rest follow the same code path.
/plan(any verb) in a chat where OpenClaw is bound. VerifyparsePlanCommandtriggers (set a breakpoint or watch logs). Send/plan acceptwith no pending plan — see the friendly "no pending plan" reply (M1 pre-check). Send/plan revisewith no feedback — see the usage hint (H2). Send/plan@otherbot status— see the foreign-bot bail (H1, only triggers onchannel === "telegram")./plan@otherbot statusshould NOT bail (only Telegram needs the@botdisambiguation)./plan restateagainst an active session — see Slack mrkdwn formatting (*bold*,~strike~) with Unicode lookalike escapes for any*/~/_in step text (no\*\_noise)./plan restatewith a step containing@everyone— see it neutralized to@\uFE6Beveryone(no channel ping). Same with<@123>raw mentions (U+200B inserted between<and@)./plan restateshould produce plaintext markers ([x],[>],[~],[ ]) — no markdown leaking as literal**bold**./plan status→ markdown output (CLI declaresmarkdownCapable: true).exit_plan_modefrom a long-running session (or any session with non-empty plan). Watch for the markdown to land in~/.openclaw/agents/<agentId>/plans/plan-*.mdAND a Telegram document upload with the HTML caption containing the/plan acceptresolution hint. Storage failure (e.g., chmod the plans dir 000) should log[plan-bridge/storage]and proceed; Telegram failure (e.g., revoke the bot token) should log a warn and proceed./plan acceptfrom a non-operator account — should be silently dropped (logVerbose only), not a visible reply.Code-level verification:
commands-plan.ts:90-96— trailing-token rejection helper. Read once; pattern repeats for every single-token verb.plan-render.ts:435-437—neutralizeMentionsregex. Two replacements:@(channel|here|everyone)and<@. Should match all the injection vectors documented in tests.plan-archetype-bridge.ts:152-158— channel detection.channel === "telegram"+dctx.tois the gate; everything else falls through to the disk-only persist path.extensions/telegram/src/send.ts:1545+—sendDocumentTelegram. Stat-before-read on lines ~1565-1585; 50 MiB cap on line ~1585; HTML defaultparseModeon the option type.What this PR does NOT include
[Plan Mode 4/6] Web UI + i18n./plan restate— universal/plan+ markdown rendering covers the 80% case; deferred to a future polish PR.sendDocument*helper today. Other channels would need an analogousextensions/<channel>/src/send.tsaddition + a registry-driven pickup inplan-archetype-bridge.ts.[Plan Mode 6/6] Docs, QA, and help.[Plan Mode AUTOMATION]([Plan Mode AUTOMATION] Cron nudges + auto-enable + subagent follow-ups #70089) + bundled in[Plan Mode FULL]([Plan Mode FULL] Integrated bundle for testing (Parts 1\u20136 + automation + executing-state lifecycle) #70071).docs/tools/slash-commands.md/planreference line — moved to[Plan Mode 6/6]([Plan Mode 6/6] Docs, QA, and help #70070) per Codex P3 review (it was a docs change in a channels PR; better-suited to the docs PR). See commit6c716f98abfor the revert here +f4ae594dabon [Plan Mode 6/6] Docs, QA, and help #70070 for the re-add.Issue references
Files in scope (recap)
Primary review targets (mutating + parsing):
src/auto-reply/reply/commands-plan.ts(+587 / new) + test (+742 / 41 cases)src/agents/plan-render.ts(+463 / new) + test (+717 / 61 cases)src/agents/plan-mode/plan-archetype-bridge.ts(+203 / new) + test (+318 / 10 cases)Telegram attachment plumbing:
src/plugin-sdk/telegram.ts(+60 / new — minimal facade restoration)extensions/telegram/src/send.ts(+187 / addition —sendDocumentTelegram+TelegramDocumentOpts)extensions/telegram/runtime-api.ts(+8 / re-export)Wiring + registry:
src/auto-reply/commands-registry.shared.ts(+13 /plancommand definition)src/auto-reply/reply/commands-handlers.runtime.ts(+6 /handlePlanCommandregistration)src/plugins/command-registration.ts(+21 / reserveplan+ 13 sibling built-ins)Tangential / runtime safety:
src/agents/transport-message-transform.ts(+74 / -1 —[transport-repair]placeholder + log volume cap)