mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
df2b83f7ae
* refactor(anthropic): split session-catalog into concept modules Follow the #124178 sibling seam pattern while preserving behavior. Remove the max-lines suppression and baseline entry after the split. * refactor(anthropic): own record caches in session-catalog-discovery The record-shaped caches (catalogDiscoveryCache, claudeSessionScanCache, their entry types, bounds, and TTLs) are consumed only by discovery; moving them out of scan removes the scan->discovery type-import edge flagged by the madge topology gate and leaves scan as record-agnostic fs primitives.
173 lines
7.2 KiB
TypeScript
173 lines
7.2 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
|
import { createSessionCatalogAdoptionCoordinator } from "openclaw/plugin-sdk/session-catalog";
|
|
import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_DEFAULT_MODEL_REF } from "./cli-constants.js";
|
|
import {
|
|
adoptedSessionKey,
|
|
adoptedSourceKey,
|
|
CLAUDE_LOCAL_SESSION_HOST_ID,
|
|
} from "./session-catalog-adoption.js";
|
|
import { type CatalogRecord, listClaudeSessions } from "./session-catalog-discovery.js";
|
|
import { importClaudeHistory } from "./session-catalog-history.js";
|
|
import {
|
|
readBoundedClaudeHistory,
|
|
readClaudeSessionTranscript,
|
|
resolveNodeClaudeRecord,
|
|
} from "./session-catalog-listing.js";
|
|
import {
|
|
currentClaudeSessionCatalogConfig,
|
|
listBoundClaudeSessions,
|
|
resolveClaudeCliRoutedModelId,
|
|
} from "./session-catalog-runtime.js";
|
|
import { currentHomeDir, gatewayClaudeScanOptions } from "./session-catalog-scan.js";
|
|
import {
|
|
CLAUDE_CLI_NODE_RUN_COMMAND,
|
|
CLAUDE_SESSION_READ_COMMAND,
|
|
CLAUDE_SESSIONS_LIST_COMMAND,
|
|
ClaudeCatalogParamsError,
|
|
isResumableClaudeSource,
|
|
} from "./session-catalog-shared.js";
|
|
import type { ClaudeTranscriptItem } from "./session-catalog-transcript.js";
|
|
import type { ClaudeSessionCatalogSession } from "./session-catalog-types.js";
|
|
import * as upstream from "./session-upstream-activity.js";
|
|
|
|
const continueClaudeAdoption =
|
|
createSessionCatalogAdoptionCoordinator<Awaited<ReturnType<typeof upstream.linkContinued>>>();
|
|
|
|
export async function continueClaudeSession(
|
|
api: OpenClawPluginApi,
|
|
agentId: string,
|
|
hostId: string,
|
|
threadId: string,
|
|
allowProcessHomeFallback?: boolean,
|
|
): Promise<{ sessionKey: string }> {
|
|
const scanOptions = gatewayClaudeScanOptions(allowProcessHomeFallback);
|
|
const sourceKey = adoptedSourceKey(hostId, threadId);
|
|
const operationKey = `${agentId}\0${sourceKey}`;
|
|
const linkSession = async (sessionKey: string, history?: ClaudeTranscriptItem[]) =>
|
|
await upstream.linkContinued({
|
|
sessionKey,
|
|
hostId,
|
|
threadId,
|
|
...(history ? { history } : {}),
|
|
listLocalSessions: () => listClaudeSessions(currentHomeDir(), scanOptions),
|
|
readRemote: async () =>
|
|
(
|
|
await readClaudeSessionTranscript({
|
|
runtime: api.runtime,
|
|
hostId,
|
|
threadId,
|
|
limit: 1,
|
|
allowProcessHomeFallback,
|
|
})
|
|
).items,
|
|
});
|
|
const existing = listBoundClaudeSessions(api, agentId).get(sourceKey);
|
|
if (existing) {
|
|
return await linkSession(existing);
|
|
}
|
|
let history: ClaudeTranscriptItem[] | undefined;
|
|
return await continueClaudeAdoption({
|
|
sourceKey: operationKey,
|
|
findExisting: () => listBoundClaudeSessions(api, agentId).get(sourceKey),
|
|
create: async () => {
|
|
let nodeId: string | undefined;
|
|
let record: ClaudeSessionCatalogSession | undefined;
|
|
if (hostId === CLAUDE_LOCAL_SESSION_HOST_ID) {
|
|
record = (await listClaudeSessions(currentHomeDir(), scanOptions)).find(
|
|
(candidate) => candidate.threadId === threadId,
|
|
);
|
|
if (!record || !isResumableClaudeSource(record.source)) {
|
|
throw new ClaudeCatalogParamsError("only local Claude Code sessions can be continued");
|
|
}
|
|
} else if (hostId.startsWith("node:")) {
|
|
nodeId = hostId.slice("node:".length);
|
|
const node = (await api.runtime.nodes.list()).nodes.find(
|
|
(candidate) =>
|
|
candidate.nodeId === nodeId &&
|
|
candidate.connected === true &&
|
|
candidate.commands?.includes(CLAUDE_SESSIONS_LIST_COMMAND) &&
|
|
candidate.commands.includes(CLAUDE_SESSION_READ_COMMAND) &&
|
|
candidate.commands.includes(CLAUDE_CLI_NODE_RUN_COMMAND) &&
|
|
candidate.invocableCommands?.includes(CLAUDE_SESSIONS_LIST_COMMAND) === true &&
|
|
candidate.invocableCommands.includes(CLAUDE_SESSION_READ_COMMAND) &&
|
|
candidate.invocableCommands.includes(CLAUDE_CLI_NODE_RUN_COMMAND),
|
|
);
|
|
if (!node) {
|
|
throw new ClaudeCatalogParamsError(
|
|
"paired node does not permit Claude CLI session continuation",
|
|
);
|
|
}
|
|
// Node rows stay CLI-only: desktop transcripts on nodes have no
|
|
// node-side run command and remain view-only.
|
|
record = await resolveNodeClaudeRecord({ runtime: api.runtime, nodeId, threadId });
|
|
if (!record || record.source !== "claude-cli") {
|
|
throw new ClaudeCatalogParamsError("only Claude CLI sessions can be continued");
|
|
}
|
|
} else {
|
|
throw new ClaudeCatalogParamsError("hostId is invalid");
|
|
}
|
|
if (hostId === CLAUDE_LOCAL_SESSION_HOST_ID) {
|
|
const source = await fs.stat((record as CatalogRecord).filePath).catch(() => undefined);
|
|
if (!source?.isFile()) {
|
|
throw new ClaudeCatalogParamsError("Claude session transcript is unavailable");
|
|
}
|
|
}
|
|
// Narrowed local: afterCreate below needs a definite array, while the outer
|
|
// `history` stays optional so complete() can link without a reread on races.
|
|
const loadedHistory = await readBoundedClaudeHistory({
|
|
runtime: api.runtime,
|
|
hostId,
|
|
threadId,
|
|
allowProcessHomeFallback,
|
|
});
|
|
history = loadedHistory;
|
|
const config = currentClaudeSessionCatalogConfig(api);
|
|
const adoptingAgentId = agentId;
|
|
// Adopt onto the model this agent actually routes to the CLI backend; the
|
|
// packaged default may not be routed or allowed in an existing config.
|
|
const model =
|
|
resolveClaudeCliRoutedModelId(config, adoptingAgentId) ??
|
|
CLAUDE_CLI_DEFAULT_MODEL_REF.slice(`${CLAUDE_CLI_BACKEND_ID}/`.length);
|
|
const marker = {
|
|
sourceThreadId: threadId,
|
|
...(hostId !== CLAUDE_LOCAL_SESSION_HOST_ID ? { sourceHostId: hostId } : {}),
|
|
};
|
|
const created = await api.runtime.agent.session.createSessionEntry({
|
|
cfg: config,
|
|
key: adoptedSessionKey(hostId, threadId),
|
|
agentId: adoptingAgentId,
|
|
recoverMatchingInitialEntry: true,
|
|
...(record.name ? { label: record.name } : {}),
|
|
...(record.cwd ? { spawnedCwd: record.cwd } : {}),
|
|
...(nodeId ? { execNode: nodeId, ...(record.cwd ? { execCwd: record.cwd } : {}) } : {}),
|
|
initialEntry: {
|
|
cliBackendId: CLAUDE_CLI_BACKEND_ID,
|
|
model,
|
|
modelSelectionLocked: true,
|
|
pluginOwnerId: api.id,
|
|
cliSessionBinding: { sessionId: threadId, forceReuse: true, forkNextResume: true },
|
|
pluginExtensions: { anthropic: { sessionCatalog: marker } },
|
|
},
|
|
afterCreate: async (entry) => {
|
|
await importClaudeHistory({
|
|
items: loadedHistory,
|
|
threadId,
|
|
sessionId: entry.sessionId,
|
|
sessionKey: entry.key,
|
|
agentId: entry.agentId,
|
|
storePath: api.runtime.agent.session.resolveStorePath(config.session?.store, {
|
|
agentId: entry.agentId,
|
|
}),
|
|
...(record.cwd ? { cwd: record.cwd } : {}),
|
|
config,
|
|
});
|
|
return { pluginExtensions: { anthropic: { sessionCatalog: marker } } };
|
|
},
|
|
});
|
|
return { sessionKey: created.key };
|
|
},
|
|
complete: async (continued) => await linkSession(continued.sessionKey, history),
|
|
});
|
|
}
|