Skip to content

Commit 7a6acc3

Browse files
committed
docs(plugins): deprecate subagent spawning SDK surface
1 parent a8caa6f commit 7a6acc3

7 files changed

Lines changed: 165 additions & 2 deletions

File tree

docs/plugins/hooks.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,10 @@ before the next major release:
466466
- **`before_agent_start`** remains for compatibility. New plugins should use
467467
`before_model_resolve` and `before_prompt_build` instead of the combined
468468
phase.
469+
- **`subagent_spawning`** remains for compatibility with older plugins, but
470+
new plugins should not return thread routing from it. Core prepares
471+
`thread: true` subagent bindings through channel session-binding adapters
472+
before `subagent_spawned` fires.
469473
- **`deactivate`** remains as a deprecated cleanup compatibility alias until
470474
after 2026-08-16. New plugins should use `gateway_stop`.
471475
- **`onResolution` in `before_tool_call`** now uses the typed

docs/plugins/sdk-migration.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -792,6 +792,35 @@ canonical replacement.
792792

793793
</Accordion>
794794

795+
<Accordion title="subagent_spawning hook → core thread binding">
796+
**Old**: `api.on("subagent_spawning", handler)` returning
797+
`threadBindingReady` or `deliveryOrigin`.
798+
799+
**New**: let core prepare `thread: true` subagent bindings through the
800+
channel session-binding adapter. Use `api.on("subagent_spawned", handler)`
801+
only for post-launch observation.
802+
803+
```typescript
804+
// Before
805+
api.on("subagent_spawning", async () => ({
806+
status: "ok",
807+
threadBindingReady: true,
808+
deliveryOrigin: { channel: "discord", to: "channel:123", threadId: "456" },
809+
}));
810+
811+
// After
812+
api.on("subagent_spawned", async (event) => {
813+
await observeSubagentLaunch(event);
814+
});
815+
```
816+
817+
`subagent_spawning`, `PluginHookSubagentSpawningEvent`,
818+
`PluginHookSubagentSpawningResult`, and
819+
`SubagentLifecycleHookRunner.runSubagentSpawning(...)` remain only as
820+
deprecated compatibility surfaces while external plugins migrate.
821+
822+
</Accordion>
823+
795824
<Accordion title="Provider discovery types → provider catalog types">
796825
Four discovery type aliases are now thin wrappers over the
797826
catalog-era types:

