diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256
index 3e3a0fb5b52..91740df134a 100644
--- a/docs/.generated/config-baseline.sha256
+++ b/docs/.generated/config-baseline.sha256
@@ -1,4 +1,4 @@
-07edb0e3c7841952ccfd5111b4be02db9c7c52ff8fc4bb185dc87552f7776116 config-baseline.json
-337632b33a9e0c27c5e4f1ed8229216e9a0a54d3f277d95d0782840f4e1ae4f9 config-baseline.core.json
-911a0a6bedaeebda7c92ea1ccfaedb223f23e96bf577398601ec61e34d35ed08 config-baseline.channel.json
+bb46810530bb11b70d3a477d815d2534ba2230b9179c0a17790a36272bc6425e config-baseline.json
+a965433f02c4b3e1e3eacb57b17ca6c9e0916fa26d91e1d6413e65eec3873f72 config-baseline.core.json
+c87db673c4b6f3339213ee979c16365e3b738070879f1a8747604165fa222102 config-baseline.channel.json
5ccd3695d1c1a60efa2654e0fd9a0397ef9c45fd1e1fd30a3cbed9b343a5c8ca config-baseline.plugin.json
diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256
index 7f56ddc5623..ad9a28ed967 100644
--- a/docs/.generated/plugin-sdk-api-baseline.sha256
+++ b/docs/.generated/plugin-sdk-api-baseline.sha256
@@ -1,2 +1,2 @@
-244b02936e24b4c5ae8ca596c7a2255e2da1026b23ad445108bd5135d6ed8564 plugin-sdk-api-baseline.json
-3566fb622a7173c4e238d449d2c605a43a21cae6d966782acb046a7925d3a20e plugin-sdk-api-baseline.jsonl
+d6856bc1215580fb139b591b4b5b8aa34d7283ce136b24b358c686638a298eaa plugin-sdk-api-baseline.json
+205ff66234e0bf7f9c5f83ef7bd6222b696d544b6db7defec1162174ba28b5b2 plugin-sdk-api-baseline.jsonl
diff --git a/docs/channels/discord.md b/docs/channels/discord.md
index b88717e510e..4edb21afab2 100644
--- a/docs/channels/discord.md
+++ b/docs/channels/discord.md
@@ -790,6 +790,25 @@ See [Slash commands](/tools/slash-commands) for the command catalog and behavior
+
+ Set `channels.discord.subagentProgress: true` to show background child activity on the Discord message that started the parent run.
+
+```json5
+{
+ channels: {
+ discord: {
+ subagentProgress: true,
+ },
+ },
+}
+```
+
+ While child runs are active, OpenClaw keeps Discord typing active for up to one hour and replaces one count reaction (`1️⃣` through `🔟`) as the concurrent count changes; `🔟` also represents 10 or more. The count reaction is removed after the final child ends. A failed, timed-out, or killed child leaves a `🔴` reaction.
+
+ This is opt-in and uses fixed internal timing and emoji defaults. The bot needs **Add Reactions** permission for reaction feedback. Account-level `channels.discord.accounts..subagentProgress` overrides the top-level value.
+
+
+
For stable "always-on" ACP workspaces, configure top-level typed ACP bindings targeting Discord conversations.
diff --git a/extensions/discord/src/config-ui-hints.ts b/extensions/discord/src/config-ui-hints.ts
index 97f4385ffa4..8ab5ca4efd3 100644
--- a/extensions/discord/src/config-ui-hints.ts
+++ b/extensions/discord/src/config-ui-hints.ts
@@ -115,6 +115,10 @@ export const discordChannelConfigUiHints = {
label: "Discord Thread Spawn Context",
help: 'Default native subagent context for thread-bound spawns. "fork" starts from the requester transcript; "isolated" starts clean. Default: "fork".',
},
+ subagentProgress: {
+ label: "Discord Subagent Progress",
+ help: "Show active subagent count reactions and typing on the source message. Default: false.",
+ },
"ui.components.accentColor": {
label: "Discord Component Accent Color",
help: "Accent color for Discord component containers (hex). Set per account via channels.discord.accounts..ui.components.accentColor.",
diff --git a/extensions/discord/src/subagent-hooks.test.ts b/extensions/discord/src/subagent-hooks.test.ts
index fb7af43a00e..68c2c942d96 100644
--- a/extensions/discord/src/subagent-hooks.test.ts
+++ b/extensions/discord/src/subagent-hooks.test.ts
@@ -60,6 +60,9 @@ const hookMocks = vi.hoisted(() => {
),
listThreadBindingsBySessionKey: vi.fn((_params?: unknown): ThreadBindingRecord[] => []),
unbindThreadBindingsBySessionKey: vi.fn(() => []),
+ progressModuleFactory: vi.fn(),
+ handleDiscordSubagentProgress: vi.fn(),
+ recoverDiscordSubagentProgress: vi.fn(),
};
});
@@ -73,6 +76,13 @@ vi.mock("./monitor/thread-bindings.js", () => ({
listThreadBindingsBySessionKey: hookMocks.listThreadBindingsBySessionKey,
unbindThreadBindingsBySessionKey: hookMocks.unbindThreadBindingsBySessionKey,
}));
+vi.mock("./subagent-progress.js", () => {
+ hookMocks.progressModuleFactory();
+ return {
+ handleDiscordSubagentProgress: hookMocks.handleDiscordSubagentProgress,
+ recoverDiscordSubagentProgress: hookMocks.recoverDiscordSubagentProgress,
+ };
+});
function registerHandlersForTest(
config: Record = {
@@ -210,6 +220,38 @@ describe("discord subagent hook handlers", () => {
hookMocks.autoBindSpawnedDiscordSubagent.mockClear();
hookMocks.listThreadBindingsBySessionKey.mockClear();
hookMocks.unbindThreadBindingsBySessionKey.mockClear();
+ hookMocks.progressModuleFactory.mockClear();
+ hookMocks.handleDiscordSubagentProgress.mockClear();
+ hookMocks.recoverDiscordSubagentProgress.mockClear();
+ });
+
+ it("keeps progress runtime lazy for unrelated subagent hooks", async () => {
+ const handlers = registerHandlersForTest();
+ const handler = getRequiredHookHandler(handlers, "subagent_delivery_target");
+
+ await handler(
+ {
+ childSessionKey: "agent:main:subagent:child",
+ requesterSessionKey: "agent:main:main",
+ requesterOrigin: { channel: "signal" },
+ childRunId: "run-1",
+ spawnMode: "session",
+ expectsCompletionMessage: true,
+ },
+ {},
+ );
+
+ expect(hookMocks.progressModuleFactory).not.toHaveBeenCalled();
+ });
+
+ it("loads progress runtime for gateway recovery", async () => {
+ const handlers = registerHandlersForTest();
+ const handler = getRequiredHookHandler(handlers, "gateway_start");
+
+ await handler({}, {});
+
+ expect(hookMocks.progressModuleFactory).toHaveBeenCalledTimes(1);
+ expect(hookMocks.recoverDiscordSubagentProgress).toHaveBeenCalledTimes(1);
});
it("binds thread routing on subagent_spawning", async () => {
diff --git a/extensions/discord/src/subagent-progress-config.ts b/extensions/discord/src/subagent-progress-config.ts
new file mode 100644
index 00000000000..e8deee65c69
--- /dev/null
+++ b/extensions/discord/src/subagent-progress-config.ts
@@ -0,0 +1,68 @@
+import { DEFAULT_EMOJIS } from "openclaw/plugin-sdk/channel-feedback";
+import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
+import {
+ normalizeOptionalLowercaseString,
+ normalizeOptionalStringifiedId,
+} from "openclaw/plugin-sdk/string-coerce-runtime";
+
+export const RUNNING_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"];
+export const FAILURE_EMOJI = "🔴";
+
+export type DiscordProgressRequester = {
+ channel?: string;
+ accountId?: string;
+ to?: string;
+ threadId?: string | number;
+ channelId?: string | number;
+ messageId?: string | number;
+};
+
+function channelIdFromTarget(target?: string): string | undefined {
+ const trimmed = target?.trim();
+ if (!trimmed) {
+ return undefined;
+ }
+ if (trimmed.startsWith("channel:")) {
+ return trimmed.slice("channel:".length).trim() || undefined;
+ }
+ return /^\d+$/u.test(trimmed) ? trimmed : undefined;
+}
+
+export function resolveDiscordProgressTarget(requester?: DiscordProgressRequester) {
+ if (normalizeOptionalLowercaseString(requester?.channel) !== "discord") {
+ return undefined;
+ }
+ const channelId =
+ normalizeOptionalStringifiedId(requester?.channelId) ?? channelIdFromTarget(requester?.to);
+ const messageId = normalizeOptionalStringifiedId(requester?.messageId);
+ if (!channelId || !messageId) {
+ return undefined;
+ }
+ return { channelId, messageId };
+}
+
+export function reservedReactionEmojis(config: OpenClawConfig, ackReaction?: string): Set {
+ const reserved = new Set(Object.values(DEFAULT_EMOJIS));
+ for (const emoji of Object.values(config.messages?.statusReactions?.emojis ?? {})) {
+ if (emoji?.trim()) {
+ reserved.add(emoji.trim());
+ }
+ }
+ for (const emoji of [config.messages?.ackReaction, ackReaction]) {
+ if (emoji?.trim()) {
+ reserved.add(emoji.trim());
+ }
+ }
+ for (const agent of config.agents?.list ?? []) {
+ const emoji = agent.identity?.emoji?.trim();
+ if (emoji) {
+ reserved.add(emoji);
+ }
+ }
+ return reserved;
+}
+
+export function reactionsAreAvailable(config: OpenClawConfig, ackReaction?: string): boolean {
+ const reserved = reservedReactionEmojis(config, ackReaction);
+ return !RUNNING_EMOJIS.some((emoji) => reserved.has(emoji)) && !reserved.has(FAILURE_EMOJI);
+}
diff --git a/extensions/discord/src/subagent-progress-state.ts b/extensions/discord/src/subagent-progress-state.ts
new file mode 100644
index 00000000000..8a3c3219750
--- /dev/null
+++ b/extensions/discord/src/subagent-progress-state.ts
@@ -0,0 +1,264 @@
+import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
+import type { PluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
+
+export const PROGRESS_STORE_TTL_MS = 7 * 24 * 60 * 60_000;
+export const MAX_TRACKED_RUNS = 4_096;
+const TERMINAL_TOMBSTONE_TTL_MS = 60 * 60_000;
+
+export type SubagentProgressOutcome = "ok" | "error" | "timeout" | "killed" | "unknown";
+
+export type ProgressApi = {
+ config: OpenClawConfig;
+ logger: { debug?: (message: string) => void };
+ runtime?: {
+ state: {
+ openKeyedStore(options: {
+ namespace: string;
+ maxEntries: number;
+ overflowPolicy: "reject-new";
+ defaultTtlMs: number;
+ }): PluginStateKeyedStore;
+ };
+ };
+};
+
+type PersistedProgressRunBase = {
+ /** Plugin SQLite ownership lets an ended run clean reactions after gateway restart. */
+ key: string;
+ accountId: string;
+ channelId: string;
+ messageId: string;
+ runningEmoji?: string;
+};
+
+type PersistedActiveProgressRun = PersistedProgressRunBase & { status: "active" };
+type PersistedCleanupProgressRun = PersistedProgressRunBase & {
+ status: "cleanup";
+ outcome: SubagentProgressOutcome;
+};
+export type PersistedProgressRun = PersistedActiveProgressRun | PersistedCleanupProgressRun;
+
+export function persistedTerminalOutcome(
+ persisted?: PersistedProgressRun,
+): SubagentProgressOutcome | undefined {
+ return persisted?.status === "cleanup" ? persisted.outcome : undefined;
+}
+
+export type ProgressTracker = {
+ accountId: string;
+ channelId: string;
+ messageId: string;
+ activeRunIds: Set;
+ persistedRunIds: Set;
+ runningEmoji?: string;
+ runningEmojiConfirmed: boolean;
+ reactionsEnabled: boolean;
+ typingTimer?: ReturnType;
+ typingExpiresAt: number;
+};
+
+type ProgressRunLookupResult =
+ | { status: "found"; value: PersistedProgressRun }
+ | { status: "missing" }
+ | { status: "error" };
+
+export type PersistProgressResult = "persisted" | "terminal" | "conflict" | "error";
+
+type ProgressStateForKeyResult =
+ | {
+ ok: true;
+ activeRunIds: string[];
+ cleanupRuns: Array<{ runId: string; value: PersistedCleanupProgressRun }>;
+ ownedEmojis: string[];
+ }
+ | { ok: false };
+
+let progressStores = new WeakMap