diff --git a/extensions/acpx/src/pi-session-catalog-plugin.ts b/extensions/acpx/src/pi-session-catalog-plugin.ts index 17fd974b36c5..214e7c30b361 100644 --- a/extensions/acpx/src/pi-session-catalog-plugin.ts +++ b/extensions/acpx/src/pi-session-catalog-plugin.ts @@ -382,7 +382,7 @@ async function listPiHosts( } let nodes: Awaited>["nodes"]; try { - nodes = (await runtime.nodes.list()).nodes; + nodes = (await (query.listNodes?.() ?? runtime.nodes.list())).nodes; } catch { return hosts; } diff --git a/extensions/acpx/src/pi-session-catalog.test.ts b/extensions/acpx/src/pi-session-catalog.test.ts index 0e3c86192c8a..bf77ad5e9e43 100644 --- a/extensions/acpx/src/pi-session-catalog.test.ts +++ b/extensions/acpx/src/pi-session-catalog.test.ts @@ -745,19 +745,20 @@ describe("Pi session catalog", () => { }), }; const invoke = vi.fn().mockResolvedValue(page); + const nodes = [ + { + nodeId: "node-1", + connected: true, + commands: [PI_SESSIONS_LIST_COMMAND, PI_TERMINAL_RESUME_COMMAND], + }, + ]; + const runtimeListNodes = vi.fn().mockResolvedValue({ nodes }); + const requestListNodes = vi.fn().mockResolvedValue({ nodes }); registerPiSessionCatalog({ pluginConfig: {}, runtime: { nodes: { - list: vi.fn().mockResolvedValue({ - nodes: [ - { - nodeId: "node-1", - connected: true, - commands: [PI_SESSIONS_LIST_COMMAND, PI_TERMINAL_RESUME_COMMAND], - }, - ], - }), + list: runtimeListNodes, invoke, }, }, @@ -768,7 +769,13 @@ describe("Pi session catalog", () => { registerNodeInvokePolicy: vi.fn(), } as unknown as OpenClawPluginApi); - await expect(provider!.list({ hostIds: ["node:node-1"], search: "remote" })).resolves.toEqual([ + await expect( + provider!.list({ + hostIds: ["node:node-1"], + search: "remote", + listNodes: requestListNodes, + }), + ).resolves.toEqual([ expect.objectContaining({ sessions: [ expect.objectContaining({ @@ -779,6 +786,8 @@ describe("Pi session catalog", () => { ], }), ]); + expect(requestListNodes).toHaveBeenCalledOnce(); + expect(runtimeListNodes).not.toHaveBeenCalled(); expect(invoke).toHaveBeenNthCalledWith(1, { nodeId: "node-1", command: PI_SESSIONS_LIST_COMMAND, diff --git a/extensions/anthropic/session-catalog.test.ts b/extensions/anthropic/session-catalog.test.ts index 685d466fbead..d81329ef8fd8 100644 --- a/extensions/anthropic/session-catalog.test.ts +++ b/extensions/anthropic/session-catalog.test.ts @@ -2261,20 +2261,33 @@ describe("Claude session catalog", () => { await openGate; return await originalOpen(...args); }); - const listNodes = vi.fn(async () => ({ nodes: [] })); + const runtimeListNodes = vi.fn(async () => ({ nodes: [] })); + const requestListNodes = vi.fn(async () => ({ nodes: [] })); const provider = captureCatalogProvider({ - nodes: { list: listNodes }, + nodes: { list: runtimeListNodes }, } as unknown as PluginRuntime); - const listing = provider.list({}); + const listing = provider.list({ listNodes: requestListNodes }); await opened; - expect(listNodes).toHaveBeenCalledOnce(); + expect(requestListNodes).toHaveBeenCalledOnce(); + expect(runtimeListNodes).not.toHaveBeenCalled(); releaseOpen(); await expect(listing).resolves.toMatchObject([ { hostId: "gateway:local", sessions: [expect.objectContaining({ threadId: sessionId })] }, ]); }); + it("falls back to the plugin node runtime without a request snapshot", async () => { + const runtimeListNodes = vi.fn(async () => ({ nodes: [] })); + const provider = captureCatalogProvider({ + nodes: { list: runtimeListNodes }, + } as unknown as PluginRuntime); + + await expect(provider.list({ hostIds: ["node:missing"] })).resolves.toEqual([]); + + expect(runtimeListNodes).toHaveBeenCalledOnce(); + }); + it("keeps the underlying paired-node list failure", async () => { const runtime = { nodes: { diff --git a/extensions/anthropic/session-catalog.ts b/extensions/anthropic/session-catalog.ts index ec0baa001b0a..5d58c0c78f14 100644 --- a/extensions/anthropic/session-catalog.ts +++ b/extensions/anthropic/session-catalog.ts @@ -1116,6 +1116,7 @@ function parseGatewayQuery(value: unknown): { async function listClaudeSessionCatalog(params: { runtime: PluginRuntime; query?: unknown; + listNodes?: Parameters[0]["listNodes"]; onHost?: (host: ClaudeSessionCatalogHost) => void; }): Promise { const query = parseGatewayQuery(params.query); @@ -1165,7 +1166,7 @@ async function listClaudeSessionCatalog(params: { } let nodes: Awaited>["nodes"]; try { - nodes = (await params.runtime.nodes.list()).nodes; + nodes = (await (params.listNodes?.() ?? params.runtime.nodes.list())).nodes; } catch (error) { const registryHost: ClaudeSessionCatalogHost = { hostId: "node:registry", @@ -1598,12 +1599,13 @@ export function registerClaudeSessionCatalog(api: OpenClawPluginApi): void { list: async (query) => { const adopted = listBoundClaudeSessions(api, query.sessionEntries); const localCliAvailable = catalogTerminal.isClaudeCliAvailable(); - const { onHost, sessionEntries: _sessionEntries, ...gatewayQuery } = query; + const { listNodes, onHost, sessionEntries: _sessionEntries, ...gatewayQuery } = query; const mapHost = (host: ClaudeSessionCatalogHost) => toGenericClaudeHost(host, adopted, localCliAvailable); const result = await listClaudeSessionCatalog({ runtime: api.runtime, query: gatewayQuery, + listNodes, ...(onHost ? { onHost: (host) => onHost(mapHost(host)) } : {}), }); return result.hosts.map(mapHost); diff --git a/extensions/codex/src/session-catalog.test.ts b/extensions/codex/src/session-catalog.test.ts index bcabf95d1212..2227753c3dbf 100644 --- a/extensions/codex/src/session-catalog.test.ts +++ b/extensions/codex/src/session-catalog.test.ts @@ -1388,6 +1388,29 @@ describe("Codex supervision catalog", () => { expect(runtime.nodes.list).not.toHaveBeenCalled(); }); + it("prefers the request node snapshot and retains the plugin runtime fallback", async () => { + const control = createControl(); + const { runtime } = createRuntime(); + const { api, getProvider } = createGatewayApi(runtime); + registerCodexSessionCatalog({ + api, + bindingStore: createCodexTestBindingStore(), + control, + getRuntimeConfig: () => config, + }); + const provider = getProvider(); + const requestListNodes = vi.fn(async () => ({ nodes: [] })); + + await expect( + provider?.list({ hostIds: ["node:missing"], listNodes: requestListNodes }), + ).resolves.toEqual([]); + expect(requestListNodes).toHaveBeenCalledOnce(); + expect(runtime.nodes.list).not.toHaveBeenCalled(); + + await expect(provider?.list({ hostIds: ["node:missing"] })).resolves.toEqual([]); + expect(runtime.nodes.list).toHaveBeenCalledOnce(); + }); + it("enriches only the local source row with its adopted OpenClaw session", async () => { const control = createControl({ listPage: vi.fn(async () => ({ diff --git a/extensions/codex/src/session-catalog.ts b/extensions/codex/src/session-catalog.ts index 399e342ba2ee..e34532e39084 100644 --- a/extensions/codex/src/session-catalog.ts +++ b/extensions/codex/src/session-catalog.ts @@ -422,6 +422,7 @@ async function listCodexSessionCatalog(params: { runtime: PluginRuntime; control: CodexSessionCatalogControl; query?: CodexSessionCatalogParams; + listNodes?: Parameters[0]["listNodes"]; onHost?: (host: CodexSessionCatalogHost) => void; sessionEntries?: SessionCatalogEntrySnapshot; }): Promise { @@ -452,7 +453,7 @@ async function listCodexSessionCatalog(params: { } let nodes: CatalogNode[]; try { - nodes = (await params.runtime.nodes.list()).nodes + nodes = (await (params.listNodes?.() ?? params.runtime.nodes.list())).nodes .filter( (node) => node.commands?.includes(CODEX_APP_SERVER_THREADS_LIST_COMMAND) && @@ -1273,7 +1274,7 @@ function registerCodexSessionCatalog(params: { ), list: async (query) => { const localTerminalAvailable = resolveLocalCodexTerminalExecutable() !== undefined; - const { onHost, sessionEntries, ...gatewayQuery } = query; + const { listNodes, onHost, sessionEntries, ...gatewayQuery } = query; const mapHost = (host: CodexSessionCatalogHost) => toGenericCatalogHost(host, localTerminalAvailable); return ( @@ -1283,6 +1284,7 @@ function registerCodexSessionCatalog(params: { runtime: params.api.runtime, control: params.control, query: gatewayQuery, + listNodes, sessionEntries, ...(onHost ? { onHost: (host) => onHost(mapHost(host)) } : {}), }) diff --git a/extensions/opencode/session-catalog-plugin.ts b/extensions/opencode/session-catalog-plugin.ts index da155bc3a7a5..8b9f70846b51 100644 --- a/extensions/opencode/session-catalog-plugin.ts +++ b/extensions/opencode/session-catalog-plugin.ts @@ -374,7 +374,7 @@ async function listOpenCodeHosts( } let nodes: Awaited>["nodes"]; try { - nodes = (await runtime.nodes.list()).nodes; + nodes = (await (query.listNodes?.() ?? runtime.nodes.list())).nodes; } catch { return hosts; } diff --git a/extensions/opencode/session-catalog.test.ts b/extensions/opencode/session-catalog.test.ts index c0b446ac4e8c..6f8aa73a2a09 100644 --- a/extensions/opencode/session-catalog.test.ts +++ b/extensions/opencode/session-catalog.test.ts @@ -688,19 +688,20 @@ describe("OpenCode session catalog", () => { }), }; const invoke = vi.fn().mockResolvedValue(page); + const nodes = [ + { + nodeId: "node-1", + connected: true, + commands: [OPENCODE_SESSIONS_LIST_COMMAND, OPENCODE_TERMINAL_RESUME_COMMAND], + }, + ]; + const runtimeListNodes = vi.fn().mockResolvedValue({ nodes }); + const requestListNodes = vi.fn().mockResolvedValue({ nodes }); registerOpenCodeSessionCatalog({ pluginConfig: {}, runtime: { nodes: { - list: vi.fn().mockResolvedValue({ - nodes: [ - { - nodeId: "node-1", - connected: true, - commands: [OPENCODE_SESSIONS_LIST_COMMAND, OPENCODE_TERMINAL_RESUME_COMMAND], - }, - ], - }), + list: runtimeListNodes, invoke, }, }, @@ -711,7 +712,13 @@ describe("OpenCode session catalog", () => { registerNodeInvokePolicy: vi.fn(), } as unknown as OpenClawPluginApi); - await expect(provider!.list({ hostIds: ["node:node-1"], search: "remote" })).resolves.toEqual([ + await expect( + provider!.list({ + hostIds: ["node:node-1"], + search: "remote", + listNodes: requestListNodes, + }), + ).resolves.toEqual([ expect.objectContaining({ sessions: [ expect.objectContaining({ @@ -722,6 +729,8 @@ describe("OpenCode session catalog", () => { ], }), ]); + expect(requestListNodes).toHaveBeenCalledOnce(); + expect(runtimeListNodes).not.toHaveBeenCalled(); expect(invoke).toHaveBeenNthCalledWith(1, { nodeId: "node-1", command: OPENCODE_SESSIONS_LIST_COMMAND, diff --git a/src/gateway/server-methods/session-catalog.test.ts b/src/gateway/server-methods/session-catalog.test.ts index e3b040da4537..6c0e6be6868e 100644 --- a/src/gateway/server-methods/session-catalog.test.ts +++ b/src/gateway/server-methods/session-catalog.test.ts @@ -350,6 +350,72 @@ describe("session catalog Gateway methods", () => { }); }); + it("shares one lazy Gateway node snapshot across catalog providers", async () => { + const previousNodesRuntime = gatewaySubagentState.nodes; + const dispatchNodeList = vi.fn(async () => ({ + nodes: [{ nodeId: "shared-node", connected: true }], + })); + gatewaySubagentState.nodes = { + list: dispatchNodeList, + invoke: vi.fn(async () => undefined), + }; + try { + const catalogUsingNodes = (id: string) => + provider(id, { + list: vi.fn(async ({ listNodes }) => { + expect(await listNodes?.()).toEqual({ + nodes: [{ nodeId: "shared-node", connected: true }], + }); + return []; + }), + }); + hoisted.activeRegistry.sessionCatalogs = [ + { provider: catalogUsingNodes("zeta") }, + { provider: catalogUsingNodes("alpha") }, + ]; + + await call("sessions.catalog.list", {}); + + expect(dispatchNodeList).toHaveBeenCalledOnce(); + } finally { + gatewaySubagentState.nodes = previousNodesRuntime; + } + }); + + it("keeps catalog-filtered Gateway node snapshots lazy", async () => { + const previousNodesRuntime = gatewaySubagentState.nodes; + const dispatchNodeList = vi.fn(async () => ({ nodes: [] })); + gatewaySubagentState.nodes = { + list: dispatchNodeList, + invoke: vi.fn(async () => undefined), + }; + try { + const selectedList = vi.fn(async () => []); + hoisted.activeRegistry.sessionCatalogs = [ + { + provider: provider("selected", { list: selectedList }), + }, + { + provider: provider("unselected", { + list: vi.fn(async ({ listNodes }) => { + await listNodes?.(); + return []; + }), + }), + }, + ]; + + await call("sessions.catalog.list", { catalogId: "selected" }); + + expect(selectedList).toHaveBeenCalledWith( + expect.objectContaining({ listNodes: expect.any(Function) }), + ); + expect(dispatchNodeList).not.toHaveBeenCalled(); + } finally { + gatewaySubagentState.nodes = previousNodesRuntime; + } + }); + it("uses the pinned Gateway catalog runtime after active registry churn", async () => { const previousNodesRuntime = gatewaySubagentState.nodes; const listNodes = vi.fn(async () => ({ nodes: [] })); diff --git a/src/gateway/server-methods/session-catalog.ts b/src/gateway/server-methods/session-catalog.ts index 99ccf6c80810..11129244d08c 100644 --- a/src/gateway/server-methods/session-catalog.ts +++ b/src/gateway/server-methods/session-catalog.ts @@ -14,8 +14,10 @@ import { validateSessionsCatalogReadParams, } from "../../../packages/gateway-protocol/src/index.js"; import { getActivePluginSessionExtensionRegistry } from "../../plugins/runtime.js"; +import { gatewaySubagentState } from "../../plugins/runtime/gateway-bindings.js"; import type { SessionCatalogCreateTarget, + SessionCatalogListProviderParams, SessionCatalogProvider, } from "../../plugins/session-catalog.js"; import { bindPluginSessionConversation } from "../../plugins/session-conversation-binding.js"; @@ -29,6 +31,20 @@ import { assertValidParams } from "./validation.js"; const SESSION_CATALOG_SEARCH_MAX_UTF16_UNITS = 500; +function createSessionCatalogRequestNodeSnapshot(): NonNullable< + SessionCatalogListProviderParams["listNodes"] +> { + let request: ReturnType> | undefined; + return () => { + // Every provider sees the same promise so one catalog request cannot multiply the + // pairing-store scans performed by the Gateway node.list runtime. + request ??= + gatewaySubagentState.nodes?.list() ?? + Promise.reject(new Error("Plugin node runtime is only available inside the Gateway.")); + return request; + }; +} + function normalizeSessionCatalogSearch(search: string | undefined): string | undefined { const normalized = normalizeOptionalString(search); return normalized @@ -204,6 +220,7 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = { cfg: config, fallbackAgentId: resolvedAgent.agentId, }); + const listNodes = createSessionCatalogRequestNodeSnapshot(); const catalogList = await Promise.all( selected.map(async (provider): Promise => { const createTarget = resolveProviderCreateTarget(provider, resolvedAgent.agentId); @@ -236,6 +253,7 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = { hostIds: request.hostIds, ...(request.cursors !== undefined ? { cursors: request.cursors } : {}), sessionEntries: requestEntries.sessionEntries, + listNodes, ...(onHost ? { onHost } : {}), }); return catalogResult( diff --git a/src/plugins/session-catalog.ts b/src/plugins/session-catalog.ts index 755d08c67f50..a91aeb2ca9ad 100644 --- a/src/plugins/session-catalog.ts +++ b/src/plugins/session-catalog.ts @@ -18,6 +18,8 @@ export type SessionCatalogListProviderParams = { cursors?: Record; /** Request-owned session entries. Providers must not retain this past `list`. */ sessionEntries?: SessionCatalogEntrySnapshot; + /** Lazily lists Gateway nodes once per catalog request. Providers must not retain this past `list`. */ + listNodes?: () => ReturnType; /** Publishes completed hosts without waiting for slower machines in the same list. */ onHost?: (host: SessionCatalogHost) => void; };