src/plugins/compat/registry.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,11 @@ const knownDeprecatedSurfaceMarkers = [
163163
file: "src/plugins/hook-types.ts",
164164
marker: "@deprecated Use gateway_stop",
165165
},
166+
{
167+
code: "legacy-subagent-spawning-hook",
168+
file: "src/plugins/hook-types.ts",
169+
marker: "@deprecated Core prepares thread-bound subagent bindings",
170+
},
166171
{
167172
code: "deprecated-memory-embedding-provider-api",
168173
file: "src/plugins/types.ts",

src/plugins/compat/registry.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,28 @@ export const PLUGIN_COMPAT_RECORDS = [
3939
releaseNote:
4040
'`api.on("deactivate", ...)` remains wired as a deprecated compatibility alias while plugins migrate to `gateway_stop`.',
4141
},
42+
{
43+
code: "legacy-subagent-spawning-hook",
44+
status: "deprecated",
45+
owner: "sdk",
46+
introduced: "2026-05-30",
47+
deprecated: "2026-05-30",
48+
warningStarts: "2026-05-30",
49+
removeAfter: "2026-08-30",
50+
replacement:
51+
"`subagent_spawned` for post-launch observation; core session-binding adapters for thread routing",
52+
docsPath: "/plugins/hooks#upcoming-deprecations",
53+
surfaces: [
54+
'api.on("subagent_spawning", ...)',
55+
"PluginHookSubagentSpawningEvent",
56+
"PluginHookSubagentSpawningResult",
57+
"SubagentLifecycleHookRunner.runSubagentSpawning",
58+
],
59+
diagnostics: ["plugin runtime compatibility warning"],
60+
tests: ["src/plugins/loader.test.ts", "src/plugins/compat/registry.test.ts"],
61+
releaseNote:
62+
'`api.on("subagent_spawning", ...)` remains wired only for older plugins; core now owns thread-bound subagent routing.',
63+
},
4264
{
4365
code: "hook-only-plugin-shape",
4466
status: "active",

src/plugins/hook-types.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,38 @@ type AssertAllPluginHookNamesListed = MissingPluginHookNames extends never ? tru
157157
const assertAllPluginHookNamesListed: AssertAllPluginHookNamesListed = true;
158158
void assertAllPluginHookNamesListed;
159159

160+
export type DeprecatedPluginHookName = "subagent_spawning" | "deactivate";
161+
162+
export type PluginHookDeprecation = {
163+
replacement: string;
164+
reason: string;
165+
removeAfter?: string;
166+
};
167+
168+
export const DEPRECATED_PLUGIN_HOOKS = {
169+
subagent_spawning: {
170+
replacement: "`subagent_spawned` for observation; core session bindings for routing",
171+
reason:
172+
"Core prepares thread-bound subagent bindings through channel session-binding adapters before `subagent_spawned` fires.",
173+
removeAfter: "2026-08-30",
174+
},
175+
deactivate: {
176+
replacement: "`gateway_stop`",
177+
reason: "`deactivate` is a legacy cleanup hook alias for `gateway_stop`.",
178+
removeAfter: "2026-08-16",
179+
},
180+
} as const satisfies Record<DeprecatedPluginHookName, PluginHookDeprecation>;
181+
182+
export const DEPRECATED_PLUGIN_HOOK_NAMES = Object.keys(
183+
DEPRECATED_PLUGIN_HOOKS,
184+
) as DeprecatedPluginHookName[];
185+
186+
const deprecatedPluginHookNameSet = new Set<PluginHookName>(DEPRECATED_PLUGIN_HOOK_NAMES);
187+
188+
export const isDeprecatedPluginHookName = (
189+
hookName: PluginHookName,
190+
): hookName is DeprecatedPluginHookName => deprecatedPluginHookNameSet.has(hookName);
191+
160192
const pluginHookNameSet = new Set<PluginHookName>(PLUGIN_HOOK_NAMES);
161193

162194
export const isPluginHookName = (hookName: unknown): hookName is PluginHookName =>
@@ -624,6 +656,11 @@ type PluginHookSubagentSpawnBase = {
624656
*/
625657
export type PluginHookSubagentSpawningEvent = PluginHookSubagentSpawnBase;
626658

659+
/**
660+
* @deprecated Core prepares thread-bound subagent bindings through channel
661+
* session-binding adapters before `subagent_spawned` fires. Returning routing
662+
* data from `subagent_spawning` is retained only for older runtimes.
663+
*/
627664
export type PluginHookSubagentSpawningResult =
628665
| {
629666
status: "ok";

src/plugins/loader.test.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ import {
1515
getRegisteredEventKeys,
1616
triggerInternalHook,
1717
} from "../hooks/internal-hooks.js";
18-
import { emitDiagnosticEvent } from "../infra/diagnostic-events.js";
18+
import {
19+
emitDiagnosticEvent,
20+
resetDiagnosticEventsForTest,
21+
waitForDiagnosticEventsDrained,
22+
} from "../infra/diagnostic-events.js";
1923
import {
2024
clearDetachedTaskLifecycleRuntimeRegistration,
2125
getDetachedTaskLifecycleRuntimeRegistration,
@@ -985,6 +989,7 @@ function collectStartupTraceMetrics(
985989
}
986990

987991
afterEach(() => {
992+
resetDiagnosticEventsForTest();
988993
clearRuntimeConfigSnapshot();
989994
runtimeRegistryLoaderTesting.resetPluginRegistryLoadedForTests();
990995
resetPluginLoaderTestStateForTest();
@@ -6862,6 +6867,37 @@ module.exports = {
68626867
).toBe(true);
68636868
});
68646869

6870+
it("warns when plugins register deprecated subagent_spawning typed hooks", () => {
6871+
useNoBundledPlugins();
6872+
const plugin = writePlugin({
6873+
id: "legacy-subagent-spawning-hook",
6874+
filename: "legacy-subagent-spawning-hook.cjs",
6875+
body: `module.exports = { id: "legacy-subagent-spawning-hook", register(api) {
6876+
api.on("subagent_spawning", () => ({ status: "ok" }));
6877+
} };`,
6878+
});
6879+
6880+
const registry = loadRegistryFromSinglePlugin({
6881+
plugin,
6882+
pluginConfig: {
6883+
allow: ["legacy-subagent-spawning-hook"],
6884+
},
6885+
});
6886+
6887+
expect(
6888+
registry.plugins.find((entry) => entry.id === "legacy-subagent-spawning-hook")?.status,
6889+
).toBe("loaded");
6890+
expect(registry.typedHooks.map((entry) => entry.hookName)).toEqual(["subagent_spawning"]);
6891+
expect(
6892+
registry.diagnostics.some(
6893+
(diag) =>
6894+
diag.pluginId === "legacy-subagent-spawning-hook" &&
6895+
diag.message ===
6896+
'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.',
6897+
),
6898+
).toBe(true);
6899+
});
6900+
68656901
it("ignores unknown typed hooks from plugins and keeps loading", () => {
68666902
useNoBundledPlugins();
68676903
const plugin = writePlugin({
@@ -8059,7 +8095,7 @@ module.exports = {
80598095
).toBe("loaded");
80608096
});
80618097

8062-
it("supports legacy plugins subscribing to diagnostic events from the root sdk", () => {
8098+
it("supports legacy plugins subscribing to diagnostic events from the root sdk", async () => {
80638099
useNoBundledPlugins();
80648100
const seenKey = "__openclawLegacyRootDiagnosticSeen";
80658101
delete (globalThis as Record<string, unknown>)[seenKey];
@@ -8114,6 +8150,7 @@ module.exports = {
81148150
sessionKey: "agent:main:test:dm:peer",
81158151
usage: { total: 1 },
81168152
});
8153+
await waitForDiagnosticEventsDrained();
81178154

81188155
expect((globalThis as Record<string, unknown>)[seenKey]).toEqual([
81198156
{

src/plugins/registry.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,9 @@ import {
152152
normalizePluginToolNames,
153153
} from "./tool-contracts.js";
154154
import {
155+
DEPRECATED_PLUGIN_HOOKS,
155156
isConversationHookName,
157+
isDeprecatedPluginHookName,
156158
isPluginHookName,
157159
isPromptInjectionHookName,
158160
stripPromptMutationFieldsFromLegacyHookResult,
@@ -202,6 +204,7 @@ export type PluginHttpRouteRegistration = RegistryTypesPluginHttpRouteRegistrati
202204

203205
const GATEWAY_METHOD_DISPATCH_CONTRACT = "authenticated-request";
204206
const LEGACY_DEACTIVATE_HOOK_ALIAS_COMPAT = getPluginCompatRecord("legacy-deactivate-hook-alias");
207+
const LEGACY_SUBAGENT_SPAWNING_HOOK_COMPAT = getPluginCompatRecord("legacy-subagent-spawning-hook");
205208

206209
function formatLegacyDeactivateHookAliasDiagnostic(): string {
207210
const removeAfter =
@@ -212,6 +215,22 @@ function formatLegacyDeactivateHookAliasDiagnostic(): string {
212215
);
213216
}
214217

218+
function formatDeprecatedTypedHookDiagnostic(hookName: PluginHookName): string | undefined {
219+
if (!isDeprecatedPluginHookName(hookName) || hookName === "deactivate") {
220+
return undefined;
221+
}
222+
const deprecation = DEPRECATED_PLUGIN_HOOKS[hookName];
223+
const compat =
224+
hookName === "subagent_spawning" ? LEGACY_SUBAGENT_SPAWNING_HOOK_COMPAT : undefined;
225+
const removeAfter = compat?.removeAfter ?? deprecation.removeAfter ?? "a future breaking release";
226+
const code = compat?.code ?? "deprecated-plugin-hook";
227+
return (
228+
`typed hook "${hookName}" is deprecated (${code}); ` +
229+
`${deprecation.reason} Use ${deprecation.replacement}. ` +
230+
`This compatibility hook will be removed after ${removeAfter}.`
231+
);
232+
}
233+
215234
type PluginOwnedProviderRegistration<T extends { id: string }> = {
216235
pluginId: string;
217236
pluginName?: string;
@@ -2444,6 +2463,16 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
24442463
source: record.source,
24452464
message: formatLegacyDeactivateHookAliasDiagnostic(),
24462465
});
2466+
} else {
2467+
const deprecatedHookDiagnostic = formatDeprecatedTypedHookDiagnostic(hookName);
2468+
if (deprecatedHookDiagnostic) {
2469+
pushDiagnostic({
2470+
level: "warn",
2471+
pluginId: record.id,
2472+
source: record.source,
2473+
message: deprecatedHookDiagnostic,
2474+
});
2475+
}
24472476
}
24482477
let effectiveHandler = handler;
24492478
if (policy?.allowPromptInjection === false && isPromptInjectionHookName(effectiveHookName)) {

0 commit comments

Comments
 (0)