improve(anthropic): reduce session catalog startup memory (#119619)

* perf(anthropic): lazy-load session catalog runtime

Punchcard-Session: coral-workshop-workshop-3f

* fix(anthropic): keep session catalog helpers private

Punchcard-Session: coral-workshop-workshop-3f

* docs(plugin-sdk): document catalog create target

Punchcard-Session: coral-workshop-workshop-3f

* fix(plugins): continue catalog target selection

Punchcard-Session: coral-workshop-workshop-3f
This commit is contained in:
Vincent Koc
2026-08-06 00:28:56 +08:00
committed by GitHub
parent 89014305f1
commit eba4487b3a
14 changed files with 408 additions and 160 deletions
+11
View File
@@ -150,6 +150,15 @@ two-party event loops that do not go through the shared inbound reply runner.
// pass level to an embedded run
}
// Resolve a synchronous create target for a session catalog
const target = api.runtime.agent.resolveSessionCatalogCreateTarget({
config: api.runtime.config.current(),
requestedAgentId: agentId,
provider: "example",
modelIds: ["example-model"],
agentRuntime: "example-cli",
});
// Get agent timeout
const timeoutMs = api.runtime.agent.resolveAgentTimeoutMs(cfg);
@@ -176,6 +185,8 @@ two-party event loops that do not go through the shared inbound reply runner.
`normalizeThinkingLevel(...)` converts user text such as `on`, `x-high`, or `extra high` to the canonical stored level before checking it against the resolved policy.
`resolveSessionCatalogCreateTarget(...)` is the supported synchronous policy seam for trusted native plugins that implement `SessionCatalogProvider.resolveCreateSession`. It selects the first candidate model routed to the requested runtime and allowed for the requested or default agent. It returns `undefined` when no candidate satisfies both policies. Use this helper instead of importing or duplicating core model-selection policy in a plugin.
**Session store helpers** are under `api.runtime.agent.session`:
```typescript
+3
View File
@@ -99,6 +99,7 @@ describe("anthropic provider replay hooks", () => {
it("lets native session discovery be disabled without disabling Anthropic", () => {
const registerCliBackend = vi.fn();
const registerNodeHostCommand = vi.fn();
const registerNodeInvokePolicy = vi.fn();
const registerProvider = vi.fn();
const registerSessionCatalog = vi.fn();
anthropicPlugin.register(
@@ -110,12 +111,14 @@ describe("anthropic provider replay hooks", () => {
pluginConfig: { sessionCatalog: { enabled: false } },
registerCliBackend,
registerNodeHostCommand,
registerNodeInvokePolicy,
registerProvider,
registerSessionCatalog,
}),
);
expect(registerCliBackend).toHaveBeenCalledOnce();
expect(registerNodeInvokePolicy).toHaveBeenCalledOnce();
expect(registerProvider).toHaveBeenCalledOnce();
expect(registerNodeHostCommand).not.toHaveBeenCalled();
expect(registerSessionCatalog).not.toHaveBeenCalled();
+71
View File
@@ -0,0 +1,71 @@
import type {
OpenClawPluginNodeHostCommand,
OpenClawPluginNodeInvokePolicy,
} from "openclaw/plugin-sdk/plugin-entry";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/plugin-test-runtime";
import type { SessionCatalogProvider } from "openclaw/plugin-sdk/session-catalog";
import { afterEach, describe, expect, it, vi } from "vitest";
describe("anthropic session catalog lazy imports", () => {
afterEach(() => {
vi.doUnmock("./session-catalog.js");
vi.doUnmock("./session-catalog-node-commands.js");
vi.resetModules();
});
it("loads catalog and node handlers only on first use", async () => {
let catalogImports = 0;
let nodeCommandImports = 0;
vi.doMock("./session-catalog.js", () => {
catalogImports += 1;
return {
createClaudeSessionCatalogRuntime: () => ({
list: async () => [],
read: async () => ({ hostId: "gateway:local", label: "Local", threadId: "", items: [] }),
continueSession: async () => ({ sessionKey: "agent:main:test" }),
openTerminal: async () => ({ kind: "local", argv: ["claude"] }),
checkUpstreamActivity: async () => [],
}),
};
});
vi.doMock("./session-catalog-node-commands.js", () => {
nodeCommandImports += 1;
return {
listClaudeSessions: async () => "[]",
readClaudeSession: async () => "{}",
resumeClaudeSession: async () => "{}",
};
});
const { default: anthropicPlugin } = await import("./index.js");
const catalogs: SessionCatalogProvider[] = [];
const nodeCommands: OpenClawPluginNodeHostCommand[] = [];
const nodePolicies: OpenClawPluginNodeInvokePolicy[] = [];
anthropicPlugin.register(
createTestPluginApi({
id: "anthropic",
name: "Anthropic",
source: "test",
config: {},
runtime: createPluginRuntimeMock(),
registerSessionCatalog: (provider) => catalogs.push(provider),
registerNodeHostCommand: (command) => nodeCommands.push(command),
registerNodeInvokePolicy: (policy) => nodePolicies.push(policy),
}),
);
expect(catalogImports).toBe(0);
expect(nodeCommandImports).toBe(0);
expect(catalogs).toHaveLength(1);
expect(nodeCommands).toHaveLength(3);
expect(nodePolicies).toHaveLength(1);
await expect(catalogs[0]?.list({})).resolves.toEqual([]);
await expect(catalogs[0]?.list({})).resolves.toEqual([]);
await expect(nodeCommands[0]?.handle()).resolves.toBe("[]");
await expect(nodeCommands[0]?.handle()).resolves.toBe("[]");
expect(catalogImports).toBe(1);
expect(nodeCommandImports).toBe(1);
});
});
+4 -2
View File
@@ -66,8 +66,10 @@ import { acceptsAnthropicLiveModelContract } from "./live-model-contract-gate.js
import { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js";
import manifest from "./openclaw.plugin.json" with { type: "json" };
import { resolveClaudeCliSyntheticAuth } from "./provider-discovery.js";
import { createClaudeSessionNodeInvokePolicies } from "./session-catalog-node-commands.js";
import { registerClaudeSessionDiscovery } from "./session-catalog-registration.js";
import {
createClaudeSessionNodeInvokePolicies,
registerClaudeSessionDiscovery,
} from "./session-catalog-registration.js";
import { isAnthropicOAuthApiKey, wrapAnthropicProviderStream } from "./stream-wrappers.js";
import { fetchAnthropicUsage, resolveAnthropicUsageAuth } from "./usage.js";
@@ -1,41 +1,17 @@
import { statSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import {
decodeNodePtyResumeParams,
type OpenClawPluginNodeHostCommandIo,
runNodePtyCommand,
validateClaudeSessionId,
} from "openclaw/plugin-sdk/node-host";
import type {
OpenClawPluginNodeHostCommand,
OpenClawPluginNodeInvokePolicy,
} from "openclaw/plugin-sdk/plugin-entry";
import { isExactClaudeSessionCursor } from "./session-catalog-cursor.js";
import { resolveClaudeTerminalExecutable } from "./session-catalog-executable.js";
import {
CLAUDE_CLI_NODE_RUN_COMMAND,
CLAUDE_SESSION_READ_COMMAND,
CLAUDE_SESSIONS_LIST_COMMAND,
CLAUDE_TERMINAL_RESUME_COMMAND,
isResumableClaudeSource,
} from "./session-catalog-shared.js";
import { isResumableClaudeSource } from "./session-catalog-shared.js";
import type { ClaudeSessionCatalogSession } from "./session-catalog-types.js";
import { listLocalClaudeSessionPage, readLocalClaudeTranscriptPage } from "./session-catalog.js";
const CLAUDE_SESSIONS_CAPABILITY = "claude-sessions";
const CLAUDE_NODE_LOOKUP_PAGE_LIMIT = 100;
// Nodes advertise the catalog commands only when this machine has a Claude
// Code session store; without it the gateway skips the node entirely.
function claudeProjectsAvailable(env: NodeJS.ProcessEnv): boolean {
const homeDir = env.HOME?.trim() || env.USERPROFILE?.trim() || os.homedir();
try {
return statSync(path.join(homeDir, ".claude", "projects")).isDirectory();
} catch {
return false;
}
}
function parseNodeParams(paramsJSON?: string | null): unknown {
if (!paramsJSON) {
return undefined;
@@ -77,71 +53,38 @@ async function requireLocalResumableClaudeSession(
throw new Error("Claude session cannot be resumed in a terminal");
}
export function createClaudeSessionNodeHostCommands(): OpenClawPluginNodeHostCommand[] {
return [
{
command: CLAUDE_SESSIONS_LIST_COMMAND,
cap: CLAUDE_SESSIONS_CAPABILITY,
dangerous: false,
isAvailable: ({ env }) => claudeProjectsAvailable(env),
handle: async (paramsJSON) =>
JSON.stringify(await listLocalClaudeSessionPage(parseNodeParams(paramsJSON))),
},
{
command: CLAUDE_SESSION_READ_COMMAND,
cap: CLAUDE_SESSIONS_CAPABILITY,
dangerous: false,
isAvailable: ({ env }) => claudeProjectsAvailable(env),
handle: async (paramsJSON) =>
JSON.stringify(await readLocalClaudeTranscriptPage(parseNodeParams(paramsJSON))),
},
{
command: CLAUDE_TERMINAL_RESUME_COMMAND,
cap: CLAUDE_SESSIONS_CAPABILITY,
dangerous: false,
duplex: true,
isAvailable: ({ env }) =>
claudeProjectsAvailable(env) && Boolean(resolveClaudeTerminalExecutable(env)),
handle: async (paramsJSON, io) => {
if (!io) {
throw new Error("Claude terminal command requires duplex transport");
}
const params = decodeNodePtyResumeParams(paramsJSON, validateClaudeSessionId);
const record = await requireLocalResumableClaudeSession(params.threadId);
const resolution = resolveClaudeTerminalExecutable();
if (!resolution) {
throw new Error("Claude CLI is unavailable");
}
return JSON.stringify(
await runNodePtyCommand(
{
file: resolution.executable,
args: ["--resume", params.threadId],
cwd: record.cwd,
...(resolution.pathEnv ? { pathEnv: resolution.pathEnv } : {}),
cols: params.cols,
rows: params.rows,
},
io,
),
);
},
},
];
export async function listClaudeSessions(paramsJSON?: string | null): Promise<string> {
return JSON.stringify(await listLocalClaudeSessionPage(parseNodeParams(paramsJSON)));
}
export function createClaudeSessionNodeInvokePolicies(): OpenClawPluginNodeInvokePolicy[] {
return [
{
commands: [
CLAUDE_SESSIONS_LIST_COMMAND,
CLAUDE_SESSION_READ_COMMAND,
CLAUDE_CLI_NODE_RUN_COMMAND,
CLAUDE_TERMINAL_RESUME_COMMAND,
],
defaultPlatforms: ["macos", "linux", "windows"],
handle: (context) =>
context.command === CLAUDE_TERMINAL_RESUME_COMMAND ? { ok: true } : context.invokeNode(),
},
];
export async function readClaudeSession(paramsJSON?: string | null): Promise<string> {
return JSON.stringify(await readLocalClaudeTranscriptPage(parseNodeParams(paramsJSON)));
}
export async function resumeClaudeSession(
paramsJSON: string | null | undefined,
io: OpenClawPluginNodeHostCommandIo | undefined,
): Promise<string> {
if (!io) {
throw new Error("Claude terminal command requires duplex transport");
}
const params = decodeNodePtyResumeParams(paramsJSON, validateClaudeSessionId);
const record = await requireLocalResumableClaudeSession(params.threadId);
const resolution = resolveClaudeTerminalExecutable();
if (!resolution) {
throw new Error("Claude CLI is unavailable");
}
return JSON.stringify(
await runNodePtyCommand(
{
file: resolution.executable,
args: ["--resume", params.threadId],
cwd: record.cwd,
...(resolution.pathEnv ? { pathEnv: resolution.pathEnv } : {}),
cols: params.cols,
rows: params.rows,
},
io,
),
);
}
@@ -1,6 +1,31 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { createClaudeSessionNodeHostCommands } from "./session-catalog-node-commands.js";
import { registerClaudeSessionCatalog } from "./session-catalog.js";
import { statSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
createLazyRuntimeModule,
createLazyRuntimeSurface,
} from "openclaw/plugin-sdk/lazy-runtime";
import type {
OpenClawPluginApi,
OpenClawPluginNodeHostCommand,
OpenClawPluginNodeInvokePolicy,
} from "openclaw/plugin-sdk/plugin-entry";
import type { SessionCatalogProvider } from "openclaw/plugin-sdk/session-catalog";
import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_ROUTE_PROBE_MODEL_IDS } from "./cli-constants.js";
import { resolveClaudeTerminalExecutable } from "./session-catalog-executable.js";
import {
CLAUDE_CLI_NODE_RUN_COMMAND,
CLAUDE_SESSION_READ_COMMAND,
CLAUDE_SESSIONS_LIST_COMMAND,
CLAUDE_TERMINAL_RESUME_COMMAND,
} from "./session-catalog-shared.js";
const CLAUDE_SESSIONS_CAPABILITY = "claude-sessions";
const loadClaudeSessionNodeCommands = createLazyRuntimeModule(
() => import("./session-catalog-node-commands.js"),
);
function isClaudeSessionCatalogEnabled(pluginConfig: unknown): boolean {
if (!pluginConfig || typeof pluginConfig !== "object") {
@@ -14,6 +39,94 @@ function isClaudeSessionCatalogEnabled(pluginConfig: unknown): boolean {
);
}
// Node declarations expose catalog commands only when this machine owns a
// Claude session store; otherwise the gateway must skip the node capability.
function claudeProjectsAvailable(env: NodeJS.ProcessEnv): boolean {
const homeDir = env.HOME?.trim() || env.USERPROFILE?.trim() || os.homedir();
try {
return statSync(path.join(homeDir, ".claude", "projects")).isDirectory();
} catch {
return false;
}
}
function currentConfig(api: OpenClawPluginApi): OpenClawConfig {
return (api.runtime.config?.current?.() ?? api.config ?? {}) as OpenClawConfig;
}
function registerClaudeSessionCatalog(api: OpenClawPluginApi): void {
const loadCatalogRuntime = createLazyRuntimeSurface(
() => import("./session-catalog.js"),
(module) => module.createClaudeSessionCatalogRuntime(api),
);
const provider: SessionCatalogProvider = {
id: "claude",
label: "Claude Code",
resolveCreateSession: ({ agentId }) =>
api.runtime.agent.resolveSessionCatalogCreateTarget({
config: currentConfig(api),
requestedAgentId: agentId,
provider: "anthropic",
modelIds: CLAUDE_CLI_ROUTE_PROBE_MODEL_IDS,
agentRuntime: CLAUDE_CLI_BACKEND_ID,
}),
list: async (query) => await (await loadCatalogRuntime()).list(query),
read: async (request) => await (await loadCatalogRuntime()).read(request),
continueSession: async (request) => await (await loadCatalogRuntime()).continueSession(request),
openTerminal: async (request) => await (await loadCatalogRuntime()).openTerminal(request),
checkUpstreamActivity: async (probes) =>
await (await loadCatalogRuntime()).checkUpstreamActivity(probes),
};
api.registerSessionCatalog(provider);
}
function createClaudeSessionNodeHostCommands(): OpenClawPluginNodeHostCommand[] {
return [
{
command: CLAUDE_SESSIONS_LIST_COMMAND,
cap: CLAUDE_SESSIONS_CAPABILITY,
dangerous: false,
isAvailable: ({ env }) => claudeProjectsAvailable(env),
handle: async (paramsJSON) =>
await (await loadClaudeSessionNodeCommands()).listClaudeSessions(paramsJSON),
},
{
command: CLAUDE_SESSION_READ_COMMAND,
cap: CLAUDE_SESSIONS_CAPABILITY,
dangerous: false,
isAvailable: ({ env }) => claudeProjectsAvailable(env),
handle: async (paramsJSON) =>
await (await loadClaudeSessionNodeCommands()).readClaudeSession(paramsJSON),
},
{
command: CLAUDE_TERMINAL_RESUME_COMMAND,
cap: CLAUDE_SESSIONS_CAPABILITY,
dangerous: false,
duplex: true,
isAvailable: ({ env }) =>
claudeProjectsAvailable(env) && Boolean(resolveClaudeTerminalExecutable(env)),
handle: async (paramsJSON, io) =>
await (await loadClaudeSessionNodeCommands()).resumeClaudeSession(paramsJSON, io),
},
];
}
export function createClaudeSessionNodeInvokePolicies(): OpenClawPluginNodeInvokePolicy[] {
return [
{
commands: [
CLAUDE_SESSIONS_LIST_COMMAND,
CLAUDE_SESSION_READ_COMMAND,
CLAUDE_CLI_NODE_RUN_COMMAND,
CLAUDE_TERMINAL_RESUME_COMMAND,
],
defaultPlatforms: ["macos", "linux", "windows"],
handle: (context) =>
context.command === CLAUDE_TERMINAL_RESUME_COMMAND ? { ok: true } : context.invokeNode(),
},
];
}
export function registerClaudeSessionDiscovery(api: OpenClawPluginApi): void {
if (!isClaudeSessionCatalogEnabled(api.pluginConfig)) {
return;
@@ -1,8 +1,3 @@
import {
resolveAllowedModelRef,
resolveDefaultAgentId,
resolveDefaultModelForAgent,
} from "openclaw/plugin-sdk/agent-runtime";
import { resolveEffectiveAgentRuntime } from "openclaw/plugin-sdk/command-auth-native";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
@@ -89,31 +84,3 @@ export function resolveClaudeCliRoutedModelId(
}) === CLAUDE_CLI_BACKEND_ID,
);
}
export function resolveClaudeCatalogCreateSession(
api: OpenClawPluginApi,
requestedAgentId?: string,
): { model: string; agentRuntime: string } | undefined {
const config = currentClaudeSessionCatalogConfig(api);
const agentId = requestedAgentId ?? resolveDefaultAgentId(config);
const routedModelId = resolveClaudeCliRoutedModelId(config, agentId);
if (!routedModelId) {
return undefined;
}
const routedModelRef = `anthropic/${routedModelId}`;
const defaultModel = resolveDefaultModelForAgent({ cfg: config, agentId });
const allowed = resolveAllowedModelRef({
cfg: config,
catalog: [],
raw: routedModelRef,
defaultProvider: defaultModel.provider,
defaultModel: defaultModel.model,
agentId,
});
return "error" in allowed
? undefined
: {
model: routedModelRef,
agentRuntime: CLAUDE_CLI_BACKEND_ID,
};
}
+45 -24
View File
@@ -2,15 +2,19 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import type {
OpenClawPluginApi,
OpenClawPluginNodeHostCommand,
} from "openclaw/plugin-sdk/plugin-entry";
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/plugin-test-runtime";
import type { SessionCatalogProvider } from "openclaw/plugin-sdk/session-catalog";
import { afterEach, describe, expect, it, vi } from "vitest";
import { adoptedSourceKey } from "./session-catalog-adoption.js";
import {
createClaudeSessionNodeHostCommands,
createClaudeSessionNodeInvokePolicies,
} from "./session-catalog-node-commands.js";
registerClaudeSessionDiscovery,
} from "./session-catalog-registration.js";
import { listBoundClaudeSessions } from "./session-catalog-runtime.js";
import {
CLAUDE_CLI_NODE_RUN_COMMAND,
@@ -19,9 +23,29 @@ import {
CLAUDE_TERMINAL_RESUME_COMMAND,
listLocalClaudeSessionPage,
readLocalClaudeTranscriptPage,
registerClaudeSessionCatalog,
} from "./session-catalog.js";
function registerClaudeSessionCatalog(api: OpenClawPluginApi): void {
registerClaudeSessionDiscovery({
...api,
registerNodeHostCommand: api.registerNodeHostCommand ?? (() => {}),
});
}
function createClaudeSessionNodeHostCommands(): OpenClawPluginNodeHostCommand[] {
const commands: OpenClawPluginNodeHostCommand[] = [];
registerClaudeSessionDiscovery({
id: "anthropic",
config: {},
runtime: createPluginRuntimeMock(),
registerSessionCatalog: () => {},
registerNodeHostCommand: (command: OpenClawPluginNodeHostCommand) => {
commands.push(command);
},
} as unknown as OpenClawPluginApi);
return commands;
}
function captureCatalogProvider(runtime: PluginRuntime): SessionCatalogProvider {
let provider: SessionCatalogProvider | undefined;
const runtimeWithSession = {
@@ -509,29 +533,28 @@ describe("Claude session catalog", () => {
sessionId: "openclaw-adopted",
entry: { sessionId: "openclaw-adopted", updatedAt: Date.now() },
}));
const config = {
agents: {
defaults: {
models: {
"anthropic/claude-opus-4-8": { agentRuntime: { id: "claude-cli" } },
},
},
},
} satisfies OpenClawConfig;
let provider: SessionCatalogProvider | undefined;
const api = {
id: "anthropic",
config: {},
runtime: {
config: {
current: () => ({
agents: {
defaults: {
models: {
"anthropic/claude-opus-4-8": { agentRuntime: { id: "claude-cli" } },
},
},
},
}),
},
runtime: createPluginRuntimeMock({
config: { current: () => config },
agent: {
session: {
listSessionEntries: () => [],
createSessionEntry,
},
},
},
}),
registerSessionCatalog: (candidate: SessionCatalogProvider) => {
provider = candidate;
},
@@ -581,9 +604,7 @@ describe("Claude session catalog", () => {
const api = {
id: "anthropic",
config: {},
runtime: {
config: { current: () => config },
},
runtime: createPluginRuntimeMock({ config: { current: () => config } }),
registerSessionCatalog: (candidate: SessionCatalogProvider) => {
provider = candidate;
},
@@ -623,7 +644,7 @@ describe("Claude session catalog", () => {
const api = {
id: "anthropic",
config,
runtime: { config: { current: () => config } },
runtime: createPluginRuntimeMock({ config: { current: () => config } }),
registerSessionCatalog: (candidate: SessionCatalogProvider) => {
provider = candidate;
},
@@ -661,7 +682,7 @@ describe("Claude session catalog", () => {
const api = {
id: "anthropic",
config,
runtime: { config: { current: () => config } },
runtime: createPluginRuntimeMock({ config: { current: () => config } }),
registerSessionCatalog: (candidate: SessionCatalogProvider) => {
provider = candidate;
},
@@ -698,7 +719,7 @@ describe("Claude session catalog", () => {
const api = {
id: "anthropic",
config,
runtime: { config: { current: () => config } },
runtime: createPluginRuntimeMock({ config: { current: () => config } }),
registerSessionCatalog: (candidate: SessionCatalogProvider) => {
provider = candidate;
},
@@ -736,7 +757,7 @@ describe("Claude session catalog", () => {
const api = {
id: "anthropic",
config,
runtime: { config: { current: () => config } },
runtime: createPluginRuntimeMock({ config: { current: () => config } }),
registerSessionCatalog: (candidate: SessionCatalogProvider) => {
provider = candidate;
},
+11 -7
View File
@@ -26,7 +26,6 @@ import { createNodeListFailedError, resolveNodeLabel } from "./session-catalog-n
import {
currentClaudeSessionCatalogConfig,
listBoundClaudeSessions,
resolveClaudeCatalogCreateSession,
resolveClaudeCliRoutedModelId,
} from "./session-catalog-runtime.js";
import {
@@ -1904,11 +1903,17 @@ function toGenericClaudeHost(
};
}
export function registerClaudeSessionCatalog(api: OpenClawPluginApi): void {
const provider: SessionCatalogProvider = {
id: "claude",
label: "Claude Code",
resolveCreateSession: ({ agentId }) => resolveClaudeCatalogCreateSession(api, agentId),
type ClaudeSessionCatalogRuntime = Required<
Pick<
SessionCatalogProvider,
"list" | "read" | "continueSession" | "openTerminal" | "checkUpstreamActivity"
>
>;
export function createClaudeSessionCatalogRuntime(
api: OpenClawPluginApi,
): ClaudeSessionCatalogRuntime {
return {
list: async (query) => {
const adopted = listBoundClaudeSessions(api, query.sessionEntries);
const localCliAvailable = catalogTerminal.isClaudeCliAvailable();
@@ -1954,6 +1959,5 @@ export function registerClaudeSessionCatalog(api: OpenClawPluginApi): void {
).items;
}),
};
api.registerSessionCatalog(provider);
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
@@ -11,6 +11,7 @@ import {
import { createChannelReplyPipeline } from "../../channels/message/reply-pipeline.js";
import { resolveSessionEntryResetFreshness } from "../../config/sessions/entry-freshness.js";
import { createChannelRuntimeContextRegistry } from "../../plugins/runtime/channel-runtime-contexts.js";
import { resolveSessionCatalogCreateTarget } from "../../plugins/runtime/runtime-agent-session-catalog.js";
import type { PluginRuntime } from "../../plugins/runtime/types.js";
import {
implicitMentionKindWhen,
@@ -527,6 +528,9 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
resolveAgentIdentity: vi.fn(() => ({
name: "test-agent",
})) as unknown as PluginRuntime["agent"]["resolveAgentIdentity"],
resolveSessionCatalogCreateTarget: vi.fn(
resolveSessionCatalogCreateTarget,
) as unknown as PluginRuntime["agent"]["resolveSessionCatalogCreateTarget"],
resolveThinkingDefault: vi.fn(
() => "off",
) as unknown as PluginRuntime["agent"]["resolveThinkingDefault"],
@@ -0,0 +1,52 @@
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import {
resolveAllowedModelRef,
resolveDefaultModelForAgent,
} from "../../agents/model-selection.js";
import { resolveEffectiveAgentRuntime } from "../../agents/thinking-runtime.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { SessionCatalogCreateTarget } from "../session-catalog.js";
type RuntimeSessionCatalogCreateTargetParams = {
config: OpenClawConfig;
requestedAgentId?: string;
provider: string;
modelIds: readonly string[];
agentRuntime: string;
};
/**
* Resolve a synchronous catalog create target through the same model/runtime
* policy used by agent turns, without making plugins import that policy graph.
*/
export function resolveSessionCatalogCreateTarget(
params: RuntimeSessionCatalogCreateTargetParams,
): SessionCatalogCreateTarget | undefined {
const agentId = params.requestedAgentId ?? resolveDefaultAgentId(params.config);
const defaultModel = resolveDefaultModelForAgent({ cfg: params.config, agentId });
for (const modelId of params.modelIds) {
if (
resolveEffectiveAgentRuntime({
cfg: params.config,
provider: params.provider,
modelId,
agentId,
}) !== params.agentRuntime
) {
continue;
}
const model = `${params.provider}/${modelId}`;
const allowed = resolveAllowedModelRef({
cfg: params.config,
catalog: [],
raw: model,
defaultProvider: defaultModel.provider,
defaultModel: defaultModel.model,
agentId,
});
if (!("error" in allowed)) {
return { model, agentRuntime: params.agentRuntime };
}
}
return undefined;
}
+53
View File
@@ -21,6 +21,59 @@ function createDeferred(): { promise: Promise<void>; resolve: () => void } {
}
describe("plugin runtime session creation", () => {
it("resolves synchronous session catalog targets through agent model policy", () => {
const runtime = createRuntimeAgent();
const config = {
agents: {
defaults: {
models: {
"anthropic/claude-opus-4-8": { agentRuntime: { id: "claude-cli" } },
},
},
},
};
expect(
runtime.resolveSessionCatalogCreateTarget({
config,
provider: "anthropic",
modelIds: ["claude-opus-5", "claude-opus-4-8"],
agentRuntime: "claude-cli",
}),
).toEqual({
model: "anthropic/claude-opus-4-8",
agentRuntime: "claude-cli",
});
});
it("skips routed catalog targets denied by agent model policy", () => {
const runtime = createRuntimeAgent();
const config = {
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-8" },
models: {
"anthropic/claude-opus-5": { agentRuntime: { id: "claude-cli" } },
"anthropic/claude-opus-4-8": { agentRuntime: { id: "claude-cli" } },
},
modelPolicy: { allow: ["anthropic/claude-opus-4-8"] },
},
},
};
expect(
runtime.resolveSessionCatalogCreateTarget({
config,
provider: "anthropic",
modelIds: ["claude-opus-5", "claude-opus-4-8"],
agentRuntime: "claude-cli",
}),
).toEqual({
model: "anthropic/claude-opus-4-8",
agentRuntime: "claude-cli",
});
});
it("requires recovery initialization to return the final trusted patch", () => {
type CreateSessionParams = Parameters<
ReturnType<typeof createRuntimeAgent>["session"]["createSessionEntry"]
+2
View File
@@ -39,6 +39,7 @@ import {
runExclusiveSessionLifecycleMutation,
} from "../../sessions/session-lifecycle-admission.js";
import { createLazyRuntimeMethod, createLazyRuntimeModule } from "../../shared/lazy-runtime.js";
import { resolveSessionCatalogCreateTarget } from "./runtime-agent-session-catalog.js";
import { resolveRuntimeThinkingCatalog } from "./runtime-agent-thinking.js";
import { defineCachedValue } from "./runtime-cache.js";
import type { PluginRuntime } from "./types.js";
@@ -580,6 +581,7 @@ export function createRuntimeAgent(): PluginRuntime["agent"] {
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveAgentIdentity,
resolveSessionCatalogCreateTarget,
resolveThinkingDefault,
normalizeThinkingLevel: normalizeThinkLevel,
resolveThinkingPolicy: (params) => {
+2
View File
@@ -327,6 +327,8 @@ export type PluginRuntimeCore = {
resolveAgentDir: typeof import("../../agents/agent-scope.js").resolveAgentDir;
resolveAgentWorkspaceDir: typeof import("../../agents/agent-scope.js").resolveAgentWorkspaceDir;
resolveAgentIdentity: typeof import("../../agents/identity.js").resolveAgentIdentity;
/** Resolve an allowed catalog create target through canonical agent model/runtime policy. */
resolveSessionCatalogCreateTarget: typeof import("./runtime-agent-session-catalog.js").resolveSessionCatalogCreateTarget;
resolveThinkingDefault: (params: {
cfg: import("../../config/types.openclaw.js").OpenClawConfig;
provider: string;