diff --git a/docs/tools/progress-card.md b/docs/tools/progress-card.md
index c696629640e2..4cc51c3a18d5 100644
--- a/docs/tools/progress-card.md
+++ b/docs/tools/progress-card.md
@@ -89,7 +89,7 @@ A full in-place conversation reset (`/reset` without `soft`, or `sessions.reset`
## Where the card appears
-Channels with progress drafts show the latest checklist in active `partial`, `block`, and `progress` previews, subject to their preview settings and line limits. Card updates supply a completion count, or `Progress updated` for a note without steps; they do not copy the note's Markdown or HTML into tool summaries. Telegram uses native checkboxes with `channels.telegram.richMessages: true` and readable HTML checklists otherwise. See [Streaming and chunking](/concepts/streaming#progress-draft-rendering).
+Channels with progress drafts show the latest checklist in active `partial`, `block`, and `progress` previews, subject to their preview settings and line limits. Cards with steps supply a completion count. Notes without steps supply readable text with Markdown formatting and authored HTML removed, subject to the existing headline limit. A note without readable text supplies `Progress updated`. The full Markdown remains in the durable card. Telegram uses native checkboxes with `channels.telegram.richMessages: true` and readable HTML checklists otherwise. See [Streaming and chunking](/concepts/streaming#progress-draft-rendering).
The current chat keeps exactly one live card, in the collapsible surface inside the composer, at every width. Opening a side panel does not move it out of the conversation. The dashboard widget and the session hovercard are separate read-only placements: hover a session row in the sidebar or a session-reference link in chat to see the same card for that session. All card placements read the same Gateway-backed state and refresh after `progressCard.changed` notifications. A notification is a refresh hint, including a null revision; clients confirm a removal with a read or clear response for that session and agent.
diff --git a/extensions/codex/src/app-server/event-projector-reasoning.ts b/extensions/codex/src/app-server/event-projector-reasoning.ts
index 2a7e30008fa0..c12a00eb5fa1 100644
--- a/extensions/codex/src/app-server/event-projector-reasoning.ts
+++ b/extensions/codex/src/app-server/event-projector-reasoning.ts
@@ -115,6 +115,7 @@ export class CodexReasoningProjection {
this.emitPlanUpdate(
{
explanation,
+ ...(params.explanationFormat === "plain" ? { explanationFormat: "plain" as const } : {}),
steps: plan,
},
source,
@@ -153,7 +154,7 @@ export class CodexReasoningProjection {
}
private emitPlanUpdate(
- params: { explanation?: string | null; steps?: AgentPlanStep[] },
+ params: { explanation?: string | null; explanationFormat?: "plain"; steps?: AgentPlanStep[] },
source: PlanUpdateSource = "codex-app-server",
): void {
if (!params.explanation && params.steps === undefined) {
@@ -166,6 +167,7 @@ export class CodexReasoningProjection {
title: "Plan updated",
source,
...(params.explanation ? { explanation: params.explanation } : {}),
+ ...(params.explanationFormat ? { explanationFormat: params.explanationFormat } : {}),
...(params.steps ? { steps: params.steps } : {}),
},
});
diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts
index 463b316670ff..d391c1deb9a7 100644
--- a/extensions/codex/src/app-server/event-projector.ts
+++ b/extensions/codex/src/app-server/event-projector.ts
@@ -309,6 +309,7 @@ export class CodexAppServerEventProjector extends CodexTurnProjection {
const projected: JsonObject = {
plan: update.steps,
...(update.explanation ? { explanation: update.explanation } : {}),
+ ...(update.explanationFormat ? { explanationFormat: update.explanationFormat } : {}),
};
await this.reasoningProjection.handleTurnPlanUpdated(projected, "openclaw");
}
diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts
index 26abe6da4461..fcf3b7a98913 100644
--- a/extensions/codex/src/app-server/run-attempt.test.ts
+++ b/extensions/codex/src/app-server/run-attempt.test.ts
@@ -2943,6 +2943,34 @@ describe("runCodexAppServerAttempt", () => {
],
});
+ const noteResponse = await harness.handleServerRequest({
+ id: "request-plan-note",
+ method: "item/tool/call",
+ params: {
+ threadId: "thread-1",
+ turnId: "turn-1",
+ callId: "call-plan-note",
+ namespace: null,
+ tool: "progress_card",
+ arguments: {
+ markdown:
+ '\n\n**Working** [results](https://example.com "',
+ },
+ expected: {
+ steps: [],
+ explanation: "Checking results. Next step.",
+ explanationFormat: "plain",
+ },
+ },
+ {
+ name: "markup without visible text",
+ input: { markdown: '' },
+ expected: { steps: [], explanation: "Progress updated", explanationFormat: "plain" },
+ },
+ {
+ name: "checklist with a note",
+ input: {
+ markdown: "Checking the next task.",
+ plan: [{ step: "Ship", status: "completed" }],
+ },
+ expected: {
+ steps: [{ step: "Ship", status: "completed" }],
+ explanation: "1/1 complete",
+ },
},
{ name: "clear", input: {}, expected: { steps: [] } },
{ name: "invalid array", input: [], expected: undefined },
diff --git a/src/session-cards/progress-card-channel-summary.ts b/src/session-cards/progress-card-channel-summary.ts
index eb7aeac7a8a2..6085f8b710c6 100644
--- a/src/session-cards/progress-card-channel-summary.ts
+++ b/src/session-cards/progress-card-channel-summary.ts
@@ -1,4 +1,5 @@
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
+import { stripMarkdown } from "../shared/text/strip-markdown.js";
import { normalizeProgressCardInput, ProgressCardInputError } from "./progress-card-input.js";
const PLAN_PROGRESS_TOOL_NAMES = new Set(["progress_card", "update_plan"]);
@@ -7,7 +8,7 @@ export function isAgentPlanProgressToolName(name: string | undefined): boolean {
return PLAN_PROGRESS_TOOL_NAMES.has(name?.trim().toLowerCase() ?? "");
}
-/** Projects durable card state without interpreting renderer-owned Markdown or HTML. */
+/** Projects checklist counts or readable notes through the shared Markdown owner. */
export function projectProgressCardChannelUpdate(input: unknown) {
const record = asOptionalRecord(input);
if (!record) {
@@ -20,9 +21,15 @@ export function projectProgressCardChannelUpdate(input: unknown) {
const explanation = steps.length
? `${completed}/${steps.length} complete`
: normalized.markdown
- ? "Progress updated"
+ ? stripMarkdown(normalized.markdown, { linkStyle: "label", stripHtml: true })
+ .replace(/\s+/g, " ")
+ .trim() || "Progress updated"
: undefined;
- return { steps, ...(explanation ? { explanation } : {}) };
+ return {
+ steps,
+ ...(explanation ? { explanation } : {}),
+ ...(!steps.length && explanation ? { explanationFormat: "plain" as const } : {}),
+ };
} catch (error) {
if (error instanceof ProgressCardInputError) {
return undefined;
diff --git a/src/shared/text/escape-markdown.ts b/src/shared/text/escape-markdown.ts
new file mode 100644
index 000000000000..9df8e0c8179d
--- /dev/null
+++ b/src/shared/text/escape-markdown.ts
@@ -0,0 +1,4 @@
+/** Encodes prepared text as literal CommonMark, including URL and HTML punctuation. */
+export function escapeMarkdownText(text: string): string {
+ return text.replace(/[!-/:-@[-`{-~]/g, "\\$&");
+}
diff --git a/src/shared/text/strip-markdown.test.ts b/src/shared/text/strip-markdown.test.ts
new file mode 100644
index 000000000000..407d4cdf9eea
--- /dev/null
+++ b/src/shared/text/strip-markdown.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+import { stripMarkdown } from "./strip-markdown.js";
+
+describe("stripMarkdown HTML projection", () => {
+ it.each([
+ ["Checking
results