diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md
index 71cbba402928..66e01cd15794 100644
--- a/docs/plugins/sdk-runtime.md
+++ b/docs/plugins/sdk-runtime.md
@@ -439,6 +439,8 @@ snapshots; OpenClaw owns all persistence and lifecycle coordination.
Gateway-backed runs return the canonical accepted `sessionKey` alongside `runId`. The field is optional in the TypeScript result only so explicit custom runtimes remain compatible.
+ `waitForRun(...)` returns the canonical Gateway wait result. `status` is `"ok"`, `"error"`, `"timeout"`, or `"pending"`; pending is a normal nonterminal observation, not an exception. Optional `error`, `startedAt`, `endedAt`, `stopReason`, `livenessState`, `yielded`, `pendingError`, `timeoutPhase`, `providerStarted`, and `terminalReply` metadata is preserved so callers can distinguish observation timeouts from terminal outcomes. `timeoutMs` bounds the wait call; it does not cancel the run.
+
Model overrides (`provider`/`model`) require operator opt-in via `plugins.entries..subagent.allowModelOverride: true` in config. Untrusted plugins can still run subagents, but override requests are rejected.
diff --git a/src/agents/run-wait.ts b/src/agents/run-wait.ts
index 3687ad824f43..ff2f2022723e 100644
--- a/src/agents/run-wait.ts
+++ b/src/agents/run-wait.ts
@@ -25,17 +25,16 @@ import {
buildAgentRunTerminalOutcomeFromWaitResult,
type AgentRunTerminalOutcome,
} from "./agent-run-terminal-outcome.js";
-import {
- normalizeAgentRunTerminalReplySnapshot,
- type AgentRunTerminalReplySnapshot,
-} from "./agent-run-terminal-reply.js";
+import { normalizeAgentRunTerminalReplySnapshot } from "./agent-run-terminal-reply.js";
import {
normalizeAgentRunTimeoutPhase,
normalizeProviderStarted,
- type AgentRunTimeoutPhase,
} from "./run-timeout-attribution.js";
+import type { AgentWaitResult } from "./run-wait.types.js";
import { extractStoredAssistantText, stripToolMessages } from "./tools/chat-history-text.js";
+export type { AgentWaitResult };
+
type GatewayCaller = typeof callGateway;
function resolveRunWaitTimeoutMs(value: number | undefined): number {
@@ -58,21 +57,6 @@ export type AssistantReplySnapshot = {
fingerprint?: string;
};
-/** Normalized terminal or pending state returned by `agent.wait`. */
-export type AgentWaitResult = {
- status: "ok" | "timeout" | "error" | "pending";
- error?: string;
- startedAt?: number;
- endedAt?: number;
- stopReason?: string;
- livenessState?: string;
- yielded?: boolean;
- pendingError?: boolean;
- timeoutPhase?: AgentRunTimeoutPhase;
- providerStarted?: boolean;
- terminalReply?: AgentRunTerminalReplySnapshot;
-};
-
/** Summary returned after waiting for a dynamic set of pending runs to drain. */
type AgentRunsDrainResult = {
timedOut: boolean;
diff --git a/src/agents/run-wait.types.ts b/src/agents/run-wait.types.ts
new file mode 100644
index 000000000000..d6cde0a94aaa
--- /dev/null
+++ b/src/agents/run-wait.types.ts
@@ -0,0 +1,17 @@
+import type { AgentRunTerminalReplySnapshot } from "./agent-run-terminal-reply.js";
+import type { AgentRunTimeoutPhase } from "./run-timeout-attribution.js";
+
+/** Normalized terminal or pending state returned by `agent.wait`. */
+export type AgentWaitResult = {
+ status: "ok" | "timeout" | "error" | "pending";
+ error?: string;
+ startedAt?: number;
+ endedAt?: number;
+ stopReason?: string;
+ livenessState?: string;
+ yielded?: boolean;
+ pendingError?: boolean;
+ timeoutPhase?: AgentRunTimeoutPhase;
+ providerStarted?: boolean;
+ terminalReply?: AgentRunTerminalReplySnapshot;
+};
diff --git a/src/gateway/agent-turn/agent-dedupe.ts b/src/gateway/agent-turn/agent-dedupe.ts
index b346987c05bd..1eeb21499863 100644
--- a/src/gateway/agent-turn/agent-dedupe.ts
+++ b/src/gateway/agent-turn/agent-dedupe.ts
@@ -36,6 +36,7 @@ export function isAcceptedAgentDedupePayload(payload: unknown): payload is {
ownerDeviceId?: unknown;
reservationId?: unknown;
runId?: unknown;
+ runtime?: unknown;
sessionKey?: unknown;
status: "accepted";
} {
diff --git a/src/gateway/agent-turn/agent-turn-service.ts b/src/gateway/agent-turn/agent-turn-service.ts
index d3cc00c5ab67..afa98b589b8d 100644
--- a/src/gateway/agent-turn/agent-turn-service.ts
+++ b/src/gateway/agent-turn/agent-turn-service.ts
@@ -1,3 +1,4 @@
+import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { ErrorCodes, type AgentWaitParams } from "../../../packages/gateway-protocol/src/index.js";
import { scheduleMainSessionRecoveryPendingTarget } from "../../agents/main-session-recovery/main-session-recovery-owner-release.js";
@@ -70,6 +71,7 @@ function replayAgentTurnIfCached(params: {
typeof cached.payload.agentId === "string" && cached.payload.agentId.trim()
? cached.payload.agentId.trim()
: undefined;
+ const cachedRuntime = asOptionalRecord(cached.payload.runtime);
params.io.emitAcceptance(
[
true,
@@ -78,6 +80,7 @@ function replayAgentTurnIfCached(params: {
status: "in_flight" as const,
...(cachedSessionKey ? { sessionKey: cachedSessionKey } : {}),
...(cachedAgentId ? { agentId: cachedAgentId } : {}),
+ ...(cachedRuntime ? { runtime: cachedRuntime } : {}),
},
undefined,
],
diff --git a/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts b/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts
index a2a981415534..177e933b2c53 100644
--- a/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts
+++ b/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts
@@ -2553,7 +2553,7 @@ describe("gateway agent handler", () => {
});
});
- it("preserves selected-global agent id on cached accepted responses", async () => {
+ it("preserves accepted session and runtime metadata on cached responses", async () => {
const context = makeContext();
mocks.listAgentIds.mockReturnValue(["main", "work"]);
mocks.loadConfigReturn = {
@@ -2569,6 +2569,11 @@ describe("gateway agent handler", () => {
sessionKey: "global",
agentId: "work",
status: "accepted",
+ runtime: {
+ harness: "claude-cli",
+ provider: "anthropic",
+ model: "claude-sonnet-4-6",
+ },
},
});
const respond = vi.fn();
@@ -2588,6 +2593,11 @@ describe("gateway agent handler", () => {
sessionKey: "global",
agentId: "work",
status: "in_flight",
+ runtime: {
+ harness: "claude-cli",
+ provider: "anthropic",
+ model: "claude-sonnet-4-6",
+ },
});
expect(mocks.agentCommand).not.toHaveBeenCalled();
});
diff --git a/src/gateway/server-plugins.subagent-ended-hook.test.ts b/src/gateway/server-plugins.subagent-ended-hook.test.ts
index e02c394e6ae8..71b8bfbf2f46 100644
--- a/src/gateway/server-plugins.subagent-ended-hook.test.ts
+++ b/src/gateway/server-plugins.subagent-ended-hook.test.ts
@@ -341,29 +341,47 @@ describe("createGatewaySubagentRuntime.run subagent_ended tracking (#59164)", ()
).rejects.toThrow(/not found/);
});
- test("normalizes completed agent.wait envelopes for plugin subagents", async () => {
+ test.each([
+ {
+ name: "pending queue observation",
+ result: {
+ status: "pending",
+ timeoutPhase: "queue",
+ providerStarted: false,
+ },
+ },
+ {
+ name: "metadata-rich observation timeout",
+ result: {
+ status: "timeout",
+ error: "provider retry is still pending",
+ startedAt: 1_000,
+ endedAt: 2_000,
+ stopReason: "timeout",
+ livenessState: "blocked",
+ yielded: true,
+ pendingError: true,
+ timeoutPhase: "provider",
+ providerStarted: true,
+ terminalReply: { disposition: "empty" },
+ },
+ },
+ {
+ name: "legacy completed status",
+ result: { status: "completed" },
+ expected: { status: "ok" },
+ },
+ {
+ name: "legacy completed error",
+ result: { status: "error", error: "completed" },
+ expected: { status: "ok" },
+ },
+ ])("preserves the agent.wait $name result", async ({ result, expected = result }) => {
const serverPlugins = await loadServerPlugins();
const runtime = serverPlugins.createGatewaySubagentRuntime();
serverPlugins.setFallbackGatewayContext(createTestContext("plugin-wait", createTestCfg()));
+ internalAgentTurnFacade.wait.mockResolvedValue(result);
- internalAgentTurnFacade.wait.mockResolvedValue({ status: "completed" });
-
- await expect(runtime.waitForRun({ runId: "plugin-run-completed" })).resolves.toEqual({
- status: "ok",
- });
- });
-
- test("normalizes malformed completed wait errors for plugin subagents", async () => {
- const serverPlugins = await loadServerPlugins();
- const runtime = serverPlugins.createGatewaySubagentRuntime();
- serverPlugins.setFallbackGatewayContext(
- createTestContext("plugin-wait-error", createTestCfg()),
- );
-
- internalAgentTurnFacade.wait.mockResolvedValue({ status: "error", error: "completed" });
-
- await expect(runtime.waitForRun({ runId: "plugin-run-error-completed" })).resolves.toEqual({
- status: "ok",
- });
+ await expect(runtime.waitForRun({ runId: "plugin-run-wait" })).resolves.toEqual(expected);
});
});
diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts
index bc5e743294f1..a0986f894bdc 100644
--- a/src/gateway/server-plugins.ts
+++ b/src/gateway/server-plugins.ts
@@ -3,6 +3,7 @@
import { randomUUID } from "node:crypto";
import { performance } from "node:perf_hooks";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
+import type { AgentWaitResult } from "../agents/run-wait.types.js";
import type { AmbientEnvTriggerPolicy } from "../channels/config-presence.js";
import { allowsProcessHomeSessionScan } from "../config/paths.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
@@ -322,7 +323,9 @@ export function createGatewaySubagentRuntime(
return { runId, sessionKey, ...(runtime ? { runtime } : {}) };
},
async waitForRun(params) {
- const payload = await dispatchGatewayMethodInProcess<{ status?: string; error?: string }>(
+ const payload = await dispatchGatewayMethodInProcess<
+ Omit & { status?: string }
+ >(
"agent.wait",
{
runId: params.runId,
@@ -330,20 +333,20 @@ export function createGatewaySubagentRuntime(
},
{ resolveGatewayContext },
);
- let status = payload?.status;
+ const { status: rawStatus, error, ...metadata } = payload;
+ let status = rawStatus;
if (status === "completed" || status === "succeeded") {
status = "ok";
- } else if (status === "error" && payload?.error?.trim().toLowerCase() === "completed") {
+ } else if (status === "error" && error?.trim().toLowerCase() === "completed") {
status = "ok";
}
- if (status !== "ok" && status !== "error" && status !== "timeout") {
- throw new Error(`Gateway agent.wait returned unexpected status: ${payload?.status}`);
+ if (status !== "ok" && status !== "error" && status !== "timeout" && status !== "pending") {
+ throw new Error(`Gateway agent.wait returned unexpected status: ${rawStatus}`);
}
return {
+ ...metadata,
status,
- ...(status !== "ok" &&
- typeof payload?.error === "string" &&
- payload.error && { error: payload.error }),
+ ...(status !== "ok" && error ? { error } : {}),
};
},
getSessionMessages,
diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts
index 3a049c514984..228d499dd2bf 100644
--- a/src/plugins/runtime/types.ts
+++ b/src/plugins/runtime/types.ts
@@ -2,6 +2,7 @@
// Owner schema module import keeps the ProtocolSchemas registry out of the
// public plugin-sdk dts graph (check-plugin-sdk-exports guards this).
import type { NodePluginToolDescriptor } from "../../../packages/gateway-protocol/src/schema/nodes.js";
+import type { AgentWaitResult } from "../../agents/run-wait.types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { OperatorScope } from "../../gateway/operator-scopes.js";
import type { PluginRuntimeCore, RuntimeLogger } from "./types-core.js";
@@ -51,11 +52,6 @@ type SubagentWaitParams = {
timeoutMs?: number;
};
-type SubagentWaitResult = {
- status: "ok" | "error" | "timeout";
- error?: string;
-};
-
type SubagentGetSessionMessagesParams = {
sessionKey: string;
limit?: number;
@@ -127,7 +123,7 @@ export type PluginRuntime = PluginRuntimeCore & {
};
subagent: {
run: (params: SubagentRunParams) => Promise;
- waitForRun: (params: SubagentWaitParams) => Promise;
+ waitForRun: (params: SubagentWaitParams) => Promise;
getSessionMessages: (
params: SubagentGetSessionMessagesParams,
) => Promise;