diff --git a/docs/plugins/hooks.md b/docs/plugins/hooks.md
index 6c5523fff7b2..54e14a0cf4ad 100644
--- a/docs/plugins/hooks.md
+++ b/docs/plugins/hooks.md
@@ -141,7 +141,9 @@ observation-only.
**Subagents**
-- `subagent_spawning` / `subagent_delivery_target` / `subagent_spawned` / `subagent_ended` - coordinate subagent routing and completion delivery
+- `subagent_spawned` / `subagent_ended` - observe subagent launch and completion.
+- `subagent_delivery_target` - compatibility hook for completion delivery when no core session binding can project a route.
+- `subagent_spawning` - deprecated compatibility hook. Core now prepares `thread: true` subagent bindings through channel session-binding adapters before `subagent_spawned` fires.
- `subagent_spawned` includes `resolvedModel` and `resolvedProvider` when OpenClaw has resolved the child session's native model before launch.
**Lifecycle**
@@ -464,6 +466,10 @@ before the next major release:
- **`before_agent_start`** remains for compatibility. New plugins should use
`before_model_resolve` and `before_prompt_build` instead of the combined
phase.
+- **`subagent_spawning`** remains for compatibility with older plugins, but
+ new plugins should not return thread routing from it. Core prepares
+ `thread: true` subagent bindings through channel session-binding adapters
+ before `subagent_spawned` fires.
- **`deactivate`** remains as a deprecated cleanup compatibility alias until
after 2026-08-16. New plugins should use `gateway_stop`.
- **`onResolution` in `before_tool_call`** now uses the typed
diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md
index 364c551241d2..00d447b7a684 100644
--- a/docs/plugins/sdk-migration.md
+++ b/docs/plugins/sdk-migration.md
@@ -792,6 +792,35 @@ canonical replacement.
+
+ **Old**: `api.on("subagent_spawning", handler)` returning
+ `threadBindingReady` or `deliveryOrigin`.
+
+ **New**: let core prepare `thread: true` subagent bindings through the
+ channel session-binding adapter. Use `api.on("subagent_spawned", handler)`
+ only for post-launch observation.
+
+ ```typescript
+ // Before
+ api.on("subagent_spawning", async () => ({
+ status: "ok",
+ threadBindingReady: true,
+ deliveryOrigin: { channel: "discord", to: "channel:123", threadId: "456" },
+ }));
+
+ // After
+ api.on("subagent_spawned", async (event) => {
+ await observeSubagentLaunch(event);
+ });
+ ```
+
+ `subagent_spawning`, `PluginHookSubagentSpawningEvent`,
+ `PluginHookSubagentSpawningResult`, and
+ `SubagentLifecycleHookRunner.runSubagentSpawning(...)` remain only as
+ deprecated compatibility surfaces while external plugins migrate.
+
+
+
Four discovery type aliases are now thin wrappers over the
catalog-era types:
diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md
index 00760141945c..806e2d5ab494 100644
--- a/docs/tools/subagents.md
+++ b/docs/tools/subagents.md
@@ -291,14 +291,12 @@ same sub-agent session.
### Thread supporting channels
-**Discord** is currently the only supported channel. It supports
-persistent thread-bound subagent sessions (`sessions_spawn` with
-`thread: true`), manual thread controls (`/focus`, `/unfocus`, `/agents`,
-`/session idle`, `/session max-age`), and adapter keys
-`channels.discord.threadBindings.enabled`,
-`channels.discord.threadBindings.idleHours`,
-`channels.discord.threadBindings.maxAgeHours`, and
-`channels.discord.threadBindings.spawnSessions`.
+Any channel with a session-binding adapter can support persistent
+thread-bound subagent sessions (`sessions_spawn` with `thread: true`).
+Bundled adapters currently include Discord threads, Matrix threads,
+Telegram forum topics, and current-conversation bindings for Feishu.
+Use the per-channel `threadBindings` config keys for enablement,
+timeouts, and `spawnSessions`.
### Quick flow
diff --git a/extensions/discord/src/subagent-hooks.test.ts b/extensions/discord/src/subagent-hooks.test.ts
index bac2928a3f38..04ed9a82b6c6 100644
--- a/extensions/discord/src/subagent-hooks.test.ts
+++ b/extensions/discord/src/subagent-hooks.test.ts
@@ -4,6 +4,7 @@ import {
} from "openclaw/plugin-sdk/channel-test-helpers";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { handleDiscordSubagentSpawning } from "./subagent-hooks.js";
type ThreadBindingRecord = {
accountId: string;
@@ -85,7 +86,10 @@ function registerHandlersForTest(
) {
return registerHookHandlersForTest({
config,
- register: registerDiscordSubagentHooks,
+ register: (api) => {
+ registerDiscordSubagentHooks(api);
+ api.on("subagent_spawning", (event) => handleDiscordSubagentSpawning(api, event));
+ },
});
}
diff --git a/extensions/discord/subagent-hooks-api.ts b/extensions/discord/subagent-hooks-api.ts
index 0a9b4e3d0d1a..d6a53671170c 100644
--- a/extensions/discord/subagent-hooks-api.ts
+++ b/extensions/discord/subagent-hooks-api.ts
@@ -12,10 +12,6 @@ function loadDiscordSubagentHooksModule() {
// Subagent hooks live behind a dedicated barrel so the bundled entry can
// register one stable hook wiring path while keeping the handler module lazy.
export function registerDiscordSubagentHooks(api: OpenClawPluginApi): void {
- api.on("subagent_spawning", async (event) => {
- const { handleDiscordSubagentSpawning } = await loadDiscordSubagentHooksModule();
- return await handleDiscordSubagentSpawning(api, event);
- });
api.on("subagent_ended", async (event) => {
const { handleDiscordSubagentEnded } = await loadDiscordSubagentHooksModule();
handleDiscordSubagentEnded(event);
diff --git a/extensions/feishu/src/subagent-hooks.test.ts b/extensions/feishu/src/subagent-hooks.test.ts
index 44910742ad2c..2b0fb36d12ef 100644
--- a/extensions/feishu/src/subagent-hooks.test.ts
+++ b/extensions/feishu/src/subagent-hooks.test.ts
@@ -5,6 +5,7 @@ import {
import { beforeEach, describe, expect, it } from "vitest";
import type { ClawdbotConfig, OpenClawPluginApi } from "../runtime-api.js";
import { registerFeishuSubagentHooks } from "../subagent-hooks-api.js";
+import { handleFeishuSubagentSpawning } from "./subagent-hooks.js";
import {
createFeishuThreadBindingManager,
testing as threadBindingTesting,
@@ -18,7 +19,10 @@ const baseConfig: ClawdbotConfig = {
function registerHandlersForTest(config: Record = baseConfig) {
return registerHookHandlersForTest({
config,
- register: registerFeishuSubagentHooks,
+ register: (api) => {
+ registerFeishuSubagentHooks(api);
+ api.on("subagent_spawning", (event, ctx) => handleFeishuSubagentSpawning(event, ctx));
+ },
});
}
diff --git a/extensions/feishu/subagent-hooks-api.ts b/extensions/feishu/subagent-hooks-api.ts
index 1292188d9c3f..bcb05bca6a25 100644
--- a/extensions/feishu/subagent-hooks-api.ts
+++ b/extensions/feishu/subagent-hooks-api.ts
@@ -10,10 +10,6 @@ function loadFeishuSubagentHooksModule() {
}
export function registerFeishuSubagentHooks(api: OpenClawPluginApi): void {
- api.on("subagent_spawning", async (event, ctx) => {
- const { handleFeishuSubagentSpawning } = await loadFeishuSubagentHooksModule();
- return await handleFeishuSubagentSpawning(event, ctx);
- });
api.on("subagent_delivery_target", async (event) => {
const { handleFeishuSubagentDeliveryTarget } = await loadFeishuSubagentHooksModule();
return handleFeishuSubagentDeliveryTarget(event);
diff --git a/extensions/matrix/index.test.ts b/extensions/matrix/index.test.ts
index bb6551d754d7..beaaeaf6ba73 100644
--- a/extensions/matrix/index.test.ts
+++ b/extensions/matrix/index.test.ts
@@ -135,17 +135,14 @@ describe("matrix plugin", () => {
expect(runtimeMocks.ensureMatrixCryptoRuntime).not.toHaveBeenCalled();
expect(on.mock.calls.map(([hookName]) => hookName)).toEqual([
- "subagent_spawning",
"subagent_ended",
"subagent_delivery_target",
]);
const handlers = Object.fromEntries(on.mock.calls);
- await expect(handlers.subagent_spawning({ id: "spawn" })).resolves.toBe("spawned");
await expect(handlers.subagent_ended({ id: "ended" })).resolves.toBeUndefined();
await expect(handlers.subagent_delivery_target({ id: "target" })).resolves.toBe(
"delivery-target",
);
- expect(runtimeMocks.handleMatrixSubagentSpawning).toHaveBeenCalledWith(api, { id: "spawn" });
expect(runtimeMocks.handleMatrixSubagentEnded).toHaveBeenCalledWith({ id: "ended" });
expect(runtimeMocks.handleMatrixSubagentDeliveryTarget).toHaveBeenCalledWith({ id: "target" });
});
diff --git a/extensions/matrix/src/matrix/subagent-hooks.test.ts b/extensions/matrix/src/matrix/subagent-hooks.test.ts
index 273bb8734816..357834dba75c 100644
--- a/extensions/matrix/src/matrix/subagent-hooks.test.ts
+++ b/extensions/matrix/src/matrix/subagent-hooks.test.ts
@@ -55,7 +55,10 @@ const fakeApi = { config: {} } as never;
function registerHandlersForTest(config: Record = {}) {
return registerHookHandlersForTest({
config,
- register: registerMatrixSubagentHooks,
+ register: (api) => {
+ registerMatrixSubagentHooks(api);
+ api.on("subagent_spawning", (event) => handleMatrixSubagentSpawning(api, event));
+ },
});
}
diff --git a/extensions/matrix/subagent-hooks-api.ts b/extensions/matrix/subagent-hooks-api.ts
index 39ba6e7a8e20..4254dcef7bc1 100644
--- a/extensions/matrix/subagent-hooks-api.ts
+++ b/extensions/matrix/subagent-hooks-api.ts
@@ -10,10 +10,6 @@ function loadMatrixSubagentHooksModule() {
}
export function registerMatrixSubagentHooks(api: OpenClawPluginApi): void {
- api.on("subagent_spawning", async (event) => {
- const { handleMatrixSubagentSpawning } = await loadMatrixSubagentHooksModule();
- return await handleMatrixSubagentSpawning(api, event);
- });
api.on("subagent_ended", async (event) => {
const { handleMatrixSubagentEnded } = await loadMatrixSubagentHooksModule();
await handleMatrixSubagentEnded(event);
diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts
index 9953befb895e..ea66b66ceb6e 100644
--- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts
+++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts
@@ -29,18 +29,7 @@ const fastModeEnv = vi.hoisted(() => {
});
const hookRunnerMocks = vi.hoisted(() => ({
- runSubagentSpawning: vi.fn(async (event: unknown) => {
- const input = event as {
- threadRequested?: boolean;
- };
- if (!input.threadRequested) {
- return undefined;
- }
- return {
- status: "ok" as const,
- threadBindingReady: true,
- };
- }),
+ runSubagentSpawning: vi.fn(async () => undefined),
runSubagentSpawned: vi.fn(async () => {}),
runSubagentEnded: vi.fn(async () => {}),
}));
@@ -198,9 +187,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
hookRunnerMocks.runSubagentEnded.mockClear();
setSessionsSpawnHookRunnerOverride({
hasHooks: (hookName: string) =>
- hookName === "subagent_spawning" ||
- hookName === "subagent_spawned" ||
- hookName === "subagent_ended",
+ hookName === "subagent_spawned" || hookName === "subagent_ended",
runSubagentSpawning: hookRunnerMocks.runSubagentSpawning,
runSubagentSpawned: hookRunnerMocks.runSubagentSpawned,
runSubagentEnded: hookRunnerMocks.runSubagentEnded,
diff --git a/src/agents/sessions-spawn-hooks.test.ts b/src/agents/sessions-spawn-hooks.test.ts
index 712c6efabf90..6cdf0e5ef424 100644
--- a/src/agents/sessions-spawn-hooks.test.ts
+++ b/src/agents/sessions-spawn-hooks.test.ts
@@ -5,6 +5,18 @@ import {
} from "./subagent-spawn.test-helpers.js";
type GatewayRequest = { method?: string; params?: Record };
+type TestBindingRequest = {
+ targetSessionKey: string;
+ targetKind?: string;
+ conversation: {
+ channel: string;
+ accountId?: string;
+ conversationId: string;
+ parentConversationId?: string;
+ };
+ placement: "current" | "child";
+ metadata?: Record;
+};
const hoisted = vi.hoisted(() => ({
callGatewayMock: vi.fn(),
@@ -14,31 +26,33 @@ const hoisted = vi.hoisted(() => ({
const hookRunnerMocks = vi.hoisted(() => ({
hasSubagentEndedHook: true,
- runSubagentSpawning: vi.fn(async (event: unknown) => {
- const input = event as {
- threadRequested?: boolean;
- requester?: { channel?: string };
- };
- if (!input.threadRequested) {
- return undefined;
- }
- const channel = input.requester?.channel?.trim().toLowerCase();
- if (channel !== "discord") {
- const channelLabel = input.requester?.channel?.trim() || "unknown";
- return {
- status: "error" as const,
- error: `thread=true is not supported for channel "${channelLabel}". Only Discord thread-bound subagent sessions are supported right now.`,
- };
- }
- return {
- status: "ok" as const,
- threadBindingReady: true,
- };
- }),
runSubagentSpawned: vi.fn(async () => {}),
runSubagentEnded: vi.fn(async () => {}),
}));
+const bindingMocks = vi.hoisted(() => ({
+ getCapabilities: vi.fn(() => ({
+ adapterAvailable: true,
+ bindSupported: true,
+ placements: ["child"] as Array<"current" | "child">,
+ })),
+ bind: vi.fn(async (request: TestBindingRequest) => {
+ const conversation = request.conversation;
+ return {
+ targetSessionKey: request.targetSessionKey,
+ targetKind: request.targetKind,
+ status: "active",
+ conversation: {
+ channel: conversation.channel,
+ accountId: conversation.accountId ?? "default",
+ conversationId: "456",
+ parentConversationId: conversation.conversationId,
+ },
+ };
+ }),
+ listBySession: vi.fn(() => []),
+}));
+
let resetSubagentRegistryForTests: typeof import("./subagent-registry.js").resetSubagentRegistryForTests;
let spawnSubagentDirect: typeof import("./subagent-spawn.js").spawnSubagentDirect;
@@ -186,13 +200,12 @@ beforeAll(async () => {
updateSessionStoreMock: hoisted.updateSessionStoreMock,
hookRunner: {
hasHooks: (hookName: string) =>
- hookName === "subagent_spawning" ||
hookName === "subagent_spawned" ||
(hookName === "subagent_ended" && hookRunnerMocks.hasSubagentEndedHook),
- runSubagentSpawning: hookRunnerMocks.runSubagentSpawning,
runSubagentSpawned: hookRunnerMocks.runSubagentSpawned,
runSubagentEnded: hookRunnerMocks.runSubagentEnded,
},
+ getSessionBindingService: () => bindingMocks,
resetModules: false,
sessionStorePath: "/tmp/subagent-spawn-hooks-session-store.json",
}));
@@ -204,9 +217,30 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
hoisted.callGatewayMock.mockReset();
hoisted.updateSessionStoreMock.mockReset();
hookRunnerMocks.hasSubagentEndedHook = true;
- hookRunnerMocks.runSubagentSpawning.mockClear();
hookRunnerMocks.runSubagentSpawned.mockClear();
hookRunnerMocks.runSubagentEnded.mockClear();
+ bindingMocks.getCapabilities.mockClear();
+ bindingMocks.getCapabilities.mockReturnValue({
+ adapterAvailable: true,
+ bindSupported: true,
+ placements: ["child"],
+ });
+ bindingMocks.bind.mockClear();
+ bindingMocks.bind.mockImplementation(async (request: TestBindingRequest) => {
+ const conversation = request.conversation;
+ return {
+ targetSessionKey: request.targetSessionKey,
+ targetKind: request.targetKind,
+ status: "active",
+ conversation: {
+ channel: conversation.channel,
+ accountId: conversation.accountId ?? "default",
+ conversationId: "456",
+ parentConversationId: conversation.conversationId,
+ },
+ };
+ });
+ bindingMocks.listBySession.mockClear();
setConfig({
session: {
mainKey: "main",
@@ -245,7 +279,7 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
resetSubagentRegistryForTests();
});
- it("runs subagent_spawning and emits subagent_spawned with requester metadata", async () => {
+ it("binds the subagent thread in core and emits subagent_spawned with requester metadata", async () => {
const result = await spawn({
label: "research",
model: "openai-codex/gpt-5.4",
@@ -267,38 +301,34 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
},
"spawn result",
);
- expect(hookRunnerMocks.runSubagentSpawning).toHaveBeenCalledTimes(1);
- const [spawningEvent, spawningContext] = (hookRunnerMocks.runSubagentSpawning.mock.calls.at(
- 0,
- ) ?? []) as unknown as [Record, Record];
- const spawningChildSessionKey = expectSubagentSessionKey(
- spawningEvent?.childSessionKey,
- "spawning event child session key",
+ expect(bindingMocks.getCapabilities).toHaveBeenCalledWith({
+ channel: "discord",
+ accountId: "work",
+ });
+ expect(bindingMocks.bind).toHaveBeenCalledTimes(1);
+ const bindingRequest = requireRecord(bindingMocks.bind.mock.calls[0]?.[0], "binding request");
+ const bindingChildSessionKey = expectSubagentSessionKey(
+ bindingRequest.targetSessionKey,
+ "binding target session key",
);
expectFields(
- spawningEvent,
+ bindingRequest,
{
- childSessionKey: spawningChildSessionKey,
- agentId: "main",
- label: "research",
- mode: "session",
- requester: {
- channel: "discord",
- accountId: "work",
- to: "channel:123",
- threadId: 456,
- },
- threadRequested: true,
+ targetSessionKey: bindingChildSessionKey,
+ targetKind: "subagent",
+ placement: "child",
},
- "spawning event",
+ "binding request",
);
expectFields(
- spawningContext,
+ bindingRequest.conversation,
{
- childSessionKey: spawningChildSessionKey,
- requesterSessionKey: "main",
+ channel: "discord",
+ accountId: "work",
+ conversationId: "456",
+ parentConversationId: "123",
},
- "spawning context",
+ "binding conversation",
);
expect(hookRunnerMocks.runSubagentSpawned).toHaveBeenCalledTimes(1);
@@ -345,7 +375,6 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
});
expectFields(result, { status: "accepted", runId: "run-1" }, "spawn result");
- expect(hookRunnerMocks.runSubagentSpawning).not.toHaveBeenCalled();
expect(hookRunnerMocks.runSubagentSpawned).toHaveBeenCalledTimes(1);
const event = getSpawnedEventCall();
expectFields(
@@ -376,7 +405,7 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
});
expectFields(result, { status: "accepted", runId: "run-1", mode: "run" }, "spawn result");
- expect(hookRunnerMocks.runSubagentSpawning).toHaveBeenCalledTimes(1);
+ expect(bindingMocks.bind).toHaveBeenCalledTimes(1);
const event = getSpawnedEventCall();
expectFields(
event,
@@ -389,10 +418,9 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
});
it("returns error when thread binding cannot be created", async () => {
- hookRunnerMocks.runSubagentSpawning.mockResolvedValueOnce({
- status: "error",
- error: "Unable to create or bind a Discord thread for this subagent session.",
- });
+ bindingMocks.bind.mockRejectedValueOnce(
+ new Error("Unable to create or bind a Discord thread for this subagent session."),
+ );
const result = await spawn({
toolCallId: "call4",
runTimeoutSeconds: 1,
@@ -406,10 +434,17 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
expectThreadBindFailureCleanup(result, /thread/i);
});
- it("returns error when thread binding is not marked ready", async () => {
- hookRunnerMocks.runSubagentSpawning.mockResolvedValueOnce({
- status: "ok",
- threadBindingReady: false,
+ it("returns error when thread binding does not produce a conversation", async () => {
+ bindingMocks.bind.mockResolvedValueOnce({
+ targetSessionKey: "agent:main:subagent:test",
+ targetKind: "subagent",
+ status: "active",
+ conversation: {
+ channel: "discord",
+ accountId: "work",
+ conversationId: "",
+ parentConversationId: "123",
+ },
});
const result = await spawn({
toolCallId: "call4b",
@@ -431,12 +466,16 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
});
expectErrorResultMessage(result, /requires thread=true/i);
- expect(hookRunnerMocks.runSubagentSpawning).not.toHaveBeenCalled();
expect(hookRunnerMocks.runSubagentSpawned).not.toHaveBeenCalled();
expect(hoisted.callGatewayMock).not.toHaveBeenCalled();
});
it("rejects thread=true on channels without thread support", async () => {
+ bindingMocks.getCapabilities.mockReturnValueOnce({
+ adapterAvailable: false,
+ bindSupported: false,
+ placements: [],
+ });
const result = await spawn({
thread: true,
mode: "session",
@@ -445,8 +484,7 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
context: "isolated",
});
- expectErrorResultMessage(result, /only discord/i);
- expect(hookRunnerMocks.runSubagentSpawning).toHaveBeenCalledTimes(1);
+ expectErrorResultMessage(result, /only available on channels that expose thread bindings/i);
expect(hookRunnerMocks.runSubagentSpawned).not.toHaveBeenCalled();
expectSessionsDeleteWithoutAgentStart();
});
diff --git a/src/agents/subagent-spawn.mode-session-diagnostics.test.ts b/src/agents/subagent-spawn.mode-session-diagnostics.test.ts
index 0474b42471b3..d73b87d7394d 100644
--- a/src/agents/subagent-spawn.mode-session-diagnostics.test.ts
+++ b/src/agents/subagent-spawn.mode-session-diagnostics.test.ts
@@ -1,13 +1,10 @@
import os from "node:os";
import { beforeEach, describe, expect, it, vi } from "vitest";
-import type { SubagentLifecycleHookRunner } from "../plugins/hooks.js";
import {
createSubagentSpawnTestConfig,
loadSubagentSpawnModuleForTest,
} from "./subagent-spawn.test-helpers.js";
-type SubagentSpawningEvent = Parameters[0];
-
describe('spawnSubagentDirect mode="session" diagnostics (#67400)', () => {
const callGatewayMock = vi.fn();
let spawnSubagentDirect: typeof import("./subagent-spawn.js").spawnSubagentDirect;
@@ -66,7 +63,7 @@ describe('spawnSubagentDirect mode="session" diagnostics (#67400)', () => {
});
});
-describe('spawnSubagentDirect mode="session" with registered thread hooks (#67400)', () => {
+describe('spawnSubagentDirect mode="session" with thread binding-capable channels (#67400)', () => {
const callGatewayMock = vi.fn();
let spawnSubagentDirect: typeof import("./subagent-spawn.js").spawnSubagentDirect;
let resetSubagentRegistryForTests: typeof import("./subagent-registry.js").resetSubagentRegistryForTests;
@@ -77,19 +74,6 @@ describe('spawnSubagentDirect mode="session" with registered thread hooks (#6740
callGatewayMock,
getRuntimeConfig: () => createSubagentSpawnTestConfig(os.tmpdir()),
workspaceDir: os.tmpdir(),
- hookRunner: {
- hasHooks: () => true,
- runSubagentSpawning: async (event: SubagentSpawningEvent) => {
- const requesterChannel = event.requester?.channel;
- if (requesterChannel !== "discord") {
- return undefined;
- }
- return {
- status: "ok" as const,
- threadBindingReady: true,
- };
- },
- },
}));
resetSubagentRegistryForTests();
});
diff --git a/src/agents/subagent-spawn.runtime.ts b/src/agents/subagent-spawn.runtime.ts
index a6def7dd68ea..61269fd6f250 100644
--- a/src/agents/subagent-spawn.runtime.ts
+++ b/src/agents/subagent-spawn.runtime.ts
@@ -13,6 +13,7 @@ export { ensureContextEnginesInitialized } from "../context-engine/init.js";
export { resolveContextEngine } from "../context-engine/registry.js";
export { callGateway } from "../gateway/call.js";
export { ADMIN_SCOPE, isAdminOnlyMethod } from "../gateway/method-scopes.js";
+export { getSessionBindingService } from "../infra/outbound/session-binding-service.js";
export {
pruneLegacyStoreKeys,
resolveGatewaySessionStoreTarget,
diff --git a/src/agents/subagent-spawn.test-helpers.ts b/src/agents/subagent-spawn.test-helpers.ts
index 4d2fa8404b5c..098b6a3db752 100644
--- a/src/agents/subagent-spawn.test-helpers.ts
+++ b/src/agents/subagent-spawn.test-helpers.ts
@@ -9,8 +9,13 @@ type MockImplementationTarget = {
};
type SessionStore = Record>;
type SessionStoreMutator = (store: SessionStore) => unknown;
-type HookRunner = Pick &
- Partial>;
+type HookRunner = Pick &
+ Partial<
+ Pick<
+ SubagentLifecycleHookRunner,
+ "runSubagentSpawning" | "runSubagentSpawned" | "runSubagentEnded"
+ >
+ >;
type SubagentSpawnModuleForTest = Awaited & {
resetSubagentRegistryForTests: MockFn;
};
@@ -136,6 +141,33 @@ export async function loadSubagentSpawnModuleForTest(params: {
sessionKey?: string;
}) => { sandboxed: boolean };
getSessionBindingService?: () => {
+ getCapabilities?: (params: { channel?: string; accountId?: string }) => {
+ adapterAvailable: boolean;
+ bindSupported: boolean;
+ placements: Array<"current" | "child">;
+ };
+ bind?: (params: {
+ targetSessionKey: string;
+ targetKind?: string;
+ conversation: {
+ channel: string;
+ accountId?: string;
+ conversationId: string;
+ parentConversationId?: string;
+ };
+ placement: "current" | "child";
+ metadata?: Record;
+ }) => Promise<{
+ targetSessionKey: string;
+ targetKind?: string;
+ status?: string;
+ conversation: {
+ channel: string;
+ accountId?: string;
+ conversationId: string;
+ parentConversationId?: string;
+ };
+ }>;
listBySession: (targetSessionKey: string) => Array<{
status?: string;
conversation: {
@@ -224,7 +256,18 @@ export async function loadSubagentSpawnModuleForTest(params: {
method === "sessions.patch" || method === "sessions.delete",
pruneLegacyStoreKeys: (...args: unknown[]) => params.pruneLegacyStoreKeysMock?.(...args),
getSessionBindingService:
- params.getSessionBindingService ?? (() => ({ listBySession: () => [] })),
+ params.getSessionBindingService ??
+ (() => ({
+ getCapabilities: () => ({
+ adapterAvailable: false,
+ bindSupported: false,
+ placements: [],
+ }),
+ bind: async () => {
+ throw new Error("session binding adapter unavailable");
+ },
+ listBySession: () => [],
+ })),
resolveConversationDeliveryTarget:
params.resolveConversationDeliveryTarget ??
((targetParams: { channel?: string; conversationId?: string | number }) => ({
diff --git a/src/agents/subagent-spawn.thread-binding.test.ts b/src/agents/subagent-spawn.thread-binding.test.ts
index aa86310ce2c8..59401378efd7 100644
--- a/src/agents/subagent-spawn.thread-binding.test.ts
+++ b/src/agents/subagent-spawn.thread-binding.test.ts
@@ -14,7 +14,6 @@ const hoisted = vi.hoisted(() => ({
emitSessionLifecycleEventMock: vi.fn(),
hookRunner: {
hasHooks: vi.fn(),
- runSubagentSpawning: vi.fn(),
},
}));
@@ -44,6 +43,10 @@ function firstRegisteredSubagentRun(): {
describe("spawnSubagentDirect thread binding delivery", () => {
type SpawnModule = Awaited>;
+ type SetActivePluginRegistry = typeof import("../plugins/runtime.js").setActivePluginRegistry;
+ type CreateChannelTestPluginBase =
+ typeof import("../test-utils/channel-plugins.js").createChannelTestPluginBase;
+ type CreateTestRegistry = typeof import("../test-utils/channel-plugins.js").createTestRegistry;
type SessionBindingService = NonNullable<
Parameters[0]["getSessionBindingService"]
>;
@@ -52,6 +55,9 @@ describe("spawnSubagentDirect thread binding delivery", () => {
>;
let spawnSubagentDirect: SpawnModule["spawnSubagentDirect"];
+ let setActivePluginRegistryForTest: SetActivePluginRegistry;
+ let createChannelTestPluginBaseForTest: CreateChannelTestPluginBase;
+ let createTestRegistryForTest: CreateTestRegistry;
let currentConfig: Record;
let currentSessionBindingService: ReturnType;
let currentDeliveryTargetResolver: DeliveryTargetResolver;
@@ -69,9 +75,47 @@ describe("spawnSubagentDirect thread binding delivery", () => {
getSessionBindingService: () => currentSessionBindingService,
resolveConversationDeliveryTarget: (params) => currentDeliveryTargetResolver(params),
}));
+ ({ setActivePluginRegistry: setActivePluginRegistryForTest } =
+ await import("../plugins/runtime.js"));
+ ({
+ createChannelTestPluginBase: createChannelTestPluginBaseForTest,
+ createTestRegistry: createTestRegistryForTest,
+ } = await import("../test-utils/channel-plugins.js"));
});
+ function installChannelRouteProjectionPluginsForTest() {
+ const matrixBase = createChannelTestPluginBaseForTest({ id: "matrix", label: "Matrix" });
+ setActivePluginRegistryForTest(
+ createTestRegistryForTest([
+ {
+ pluginId: "matrix",
+ source: "test",
+ plugin: {
+ ...matrixBase,
+ messaging: {
+ resolveDeliveryTarget: ({
+ conversationId,
+ parentConversationId,
+ }: {
+ conversationId: string;
+ parentConversationId?: string;
+ }) => {
+ const parent = parentConversationId?.trim();
+ const child = conversationId.trim();
+ if (parent && parent !== child) {
+ return { to: `room:${parent}`, threadId: child };
+ }
+ return { to: `room:${child}` };
+ },
+ },
+ },
+ },
+ ]),
+ );
+ }
+
beforeEach(() => {
+ installChannelRouteProjectionPluginsForTest();
currentConfig = createSubagentSpawnTestConfig(os.tmpdir(), {
agents: {
defaults: {
@@ -85,7 +129,24 @@ describe("spawnSubagentDirect thread binding delivery", () => {
},
},
});
- currentSessionBindingService = { listBySession: () => [] };
+ currentSessionBindingService = {
+ getCapabilities: () => ({
+ adapterAvailable: true,
+ bindSupported: true,
+ placements: ["child"],
+ }),
+ bind: async (request) => ({
+ targetSessionKey: request.targetSessionKey,
+ targetKind: request.targetKind,
+ status: "active",
+ conversation: {
+ channel: request.conversation.channel,
+ accountId: request.conversation.accountId,
+ conversationId: request.conversation.conversationId,
+ },
+ }),
+ listBySession: () => [],
+ };
currentDeliveryTargetResolver = (params) => ({
to: params.conversationId ? `channel:${String(params.conversationId)}` : undefined,
});
@@ -94,40 +155,35 @@ describe("spawnSubagentDirect thread binding delivery", () => {
hoisted.registerSubagentRunMock.mockReset();
hoisted.emitSessionLifecycleEventMock.mockReset();
hoisted.hookRunner.hasHooks.mockReset();
- hoisted.hookRunner.runSubagentSpawning.mockReset();
installAcceptedSubagentGatewayMock(hoisted.callGatewayMock);
installSessionStoreCaptureMock(hoisted.updateSessionStoreMock);
});
- it("passes the target agent's bound account to thread binding hooks", async () => {
+ it("passes the target agent's bound account to core thread binding", async () => {
const boundRoom = "!room:example.org";
- let hookRequester:
- | { channel?: string; accountId?: string; to?: string; threadId?: string | number }
- | undefined;
- hoisted.hookRunner.hasHooks.mockImplementation(
- (hookName?: string) => hookName === "subagent_spawning",
- );
- hoisted.hookRunner.runSubagentSpawning.mockImplementation(async (event: unknown) => {
- hookRequester = (
- event as {
- requester?: {
- channel?: string;
- accountId?: string;
- to?: string;
- threadId?: string | number;
- };
- }
- ).requester;
- return {
- status: "ok",
- threadBindingReady: true,
- deliveryOrigin: {
- channel: "matrix",
- to: `room:${boundRoom}`,
- threadId: "$thread-root",
- },
- };
- });
+ const bindCalls: Array> = [];
+ currentSessionBindingService = {
+ getCapabilities: () => ({
+ adapterAvailable: true,
+ bindSupported: true,
+ placements: ["child"],
+ }),
+ bind: async (request) => {
+ bindCalls.push(request as unknown as Record);
+ return {
+ targetSessionKey: request.targetSessionKey,
+ targetKind: request.targetKind,
+ status: "active",
+ conversation: {
+ channel: request.conversation.channel,
+ accountId: request.conversation.accountId,
+ conversationId: "$thread-root",
+ parentConversationId: request.conversation.conversationId,
+ },
+ };
+ },
+ listBySession: () => [],
+ };
currentConfig = createSubagentSpawnTestConfig(os.tmpdir(), {
agents: {
defaults: {
@@ -174,9 +230,13 @@ describe("spawnSubagentDirect thread binding delivery", () => {
);
expect(result.status).toBe("accepted");
- expect(hookRequester?.channel).toBe("matrix");
- expect(hookRequester?.accountId).toBe("bot-alpha");
- expect(hookRequester?.to).toBe(`room:${boundRoom}`);
+ expect(bindCalls).toHaveLength(1);
+ const bindingConversation = bindCalls[0]?.conversation as
+ | { channel?: string; accountId?: string; conversationId?: string }
+ | undefined;
+ expect(bindingConversation?.channel).toBe("matrix");
+ expect(bindingConversation?.accountId).toBe("bot-alpha");
+ expect(bindingConversation?.conversationId).toBe(boundRoom);
const agentCall = hoisted.callGatewayMock.mock.calls.find(
([call]) => (call as { method?: string }).method === "agent",
)?.[0] as { params?: Record } | undefined;
@@ -194,20 +254,6 @@ describe("spawnSubagentDirect thread binding delivery", () => {
});
it("uses controller ownership for thread binding while completion routes to owner", async () => {
- let hookRequesterSessionKey: string | undefined;
- hoisted.hookRunner.hasHooks.mockImplementation(
- (hookName?: string) => hookName === "subagent_spawning",
- );
- hoisted.hookRunner.runSubagentSpawning.mockImplementation(
- async (eventValue: unknown, ctx?: { requesterSessionKey?: string }) => {
- hookRequesterSessionKey = ctx?.requesterSessionKey;
- return {
- status: "ok",
- threadBindingReady: true,
- };
- },
- );
-
const result = await spawnSubagentDirect(
{
task: "reply with a marker",
@@ -225,22 +271,29 @@ describe("spawnSubagentDirect thread binding delivery", () => {
);
expect(result.status).toBe("accepted");
- expect(hookRequesterSessionKey).toBe("agent:main:telegram:default:direct:456");
const registeredRun = firstRegisteredSubagentRun();
expect(registeredRun.controllerSessionKey).toBe("agent:main:telegram:default:direct:456");
expect(registeredRun.requesterSessionKey).toBe("agent:main:main");
expect(registeredRun.requesterDisplayKey).toBe("agent:main:main");
});
- it("keeps completion announcements when only a generic binding is available", async () => {
- hoisted.hookRunner.hasHooks.mockImplementation(
- (hookName?: string) => hookName === "subagent_spawning",
- );
- hoisted.hookRunner.runSubagentSpawning.mockResolvedValue({
- status: "ok",
- threadBindingReady: true,
- });
+ it("uses core binding delivery when only a generic route projection is available", async () => {
currentSessionBindingService = {
+ getCapabilities: () => ({
+ adapterAvailable: true,
+ bindSupported: true,
+ placements: ["child"],
+ }),
+ bind: async (request) => ({
+ targetSessionKey: request.targetSessionKey,
+ targetKind: request.targetKind,
+ status: "active",
+ conversation: {
+ channel: "collabchat",
+ accountId: "work",
+ conversationId: "collab_dm_1",
+ },
+ }),
listBySession: () => [
{
status: "active",
@@ -275,12 +328,12 @@ describe("spawnSubagentDirect thread binding delivery", () => {
const agentCall = hoisted.callGatewayMock.mock.calls.find(
([call]) => (call as { method?: string }).method === "agent",
)?.[0] as { params?: Record } | undefined;
- expect(agentCall?.params?.channel).toBe("matrix");
- expect(agentCall?.params?.accountId).toBe("sut");
- expect(agentCall?.params?.to).toBe("room:!parent:example");
- expect(agentCall?.params?.deliver).toBe(false);
+ expect(agentCall?.params?.channel).toBe("collabchat");
+ expect(agentCall?.params?.accountId).toBe("work");
+ expect(agentCall?.params?.to).toBe("channel:collab_dm_1");
+ expect(agentCall?.params?.deliver).toBe(true);
const registeredRun = firstRegisteredSubagentRun();
- expect(registeredRun?.expectsCompletionMessage).toBe(true);
+ expect(registeredRun?.expectsCompletionMessage).toBe(false);
expect(registeredRun?.requesterOrigin?.channel).toBe("matrix");
expect(registeredRun?.requesterOrigin?.accountId).toBe("sut");
expect(registeredRun?.requesterOrigin?.to).toBe("room:!parent:example");
diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts
index c408a7cdbcf4..5123b45991fd 100644
--- a/src/agents/subagent-spawn.ts
+++ b/src/agents/subagent-spawn.ts
@@ -2,7 +2,22 @@ import crypto from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import { isAcpRuntimeSpawnAvailable } from "../acp/runtime/availability.js";
-import { resolveThreadBindingSpawnPolicy } from "../channels/thread-bindings-policy.js";
+import {
+ resolveChannelDefaultBindingPlacement,
+ resolveInboundConversationResolution,
+} from "../channels/conversation-resolution.js";
+import { routeFromBindingRecord, routeToDeliveryFields } from "../channels/route-projection.js";
+import {
+ resolveThreadBindingIntroText,
+ resolveThreadBindingThreadName,
+} from "../channels/thread-bindings-messages.js";
+import {
+ formatThreadBindingDisabledError,
+ formatThreadBindingSpawnDisabledError,
+ resolveThreadBindingIdleTimeoutMsForChannel,
+ resolveThreadBindingMaxAgeMsForChannel,
+ resolveThreadBindingSpawnPolicy,
+} from "../channels/thread-bindings-policy.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { SubagentSpawnPreparation } from "../context-engine/types.js";
@@ -11,7 +26,10 @@ import { listRegisteredPluginAgentPromptGuidance } from "../plugins/command-regi
import type { SubagentLifecycleHookRunner } from "../plugins/hooks.js";
import { isValidAgentId, normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js";
import { finiteSecondsToTimerSafeMilliseconds } from "../shared/number-coercion.js";
-import { normalizeOptionalString } from "../shared/string-coerce.js";
+import {
+ normalizeOptionalLowercaseString,
+ normalizeOptionalString,
+} from "../shared/string-coerce.js";
import { resolveUserPath } from "../utils.js";
import type { DeliveryContext } from "../utils/delivery-context.types.js";
import { listAgentIds, resolveAgentDir } from "./agent-scope-config.js";
@@ -60,6 +78,7 @@ import {
emitSessionLifecycleEvent,
forkSessionFromParent,
getGlobalHookRunner,
+ getSessionBindingService,
getRuntimeConfig,
mergeSessionEntry,
mergeDeliveryContext,
@@ -642,8 +661,223 @@ function buildThreadBindingUnavailableError(mode: SpawnSubagentMode): string {
);
}
-async function ensureThreadBindingForSubagentSpawn(params: {
- hookRunner: SubagentLifecycleHookRunner | null;
+type PreparedSubagentThreadBinding = {
+ channel: string;
+ accountId: string;
+ placement: "current" | "child";
+ conversationId: string;
+ parentConversationId?: string;
+};
+
+function resolvePlacementWithoutChannelPlugin(params: {
+ capabilities: { placements: Array<"current" | "child"> };
+}): "current" | "child" {
+ return params.capabilities.placements.includes("child") ? "child" : "current";
+}
+
+function resolveSubagentSpawnChannelAccountId(params: {
+ cfg: OpenClawConfig;
+ channel?: string;
+ accountId?: string;
+}): string | undefined {
+ const channel = normalizeOptionalLowercaseString(params.channel);
+ const explicitAccountId = normalizeOptionalString(params.accountId);
+ if (explicitAccountId) {
+ return explicitAccountId;
+ }
+ if (!channel) {
+ return undefined;
+ }
+ const channels = params.cfg.channels as Record;
+ return normalizeOptionalString(channels?.[channel]?.defaultAccount) ?? "default";
+}
+
+function resolveConversationRefForThreadBinding(params: {
+ cfg: OpenClawConfig;
+ channel?: string;
+ accountId?: string;
+ to?: string;
+ threadId?: string | number;
+}): { conversationId: string; parentConversationId?: string } | null {
+ const resolution = resolveInboundConversationResolution({
+ cfg: params.cfg,
+ channel: params.channel,
+ accountId: params.accountId,
+ to: params.to,
+ threadId: params.threadId,
+ isGroup: true,
+ });
+ return resolution?.canonical ?? null;
+}
+
+function resolveRequesterBoundConversationRef(params: {
+ requesterSessionKey?: string;
+ channel: string;
+ accountId: string;
+ fallback?: { conversationId: string; parentConversationId?: string } | null;
+}): { conversationId: string; parentConversationId?: string } | null | undefined {
+ const requesterSessionKey = normalizeOptionalString(params.requesterSessionKey);
+ if (!requesterSessionKey) {
+ return undefined;
+ }
+ const activeBindings = getSessionBindingService()
+ .listBySession(requesterSessionKey)
+ .filter(
+ (record) =>
+ record.status !== "ended" &&
+ record.conversation.channel === params.channel &&
+ (record.conversation.accountId ?? params.accountId) === params.accountId,
+ );
+ if (activeBindings.length === 0) {
+ return undefined;
+ }
+ if (activeBindings.length === 1) {
+ const conversation = activeBindings[0].conversation;
+ return {
+ conversationId: conversation.conversationId,
+ ...(conversation.parentConversationId
+ ? { parentConversationId: conversation.parentConversationId }
+ : {}),
+ };
+ }
+ if (params.fallback?.conversationId) {
+ const matched = activeBindings.filter(
+ (record) =>
+ record.conversation.conversationId === params.fallback?.conversationId &&
+ normalizeOptionalString(record.conversation.parentConversationId) ===
+ normalizeOptionalString(params.fallback?.parentConversationId),
+ );
+ if (matched.length === 1) {
+ const conversation = matched[0].conversation;
+ return {
+ conversationId: conversation.conversationId,
+ ...(conversation.parentConversationId
+ ? { parentConversationId: conversation.parentConversationId }
+ : {}),
+ };
+ }
+ }
+ return null;
+}
+
+function prepareSubagentThreadBinding(params: {
+ cfg: OpenClawConfig;
+ mode: SpawnSubagentMode;
+ requesterSessionKey?: string;
+ requester: {
+ channel?: string;
+ accountId?: string;
+ to?: string;
+ threadId?: string | number;
+ };
+}): { ok: true; binding: PreparedSubagentThreadBinding } | { ok: false; error: string } {
+ const channel = normalizeOptionalLowercaseString(params.requester.channel);
+ if (!channel) {
+ return {
+ ok: false,
+ error: buildThreadBindingUnavailableError(params.mode),
+ };
+ }
+
+ const accountId = resolveSubagentSpawnChannelAccountId({
+ cfg: params.cfg,
+ channel,
+ accountId: params.requester.accountId,
+ });
+ const policy = resolveThreadBindingSpawnPolicy({
+ cfg: params.cfg,
+ channel,
+ accountId,
+ kind: "subagent",
+ });
+ if (!policy.enabled) {
+ return {
+ ok: false,
+ error: formatThreadBindingDisabledError({
+ channel: policy.channel,
+ accountId: policy.accountId,
+ kind: "subagent",
+ }),
+ };
+ }
+ if (!policy.spawnEnabled) {
+ return {
+ ok: false,
+ error: formatThreadBindingSpawnDisabledError({
+ channel: policy.channel,
+ accountId: policy.accountId,
+ kind: "subagent",
+ }),
+ };
+ }
+
+ const bindingService = getSessionBindingService();
+ const capabilities = bindingService.getCapabilities({
+ channel: policy.channel,
+ accountId: policy.accountId,
+ });
+ if (!capabilities.adapterAvailable) {
+ return {
+ ok: false,
+ error: buildThreadBindingUnavailableError(params.mode),
+ };
+ }
+ const pluginPlacement = resolveChannelDefaultBindingPlacement(policy.channel);
+ const placementToUse =
+ pluginPlacement ??
+ resolvePlacementWithoutChannelPlugin({
+ capabilities,
+ });
+ if (!capabilities.bindSupported || !capabilities.placements.includes(placementToUse)) {
+ return {
+ ok: false,
+ error: `Thread bindings do not support ${placementToUse} placement for ${policy.channel}.`,
+ };
+ }
+
+ const fallbackConversationRef = resolveConversationRefForThreadBinding({
+ cfg: params.cfg,
+ channel: policy.channel,
+ accountId: policy.accountId,
+ to: params.requester.to,
+ threadId: params.requester.threadId,
+ });
+ const requesterConversationRef = resolveRequesterBoundConversationRef({
+ requesterSessionKey: params.requesterSessionKey,
+ channel: policy.channel,
+ accountId: policy.accountId,
+ fallback: fallbackConversationRef,
+ });
+ if (requesterConversationRef === null) {
+ return {
+ ok: false,
+ error: `Could not resolve a unique ${policy.channel} requester conversation for subagent thread spawn.`,
+ };
+ }
+ const conversationRef = requesterConversationRef ?? fallbackConversationRef;
+ if (!conversationRef?.conversationId) {
+ return {
+ ok: false,
+ error: `Could not resolve a ${policy.channel} conversation for subagent thread spawn.`,
+ };
+ }
+
+ return {
+ ok: true,
+ binding: {
+ channel: policy.channel,
+ accountId: policy.accountId,
+ placement: placementToUse,
+ conversationId: conversationRef.conversationId,
+ ...(conversationRef.parentConversationId
+ ? { parentConversationId: conversationRef.parentConversationId }
+ : {}),
+ },
+ };
+}
+
+async function bindThreadForSubagentSpawn(params: {
+ cfg: OpenClawConfig;
childSessionKey: string;
agentId: string;
label?: string;
@@ -656,51 +890,70 @@ async function ensureThreadBindingForSubagentSpawn(params: {
threadId?: string | number;
};
}): Promise<
- { status: "ok"; deliveryOrigin?: DeliveryContext } | { status: "error"; error: string }
+ | { status: "ok"; deliveryOrigin?: DeliveryContext }
+ | {
+ status: "error";
+ error: string;
+ }
> {
- if (!params.hookRunner?.hasHooks("subagent_spawning")) {
+ const prepared = prepareSubagentThreadBinding({
+ cfg: params.cfg,
+ mode: params.mode,
+ requesterSessionKey: params.requesterSessionKey,
+ requester: params.requester,
+ });
+ if (!prepared.ok) {
return {
status: "error",
- error: buildThreadBindingUnavailableError(params.mode),
+ error: prepared.error,
};
}
try {
- const result = await params.hookRunner.runSubagentSpawning(
- {
- childSessionKey: params.childSessionKey,
+ const binding = await getSessionBindingService().bind({
+ targetSessionKey: params.childSessionKey,
+ targetKind: "subagent",
+ conversation: {
+ channel: prepared.binding.channel,
+ accountId: prepared.binding.accountId,
+ conversationId: prepared.binding.conversationId,
+ ...(prepared.binding.parentConversationId
+ ? { parentConversationId: prepared.binding.parentConversationId }
+ : {}),
+ },
+ placement: prepared.binding.placement,
+ metadata: {
+ threadName: resolveThreadBindingThreadName({
+ agentId: params.agentId,
+ label: params.label || params.agentId,
+ }),
agentId: params.agentId,
- label: params.label,
- mode: params.mode,
- requester: params.requester,
- threadRequested: true,
+ label: params.label || undefined,
+ boundBy: "system",
+ introText: resolveThreadBindingIntroText({
+ agentId: params.agentId,
+ label: params.label || undefined,
+ idleTimeoutMs: resolveThreadBindingIdleTimeoutMsForChannel({
+ cfg: params.cfg,
+ channel: prepared.binding.channel,
+ accountId: prepared.binding.accountId,
+ }),
+ maxAgeMs: resolveThreadBindingMaxAgeMsForChannel({
+ cfg: params.cfg,
+ channel: prepared.binding.channel,
+ accountId: prepared.binding.accountId,
+ }),
+ }),
},
- {
- childSessionKey: params.childSessionKey,
- requesterSessionKey: params.requesterSessionKey,
- },
- );
- if (result?.status === "error") {
- const error = result.error.trim();
- return {
- status: "error",
- error: error || "Failed to prepare thread binding for this subagent session.",
- };
- }
- if (!result) {
- return {
- status: "error",
- error: buildThreadBindingUnavailableError(params.mode),
- };
- }
- if (result?.status !== "ok" || !result.threadBindingReady) {
+ });
+ if (!binding.conversation.conversationId) {
return {
status: "error",
error:
"Unable to create or bind a thread for this subagent session. Session mode is unavailable for this target.",
};
}
- const deliveryOrigin = normalizeDeliveryContext(result.deliveryOrigin);
+ const deliveryOrigin = routeToDeliveryFields(routeFromBindingRecord(binding)).deliveryContext;
return {
status: "ok",
...(deliveryOrigin ? { deliveryOrigin } : {}),
@@ -1030,8 +1283,8 @@ export async function spawnSubagentDirect(
modelApplied = true;
}
if (requestThreadBinding) {
- const bindResult = await ensureThreadBindingForSubagentSpawn({
- hookRunner,
+ const bindResult = await bindThreadForSubagentSpawn({
+ cfg,
childSessionKey,
agentId: targetAgentId,
label: label || undefined,
diff --git a/src/agents/subagent-spawn.workspace.test.ts b/src/agents/subagent-spawn.workspace.test.ts
index 3f3c29bcec4e..fb1ce13f56eb 100644
--- a/src/agents/subagent-spawn.workspace.test.ts
+++ b/src/agents/subagent-spawn.workspace.test.ts
@@ -18,6 +18,18 @@ type TestConfig = {
list?: TestAgentConfig[];
};
};
+type TestBindingRequest = {
+ targetSessionKey: string;
+ targetKind?: string;
+ conversation: {
+ channel: string;
+ accountId?: string;
+ conversationId: string;
+ parentConversationId?: string;
+ };
+ placement: "current" | "child";
+ metadata?: Record;
+};
const hoisted = vi.hoisted(() => ({
callGatewayMock: vi.fn(),
@@ -28,7 +40,23 @@ const hoisted = vi.hoisted(() => ({
>(() => ({ sandboxed: false })),
hookRunner: {
hasHooks: vi.fn(() => false),
- runSubagentSpawning: vi.fn(),
+ },
+ bindingService: {
+ getCapabilities: vi.fn(() => ({
+ adapterAvailable: true,
+ bindSupported: true,
+ placements: ["child"] as Array<"current" | "child">,
+ })),
+ bind: vi.fn(async (request: TestBindingRequest) => {
+ const conversation = request.conversation;
+ return {
+ targetSessionKey: request.targetSessionKey,
+ targetKind: request.targetKind,
+ status: "active",
+ conversation,
+ };
+ }),
+ listBySession: vi.fn(() => []),
},
}));
@@ -111,6 +139,7 @@ describe("spawnSubagentDirect workspace inheritance", () => {
resolveAgentConfig: resolveTestAgentConfig,
resolveAgentWorkspaceDir: resolveTestAgentWorkspace,
resolveSandboxRuntimeStatus: hoisted.resolveSandboxRuntimeStatusMock,
+ getSessionBindingService: () => hoisted.bindingService,
resetModules: false,
}));
});
@@ -123,7 +152,9 @@ describe("spawnSubagentDirect workspace inheritance", () => {
hoisted.resolveSandboxRuntimeStatusMock.mockImplementation(() => ({ sandboxed: false }));
hoisted.hookRunner.hasHooks.mockReset();
hoisted.hookRunner.hasHooks.mockImplementation(() => false);
- hoisted.hookRunner.runSubagentSpawning.mockReset();
+ hoisted.bindingService.getCapabilities.mockClear();
+ hoisted.bindingService.bind.mockClear();
+ hoisted.bindingService.listBySession.mockClear();
hoisted.configOverride = createConfigOverride();
setupAcceptedSubagentGatewayMock(hoisted.callGatewayMock);
});
@@ -323,11 +354,6 @@ describe("spawnSubagentDirect workspace inheritance", () => {
});
it("keeps lifecycle hooks enabled when registerSubagentRun fails after thread binding succeeds", async () => {
- hoisted.hookRunner.hasHooks.mockImplementation((name?: string) => name === "subagent_spawning");
- hoisted.hookRunner.runSubagentSpawning.mockResolvedValue({
- status: "ok",
- threadBindingReady: true,
- });
hoisted.registerSubagentRunMock.mockImplementation(() => {
throw new Error("registry unavailable");
});
diff --git a/src/plugins/compat/registry.test.ts b/src/plugins/compat/registry.test.ts
index 942bdc95ccce..3d6cfe9a7314 100644
--- a/src/plugins/compat/registry.test.ts
+++ b/src/plugins/compat/registry.test.ts
@@ -163,6 +163,11 @@ const knownDeprecatedSurfaceMarkers = [
file: "src/plugins/hook-types.ts",
marker: "@deprecated Use gateway_stop",
},
+ {
+ code: "legacy-subagent-spawning-hook",
+ file: "src/plugins/hook-types.ts",
+ marker: "@deprecated Core prepares thread-bound subagent bindings",
+ },
{
code: "deprecated-memory-embedding-provider-api",
file: "src/plugins/types.ts",
diff --git a/src/plugins/compat/registry.ts b/src/plugins/compat/registry.ts
index aa3a0a803e9f..f7f568da99b1 100644
--- a/src/plugins/compat/registry.ts
+++ b/src/plugins/compat/registry.ts
@@ -39,6 +39,28 @@ export const PLUGIN_COMPAT_RECORDS = [
releaseNote:
'`api.on("deactivate", ...)` remains wired as a deprecated compatibility alias while plugins migrate to `gateway_stop`.',
},
+ {
+ code: "legacy-subagent-spawning-hook",
+ status: "deprecated",
+ owner: "sdk",
+ introduced: "2026-05-30",
+ deprecated: "2026-05-30",
+ warningStarts: "2026-05-30",
+ removeAfter: "2026-08-30",
+ replacement:
+ "`subagent_spawned` for post-launch observation; core session-binding adapters for thread routing",
+ docsPath: "/plugins/hooks#upcoming-deprecations",
+ surfaces: [
+ 'api.on("subagent_spawning", ...)',
+ "PluginHookSubagentSpawningEvent",
+ "PluginHookSubagentSpawningResult",
+ "SubagentLifecycleHookRunner.runSubagentSpawning",
+ ],
+ diagnostics: ["plugin runtime compatibility warning"],
+ tests: ["src/plugins/loader.test.ts", "src/plugins/compat/registry.test.ts"],
+ releaseNote:
+ '`api.on("subagent_spawning", ...)` remains wired only for older plugins; core now owns thread-bound subagent routing.',
+ },
{
code: "hook-only-plugin-shape",
status: "active",
diff --git a/src/plugins/contracts/boundary-invariants.test.ts b/src/plugins/contracts/boundary-invariants.test.ts
index 1a1df99a569c..8a6c0a692465 100644
--- a/src/plugins/contracts/boundary-invariants.test.ts
+++ b/src/plugins/contracts/boundary-invariants.test.ts
@@ -28,21 +28,9 @@ const BUNDLED_TYPED_HOOK_REGISTRATION_GUARDS = {
"extensions/active-memory/index.ts": ["before_prompt_build"],
"extensions/codex/index.ts": ["inbound_claim"],
"extensions/diffs/src/plugin.ts": ["before_prompt_build"],
- "extensions/discord/subagent-hooks-api.ts": [
- "subagent_delivery_target",
- "subagent_ended",
- "subagent_spawning",
- ],
- "extensions/feishu/subagent-hooks-api.ts": [
- "subagent_delivery_target",
- "subagent_ended",
- "subagent_spawning",
- ],
- "extensions/matrix/subagent-hooks-api.ts": [
- "subagent_delivery_target",
- "subagent_ended",
- "subagent_spawning",
- ],
+ "extensions/discord/subagent-hooks-api.ts": ["subagent_delivery_target", "subagent_ended"],
+ "extensions/feishu/subagent-hooks-api.ts": ["subagent_delivery_target", "subagent_ended"],
+ "extensions/matrix/subagent-hooks-api.ts": ["subagent_delivery_target", "subagent_ended"],
"extensions/memory-core/src/dreaming.ts": ["before_agent_reply", "gateway_start", "gateway_stop"],
"extensions/memory-lancedb/index.ts": ["agent_end", "before_prompt_build", "session_end"],
"extensions/thread-ownership/index.ts": ["message_received", "message_sending"],
diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts
index 8793c3fe266c..fed859a696bd 100644
--- a/src/plugins/hook-types.ts
+++ b/src/plugins/hook-types.ts
@@ -91,6 +91,11 @@ export type PluginHookName =
| "before_message_write"
| "session_start"
| "session_end"
+ /**
+ * @deprecated Core prepares thread-bound subagent bindings through channel
+ * session-binding adapters before `subagent_spawned` fires. Use
+ * `subagent_spawned` for post-launch observation in new plugins.
+ */
| "subagent_spawning"
| "subagent_delivery_target"
| "subagent_spawned"
@@ -152,6 +157,38 @@ type AssertAllPluginHookNamesListed = MissingPluginHookNames extends never ? tru
const assertAllPluginHookNamesListed: AssertAllPluginHookNamesListed = true;
void assertAllPluginHookNamesListed;
+export type DeprecatedPluginHookName = "subagent_spawning" | "deactivate";
+
+export type PluginHookDeprecation = {
+ replacement: string;
+ reason: string;
+ removeAfter?: string;
+};
+
+export const DEPRECATED_PLUGIN_HOOKS = {
+ subagent_spawning: {
+ replacement: "`subagent_spawned` for observation; core session bindings for routing",
+ reason:
+ "Core prepares thread-bound subagent bindings through channel session-binding adapters before `subagent_spawned` fires.",
+ removeAfter: "2026-08-30",
+ },
+ deactivate: {
+ replacement: "`gateway_stop`",
+ reason: "`deactivate` is a legacy cleanup hook alias for `gateway_stop`.",
+ removeAfter: "2026-08-16",
+ },
+} as const satisfies Record;
+
+export const DEPRECATED_PLUGIN_HOOK_NAMES = Object.keys(
+ DEPRECATED_PLUGIN_HOOKS,
+) as DeprecatedPluginHookName[];
+
+const deprecatedPluginHookNameSet = new Set(DEPRECATED_PLUGIN_HOOK_NAMES);
+
+export const isDeprecatedPluginHookName = (
+ hookName: PluginHookName,
+): hookName is DeprecatedPluginHookName => deprecatedPluginHookNameSet.has(hookName);
+
const pluginHookNameSet = new Set(PLUGIN_HOOK_NAMES);
export const isPluginHookName = (hookName: unknown): hookName is PluginHookName =>
@@ -612,8 +649,18 @@ type PluginHookSubagentSpawnBase = {
threadRequested: boolean;
};
+/**
+ * @deprecated Core prepares thread-bound subagent bindings through channel
+ * session-binding adapters before `subagent_spawned` fires. Use
+ * `subagent_spawned` for post-launch observation in new plugins.
+ */
export type PluginHookSubagentSpawningEvent = PluginHookSubagentSpawnBase;
+/**
+ * @deprecated Core prepares thread-bound subagent bindings through channel
+ * session-binding adapters before `subagent_spawned` fires. Returning routing
+ * data from `subagent_spawning` is retained only for older runtimes.
+ */
export type PluginHookSubagentSpawningResult =
| {
status: "ok";
@@ -1040,6 +1087,11 @@ export type PluginHookHandlerMap = {
event: PluginHookSessionEndEvent,
ctx: PluginHookSessionContext,
) => Promise | void;
+ /**
+ * @deprecated Core prepares thread-bound subagent bindings through channel
+ * session-binding adapters before `subagent_spawned` fires. Use
+ * `subagent_spawned` for post-launch observation in new plugins.
+ */
subagent_spawning: (
event: PluginHookSubagentSpawningEvent,
ctx: PluginHookSubagentContext,
diff --git a/src/plugins/hooks.ts b/src/plugins/hooks.ts
index 1914cb4e0f2b..8a05bcca1668 100644
--- a/src/plugins/hooks.ts
+++ b/src/plugins/hooks.ts
@@ -1453,8 +1453,9 @@ export function createHookRunner(
}
/**
- * Run subagent_spawning hook.
- * Runs sequentially so channel plugins can deterministically provision session bindings.
+ * @deprecated Core prepares thread-bound subagent bindings through channel
+ * session-binding adapters before subagent_spawned fires. This remains only
+ * for older plugins that call the hook runner directly.
*/
async function runSubagentSpawning(
event: PluginHookSubagentSpawningEvent,
diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts
index 00fd62d4cbda..cfd210bf6d4d 100644
--- a/src/plugins/loader.test.ts
+++ b/src/plugins/loader.test.ts
@@ -15,7 +15,11 @@ import {
getRegisteredEventKeys,
triggerInternalHook,
} from "../hooks/internal-hooks.js";
-import { emitDiagnosticEvent } from "../infra/diagnostic-events.js";
+import {
+ emitDiagnosticEvent,
+ resetDiagnosticEventsForTest,
+ waitForDiagnosticEventsDrained,
+} from "../infra/diagnostic-events.js";
import {
clearDetachedTaskLifecycleRuntimeRegistration,
getDetachedTaskLifecycleRuntimeRegistration,
@@ -985,6 +989,7 @@ function collectStartupTraceMetrics(
}
afterEach(() => {
+ resetDiagnosticEventsForTest();
clearRuntimeConfigSnapshot();
runtimeRegistryLoaderTesting.resetPluginRegistryLoadedForTests();
resetPluginLoaderTestStateForTest();
@@ -6862,6 +6867,37 @@ module.exports = {
).toBe(true);
});
+ it("warns when plugins register deprecated subagent_spawning typed hooks", () => {
+ useNoBundledPlugins();
+ const plugin = writePlugin({
+ id: "legacy-subagent-spawning-hook",
+ filename: "legacy-subagent-spawning-hook.cjs",
+ body: `module.exports = { id: "legacy-subagent-spawning-hook", register(api) {
+ api.on("subagent_spawning", () => ({ status: "ok" }));
+} };`,
+ });
+
+ const registry = loadRegistryFromSinglePlugin({
+ plugin,
+ pluginConfig: {
+ allow: ["legacy-subagent-spawning-hook"],
+ },
+ });
+
+ expect(
+ registry.plugins.find((entry) => entry.id === "legacy-subagent-spawning-hook")?.status,
+ ).toBe("loaded");
+ expect(registry.typedHooks.map((entry) => entry.hookName)).toEqual(["subagent_spawning"]);
+ expect(
+ registry.diagnostics.some(
+ (diag) =>
+ diag.pluginId === "legacy-subagent-spawning-hook" &&
+ diag.message ===
+ 'typed hook "subagent_spawning" is deprecated (legacy-subagent-spawning-hook); Core prepares thread-bound subagent bindings through channel session-binding adapters before `subagent_spawned` fires. Use `subagent_spawned` for observation; core session bindings for routing. This compatibility hook will be removed after 2026-08-30.',
+ ),
+ ).toBe(true);
+ });
+
it("ignores unknown typed hooks from plugins and keeps loading", () => {
useNoBundledPlugins();
const plugin = writePlugin({
@@ -8059,7 +8095,7 @@ module.exports = {
).toBe("loaded");
});
- it("supports legacy plugins subscribing to diagnostic events from the root sdk", () => {
+ it("supports legacy plugins subscribing to diagnostic events from the root sdk", async () => {
useNoBundledPlugins();
const seenKey = "__openclawLegacyRootDiagnosticSeen";
delete (globalThis as Record)[seenKey];
@@ -8114,6 +8150,7 @@ module.exports = {
sessionKey: "agent:main:test:dm:peer",
usage: { total: 1 },
});
+ await waitForDiagnosticEventsDrained();
expect((globalThis as Record)[seenKey]).toEqual([
{
diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts
index 0d75775afbb0..5b27cf6d4a1f 100644
--- a/src/plugins/registry.ts
+++ b/src/plugins/registry.ts
@@ -152,7 +152,9 @@ import {
normalizePluginToolNames,
} from "./tool-contracts.js";
import {
+ DEPRECATED_PLUGIN_HOOKS,
isConversationHookName,
+ isDeprecatedPluginHookName,
isPluginHookName,
isPromptInjectionHookName,
stripPromptMutationFieldsFromLegacyHookResult,
@@ -202,6 +204,7 @@ export type PluginHttpRouteRegistration = RegistryTypesPluginHttpRouteRegistrati
const GATEWAY_METHOD_DISPATCH_CONTRACT = "authenticated-request";
const LEGACY_DEACTIVATE_HOOK_ALIAS_COMPAT = getPluginCompatRecord("legacy-deactivate-hook-alias");
+const LEGACY_SUBAGENT_SPAWNING_HOOK_COMPAT = getPluginCompatRecord("legacy-subagent-spawning-hook");
function formatLegacyDeactivateHookAliasDiagnostic(): string {
const removeAfter =
@@ -212,6 +215,22 @@ function formatLegacyDeactivateHookAliasDiagnostic(): string {
);
}
+function formatDeprecatedTypedHookDiagnostic(hookName: PluginHookName): string | undefined {
+ if (!isDeprecatedPluginHookName(hookName) || hookName === "deactivate") {
+ return undefined;
+ }
+ const deprecation = DEPRECATED_PLUGIN_HOOKS[hookName];
+ const compat =
+ hookName === "subagent_spawning" ? LEGACY_SUBAGENT_SPAWNING_HOOK_COMPAT : undefined;
+ const removeAfter = compat?.removeAfter ?? deprecation.removeAfter ?? "a future breaking release";
+ const code = compat?.code ?? "deprecated-plugin-hook";
+ return (
+ `typed hook "${hookName}" is deprecated (${code}); ` +
+ `${deprecation.reason} Use ${deprecation.replacement}. ` +
+ `This compatibility hook will be removed after ${removeAfter}.`
+ );
+}
+
type PluginOwnedProviderRegistration = {
pluginId: string;
pluginName?: string;
@@ -2444,6 +2463,16 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
source: record.source,
message: formatLegacyDeactivateHookAliasDiagnostic(),
});
+ } else {
+ const deprecatedHookDiagnostic = formatDeprecatedTypedHookDiagnostic(hookName);
+ if (deprecatedHookDiagnostic) {
+ pushDiagnostic({
+ level: "warn",
+ pluginId: record.id,
+ source: record.source,
+ message: deprecatedHookDiagnostic,
+ });
+ }
}
let effectiveHandler = handler;
if (policy?.allowPromptInjection === false && isPromptInjectionHookName(effectiveHookName)) {