@gguf/claw
Version:
Multi-channel AI gateway with extensible messaging integrations
1,297 lines (1,282 loc) • 267 kB
JavaScript
import { d as resolveAgentIdFromSessionKey, i as buildAgentMainSessionKey, l as normalizeAgentId, n as DEFAULT_AGENT_ID, u as normalizeMainKey } from "./session-key-9yEYlIQe.js";
import { D as normalizeE164, R as truncateUtf16Safe, j as resolveUserPath, st as resolvePreferredOpenClawTmpDir, t as CHANNEL_IDS } from "./registry-C8pj8ctW.js";
import { i as resolveGatewayPort, t as STATE_DIR, u as resolveRequiredHomeDir } from "./paths-DJmOcr7Q.js";
import { $ as resolveProcessScopedMap, et as isPidAlive } from "./model-selection-DQIwoYb8.js";
import { V as parseDurationMs, a as writeConfigFile, n as loadConfig, s as parseByteSize, t as createConfigIO } from "./config-DA0pxYcO.js";
import { a as buildImageResizeSideGrid, i as IMAGE_REDUCE_QUALITY_STEPS, n as openFileWithinRoot, s as getImageMetadata, t as SafeOpenError, u as resizeToJpeg } from "./fs-safe-CGcLaY9D.js";
import { i as defaultRuntime, t as createSubsystemLogger } from "./subsystem-BfLMZ8kW.js";
import { D as runExec, _ as DEFAULT_SOUL_FILENAME, b as ensureAgentWorkspace, f as DEFAULT_AGENTS_FILENAME, g as DEFAULT_IDENTITY_FILENAME, h as DEFAULT_HEARTBEAT_FILENAME, m as DEFAULT_BOOTSTRAP_FILENAME, n as resolveAgentConfig, p as DEFAULT_AGENT_WORKSPACE_DIR, u as resolveSessionAgentId, v as DEFAULT_TOOLS_FILENAME, y as DEFAULT_USER_FILENAME } from "./agent-scope-OWMdRegz.js";
import { d as wrapOwnerOnlyToolExecution, f as sanitizeContentBlocksImages } from "./common-CCPGi5xr.js";
import { a as syncSkillsToWorkspace, h as safeEqualSecret, l as resolveSandboxInputPath, m as sanitizeEnvVars, u as resolveSandboxPath } from "./skills-OZUqNHBX.js";
import { n as formatErrorMessage, t as extractErrorCode } from "./errors-Bv8oZiTO.js";
import { t as SsrFBlockedError } from "./ssrf-DxmqkHJJ.js";
import { A as DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, C as DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH, D as DEFAULT_BROWSER_EVALUATE_ENABLED, E as DEFAULT_BROWSER_DEFAULT_PROFILE_NAME, O as DEFAULT_OPENCLAW_BROWSER_COLOR, S as stopChromeExtensionRelayServer, T as DEFAULT_AI_SNAPSHOT_MAX_CHARS, _ as fetchJson, a as resolveOpenClawUserDataDir, c as captureScreenshot, d as normalizeCdpWsUrl, f as snapshotAria, g as appendCdpPath, h as withBrowserNavigationPolicy, i as launchOpenClawChrome, j as isLoopbackHost, k as DEFAULT_OPENCLAW_BROWSER_ENABLED, l as createTargetViaCdp, m as assertBrowserNavigationAllowed, n as isChromeCdpReady, o as stopOpenClawChrome, p as InvalidBrowserNavigationUrlError, r as isChromeReachable, s as resolveBrowserExecutableForPlatform, v as fetchOk, w as DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS, x as ensureChromeExtensionRelayServer } from "./chrome-32CP7Tm2.js";
import { i as parseBooleanValue, t as formatCliCommand } from "./command-format-sreXrOOr.js";
import { A as normalizeHyphenSlug, d as getChannelDock, o as normalizeThinkLevel } from "./thinking-DFXDZFyu.js";
import { t as normalizeChatType } from "./chat-type-B5__aQIT.js";
import { r as normalizeChannelId } from "./plugins-BpKonLQ1.js";
import { o as listDeliverableMessageChannels, s as normalizeMessageChannel } from "./message-channel-C3OrO29D.js";
import { n as resolveConversationLabel } from "./conversation-label-2FLz1ei_.js";
import { a as resolveSessionTranscriptPathInDir, i as resolveSessionTranscriptPath, n as resolveSessionFilePath, s as resolveStorePath, t as resolveDefaultSessionStorePath } from "./paths-a1ZEI8nz.js";
import { t as emitSessionTranscriptUpdate } from "./transcript-events-Cj85Mq0h.js";
import { i as saveMediaBuffer, t as ensureMediaDir } from "./store-BJRhX9Bi.js";
import path, { posix } from "node:path";
import fs, { existsSync, realpathSync } from "node:fs";
import os from "node:os";
import fs$1 from "node:fs/promises";
import { spawn } from "node:child_process";
import crypto, { createHash } from "node:crypto";
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
import express from "express";
//#region src/config/sessions/group.ts
const getGroupSurfaces = () => new Set([...listDeliverableMessageChannels(), "webchat"]);
function normalizeGroupLabel(raw) {
return normalizeHyphenSlug(raw);
}
function shortenGroupId(value) {
const trimmed = value?.trim() ?? "";
if (!trimmed) return "";
if (trimmed.length <= 14) return trimmed;
return `${trimmed.slice(0, 6)}...${trimmed.slice(-4)}`;
}
function buildGroupDisplayName(params) {
const providerKey = (params.provider?.trim().toLowerCase() || "group").trim();
const groupChannel = params.groupChannel?.trim();
const space = params.space?.trim();
const subject = params.subject?.trim();
const detail = (groupChannel && space ? `${space}${groupChannel.startsWith("#") ? "" : "#"}${groupChannel}` : groupChannel || subject || space || "") || "";
const fallbackId = params.id?.trim() || params.key;
const rawLabel = detail || fallbackId;
let token = normalizeGroupLabel(rawLabel);
if (!token) token = normalizeGroupLabel(shortenGroupId(rawLabel));
if (!params.groupChannel && token.startsWith("#")) token = token.replace(/^#+/, "");
if (token && !/^[@#]/.test(token) && !token.startsWith("g-") && !token.includes("#")) token = `g-${token}`;
return token ? `${providerKey}:${token}` : providerKey;
}
function resolveGroupSessionKey(ctx) {
const from = typeof ctx.From === "string" ? ctx.From.trim() : "";
const chatType = ctx.ChatType?.trim().toLowerCase();
const normalizedChatType = chatType === "channel" ? "channel" : chatType === "group" ? "group" : void 0;
const isWhatsAppGroupId = from.toLowerCase().endsWith("@g.us");
if (!(normalizedChatType === "group" || normalizedChatType === "channel" || from.includes(":group:") || from.includes(":channel:") || isWhatsAppGroupId)) return null;
const providerHint = ctx.Provider?.trim().toLowerCase();
const parts = from.split(":").filter(Boolean);
const head = parts[0]?.trim().toLowerCase() ?? "";
const headIsSurface = head ? getGroupSurfaces().has(head) : false;
const provider = headIsSurface ? head : providerHint ?? (isWhatsAppGroupId ? "whatsapp" : void 0);
if (!provider) return null;
const second = parts[1]?.trim().toLowerCase();
const secondIsKind = second === "group" || second === "channel";
const kind = secondIsKind ? second : from.includes(":channel:") || normalizedChatType === "channel" ? "channel" : "group";
const finalId = (headIsSurface ? secondIsKind ? parts.slice(2).join(":") : parts.slice(1).join(":") : from).trim().toLowerCase();
if (!finalId) return null;
return {
key: `${provider}:${kind}:${finalId}`,
channel: provider,
id: finalId,
chatType: kind === "channel" ? "channel" : "group"
};
}
//#endregion
//#region src/config/sessions/metadata.ts
const mergeOrigin = (existing, next) => {
if (!existing && !next) return;
const merged = existing ? { ...existing } : {};
if (next?.label) merged.label = next.label;
if (next?.provider) merged.provider = next.provider;
if (next?.surface) merged.surface = next.surface;
if (next?.chatType) merged.chatType = next.chatType;
if (next?.from) merged.from = next.from;
if (next?.to) merged.to = next.to;
if (next?.accountId) merged.accountId = next.accountId;
if (next?.threadId != null && next.threadId !== "") merged.threadId = next.threadId;
return Object.keys(merged).length > 0 ? merged : void 0;
};
function deriveSessionOrigin(ctx) {
const label = resolveConversationLabel(ctx)?.trim();
const provider = normalizeMessageChannel(typeof ctx.OriginatingChannel === "string" && ctx.OriginatingChannel || ctx.Surface || ctx.Provider);
const surface = ctx.Surface?.trim().toLowerCase();
const chatType = normalizeChatType(ctx.ChatType) ?? void 0;
const from = ctx.From?.trim();
const to = (typeof ctx.OriginatingTo === "string" ? ctx.OriginatingTo : ctx.To)?.trim() ?? void 0;
const accountId = ctx.AccountId?.trim();
const threadId = ctx.MessageThreadId ?? void 0;
const origin = {};
if (label) origin.label = label;
if (provider) origin.provider = provider;
if (surface) origin.surface = surface;
if (chatType) origin.chatType = chatType;
if (from) origin.from = from;
if (to) origin.to = to;
if (accountId) origin.accountId = accountId;
if (threadId != null && threadId !== "") origin.threadId = threadId;
return Object.keys(origin).length > 0 ? origin : void 0;
}
function deriveGroupSessionPatch(params) {
const resolution = params.groupResolution ?? resolveGroupSessionKey(params.ctx);
if (!resolution?.channel) return null;
const channel = resolution.channel;
const subject = params.ctx.GroupSubject?.trim();
const space = params.ctx.GroupSpace?.trim();
const explicitChannel = params.ctx.GroupChannel?.trim();
const normalizedChannel = normalizeChannelId(channel);
const isChannelProvider = Boolean(normalizedChannel && getChannelDock(normalizedChannel)?.capabilities.chatTypes.includes("channel"));
const nextGroupChannel = explicitChannel ?? ((resolution.chatType === "channel" || isChannelProvider) && subject && subject.startsWith("#") ? subject : void 0);
const nextSubject = nextGroupChannel ? void 0 : subject;
const patch = {
chatType: resolution.chatType ?? "group",
channel,
groupId: resolution.id
};
if (nextSubject) patch.subject = nextSubject;
if (nextGroupChannel) patch.groupChannel = nextGroupChannel;
if (space) patch.space = space;
const displayName = buildGroupDisplayName({
provider: channel,
subject: nextSubject ?? params.existing?.subject,
groupChannel: nextGroupChannel ?? params.existing?.groupChannel,
space: space ?? params.existing?.space,
id: resolution.id,
key: params.sessionKey
});
if (displayName) patch.displayName = displayName;
return patch;
}
function deriveSessionMetaPatch(params) {
const groupPatch = deriveGroupSessionPatch(params);
const origin = deriveSessionOrigin(params.ctx);
if (!groupPatch && !origin) return null;
const patch = groupPatch ? { ...groupPatch } : {};
const mergedOrigin = mergeOrigin(params.existing?.origin, origin);
if (mergedOrigin) patch.origin = mergedOrigin;
return Object.keys(patch).length > 0 ? patch : null;
}
//#endregion
//#region src/config/sessions/main-session.ts
function resolveMainSessionKey(cfg) {
if (cfg?.session?.scope === "global") return "global";
const agents = cfg?.agents?.list ?? [];
return buildAgentMainSessionKey({
agentId: normalizeAgentId(agents.find((agent) => agent?.default)?.id ?? agents[0]?.id ?? DEFAULT_AGENT_ID),
mainKey: normalizeMainKey(cfg?.session?.mainKey)
});
}
function resolveAgentMainSessionKey(params) {
const mainKey = normalizeMainKey(params.cfg?.session?.mainKey);
return buildAgentMainSessionKey({
agentId: params.agentId,
mainKey
});
}
function resolveExplicitAgentSessionKey(params) {
const agentId = params.agentId?.trim();
if (!agentId) return;
return resolveAgentMainSessionKey({
cfg: params.cfg,
agentId
});
}
function canonicalizeMainSessionAlias(params) {
const raw = params.sessionKey.trim();
if (!raw) return raw;
const agentId = normalizeAgentId(params.agentId);
const mainKey = normalizeMainKey(params.cfg?.session?.mainKey);
const agentMainSessionKey = buildAgentMainSessionKey({
agentId,
mainKey
});
const agentMainAliasKey = buildAgentMainSessionKey({
agentId,
mainKey: "main"
});
const isMainAlias = raw === "main" || raw === mainKey || raw === agentMainSessionKey || raw === agentMainAliasKey;
if (params.cfg?.session?.scope === "global" && isMainAlias) return "global";
if (isMainAlias) return agentMainSessionKey;
return raw;
}
//#endregion
//#region src/config/sessions/types.ts
function mergeSessionEntry(existing, patch) {
const sessionId = patch.sessionId ?? existing?.sessionId ?? crypto.randomUUID();
const updatedAt = Math.max(existing?.updatedAt ?? 0, patch.updatedAt ?? 0, Date.now());
if (!existing) return {
...patch,
sessionId,
updatedAt
};
return {
...existing,
...patch,
sessionId,
updatedAt
};
}
function resolveFreshSessionTotalTokens(entry) {
const total = entry?.totalTokens;
if (typeof total !== "number" || !Number.isFinite(total) || total < 0) return;
if (entry?.totalTokensFresh === false) return;
return total;
}
const DEFAULT_RESET_TRIGGERS = ["/new", "/reset"];
const DEFAULT_IDLE_MINUTES = 60;
//#endregion
//#region src/config/sessions/reset.ts
const DEFAULT_RESET_MODE = "daily";
const DEFAULT_RESET_AT_HOUR = 4;
const THREAD_SESSION_MARKERS = [":thread:", ":topic:"];
const GROUP_SESSION_MARKERS = [":group:", ":channel:"];
function isThreadSessionKey(sessionKey) {
const normalized = (sessionKey ?? "").toLowerCase();
if (!normalized) return false;
return THREAD_SESSION_MARKERS.some((marker) => normalized.includes(marker));
}
function resolveSessionResetType(params) {
if (params.isThread || isThreadSessionKey(params.sessionKey)) return "thread";
if (params.isGroup) return "group";
const normalized = (params.sessionKey ?? "").toLowerCase();
if (GROUP_SESSION_MARKERS.some((marker) => normalized.includes(marker))) return "group";
return "direct";
}
function resolveThreadFlag(params) {
if (params.messageThreadId != null) return true;
if (params.threadLabel?.trim()) return true;
if (params.threadStarterBody?.trim()) return true;
if (params.parentSessionKey?.trim()) return true;
return isThreadSessionKey(params.sessionKey);
}
function resolveDailyResetAtMs(now, atHour) {
const normalizedAtHour = normalizeResetAtHour(atHour);
const resetAt = new Date(now);
resetAt.setHours(normalizedAtHour, 0, 0, 0);
if (now < resetAt.getTime()) resetAt.setDate(resetAt.getDate() - 1);
return resetAt.getTime();
}
function resolveSessionResetPolicy(params) {
const sessionCfg = params.sessionCfg;
const baseReset = params.resetOverride ?? sessionCfg?.reset;
const typeReset = params.resetOverride ? void 0 : sessionCfg?.resetByType?.[params.resetType] ?? (params.resetType === "direct" ? (sessionCfg?.resetByType)?.dm : void 0);
const hasExplicitReset = Boolean(baseReset || sessionCfg?.resetByType);
const legacyIdleMinutes = params.resetOverride ? void 0 : sessionCfg?.idleMinutes;
const mode = typeReset?.mode ?? baseReset?.mode ?? (!hasExplicitReset && legacyIdleMinutes != null ? "idle" : DEFAULT_RESET_MODE);
const atHour = normalizeResetAtHour(typeReset?.atHour ?? baseReset?.atHour ?? DEFAULT_RESET_AT_HOUR);
const idleMinutesRaw = typeReset?.idleMinutes ?? baseReset?.idleMinutes ?? legacyIdleMinutes;
let idleMinutes;
if (idleMinutesRaw != null) {
const normalized = Math.floor(idleMinutesRaw);
if (Number.isFinite(normalized)) idleMinutes = Math.max(normalized, 1);
} else if (mode === "idle") idleMinutes = DEFAULT_IDLE_MINUTES;
return {
mode,
atHour,
idleMinutes
};
}
function resolveChannelResetConfig(params) {
const resetByChannel = params.sessionCfg?.resetByChannel;
if (!resetByChannel) return;
const normalized = normalizeMessageChannel(params.channel);
const fallback = params.channel?.trim().toLowerCase();
const key = normalized ?? fallback;
if (!key) return;
return resetByChannel[key] ?? resetByChannel[key.toLowerCase()];
}
function evaluateSessionFreshness(params) {
const dailyResetAt = params.policy.mode === "daily" ? resolveDailyResetAtMs(params.now, params.policy.atHour) : void 0;
const idleExpiresAt = params.policy.idleMinutes != null ? params.updatedAt + params.policy.idleMinutes * 6e4 : void 0;
const staleDaily = dailyResetAt != null && params.updatedAt < dailyResetAt;
const staleIdle = idleExpiresAt != null && params.now > idleExpiresAt;
return {
fresh: !(staleDaily || staleIdle),
dailyResetAt,
idleExpiresAt
};
}
function normalizeResetAtHour(value) {
if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_RESET_AT_HOUR;
const normalized = Math.floor(value);
if (!Number.isFinite(normalized)) return DEFAULT_RESET_AT_HOUR;
if (normalized < 0) return 0;
if (normalized > 23) return 23;
return normalized;
}
//#endregion
//#region src/config/sessions/session-key.ts
function deriveSessionKey(scope, ctx) {
if (scope === "global") return "global";
const resolvedGroup = resolveGroupSessionKey(ctx);
if (resolvedGroup) return resolvedGroup.key;
return (ctx.From ? normalizeE164(ctx.From) : "") || "unknown";
}
/**
* Resolve the session key with a canonical direct-chat bucket (default: "main").
* All non-group direct chats collapse to this bucket; groups stay isolated.
*/
function resolveSessionKey(scope, ctx, mainKey) {
const explicit = ctx.SessionKey?.trim();
if (explicit) return explicit.toLowerCase();
const raw = deriveSessionKey(scope, ctx);
if (scope === "global") return raw;
const canonical = buildAgentMainSessionKey({
agentId: DEFAULT_AGENT_ID,
mainKey: normalizeMainKey(mainKey)
});
if (!(raw.includes(":group:") || raw.includes(":channel:"))) return canonical;
return `agent:${DEFAULT_AGENT_ID}:${raw}`;
}
//#endregion
//#region src/agents/session-write-lock.ts
const CLEANUP_SIGNALS = [
"SIGINT",
"SIGTERM",
"SIGQUIT",
"SIGABRT"
];
const CLEANUP_STATE_KEY = Symbol.for("openclaw.sessionWriteLockCleanupState");
const HELD_LOCKS_KEY = Symbol.for("openclaw.sessionWriteLockHeldLocks");
const WATCHDOG_STATE_KEY = Symbol.for("openclaw.sessionWriteLockWatchdogState");
const DEFAULT_STALE_MS = 1800 * 1e3;
const DEFAULT_MAX_HOLD_MS = 300 * 1e3;
const DEFAULT_WATCHDOG_INTERVAL_MS = 6e4;
const DEFAULT_TIMEOUT_GRACE_MS = 120 * 1e3;
const MAX_LOCK_HOLD_MS = 2147e6;
const HELD_LOCKS = resolveProcessScopedMap(HELD_LOCKS_KEY);
function resolveCleanupState() {
const proc = process;
if (!proc[CLEANUP_STATE_KEY]) proc[CLEANUP_STATE_KEY] = {
registered: false,
cleanupHandlers: /* @__PURE__ */ new Map()
};
return proc[CLEANUP_STATE_KEY];
}
function resolveWatchdogState() {
const proc = process;
if (!proc[WATCHDOG_STATE_KEY]) proc[WATCHDOG_STATE_KEY] = {
started: false,
intervalMs: DEFAULT_WATCHDOG_INTERVAL_MS
};
return proc[WATCHDOG_STATE_KEY];
}
function resolvePositiveMs(value, fallback, opts = {}) {
if (typeof value !== "number" || Number.isNaN(value) || value <= 0) return fallback;
if (value === Number.POSITIVE_INFINITY) return opts.allowInfinity ? value : fallback;
if (!Number.isFinite(value)) return fallback;
return value;
}
function resolveSessionLockMaxHoldFromTimeout(params) {
const minMs = resolvePositiveMs(params.minMs, DEFAULT_MAX_HOLD_MS);
const timeoutMs = resolvePositiveMs(params.timeoutMs, minMs, { allowInfinity: true });
if (timeoutMs === Number.POSITIVE_INFINITY) return MAX_LOCK_HOLD_MS;
const graceMs = resolvePositiveMs(params.graceMs, DEFAULT_TIMEOUT_GRACE_MS);
return Math.min(MAX_LOCK_HOLD_MS, Math.max(minMs, timeoutMs + graceMs));
}
async function releaseHeldLock(normalizedSessionFile, held, opts = {}) {
if (HELD_LOCKS.get(normalizedSessionFile) !== held) return false;
if (opts.force) held.count = 0;
else {
held.count -= 1;
if (held.count > 0) return false;
}
if (held.releasePromise) {
await held.releasePromise.catch(() => void 0);
return true;
}
HELD_LOCKS.delete(normalizedSessionFile);
held.releasePromise = (async () => {
try {
await held.handle.close();
} catch {}
try {
await fs$1.rm(held.lockPath, { force: true });
} catch {}
})();
try {
await held.releasePromise;
return true;
} finally {
held.releasePromise = void 0;
}
}
/**
* Synchronously release all held locks.
* Used during process exit when async operations aren't reliable.
*/
function releaseAllLocksSync() {
for (const [sessionFile, held] of HELD_LOCKS) {
try {
if (typeof held.handle.close === "function") held.handle.close().catch(() => {});
} catch {}
try {
fs.rmSync(held.lockPath, { force: true });
} catch {}
HELD_LOCKS.delete(sessionFile);
}
}
async function runLockWatchdogCheck(nowMs = Date.now()) {
let released = 0;
for (const [sessionFile, held] of HELD_LOCKS.entries()) {
const heldForMs = nowMs - held.acquiredAt;
if (heldForMs <= held.maxHoldMs) continue;
console.warn(`[session-write-lock] releasing lock held for ${heldForMs}ms (max=${held.maxHoldMs}ms): ${held.lockPath}`);
if (await releaseHeldLock(sessionFile, held, { force: true })) released += 1;
}
return released;
}
function ensureWatchdogStarted(intervalMs) {
const watchdogState = resolveWatchdogState();
if (watchdogState.started) return;
watchdogState.started = true;
watchdogState.intervalMs = intervalMs;
watchdogState.timer = setInterval(() => {
runLockWatchdogCheck().catch(() => {});
}, intervalMs);
watchdogState.timer.unref?.();
}
function handleTerminationSignal(signal) {
releaseAllLocksSync();
const cleanupState = resolveCleanupState();
if (process.listenerCount(signal) === 1) {
const handler = cleanupState.cleanupHandlers.get(signal);
if (handler) {
process.off(signal, handler);
cleanupState.cleanupHandlers.delete(signal);
}
try {
process.kill(process.pid, signal);
} catch {}
}
}
function registerCleanupHandlers() {
const cleanupState = resolveCleanupState();
if (!cleanupState.registered) {
cleanupState.registered = true;
process.on("exit", () => {
releaseAllLocksSync();
});
}
ensureWatchdogStarted(DEFAULT_WATCHDOG_INTERVAL_MS);
for (const signal of CLEANUP_SIGNALS) {
if (cleanupState.cleanupHandlers.has(signal)) continue;
try {
const handler = () => handleTerminationSignal(signal);
cleanupState.cleanupHandlers.set(signal, handler);
process.on(signal, handler);
} catch {}
}
}
async function readLockPayload(lockPath) {
try {
const raw = await fs$1.readFile(lockPath, "utf8");
const parsed = JSON.parse(raw);
const payload = {};
if (typeof parsed.pid === "number") payload.pid = parsed.pid;
if (typeof parsed.createdAt === "string") payload.createdAt = parsed.createdAt;
return payload;
} catch {
return null;
}
}
function inspectLockPayload(payload, staleMs, nowMs) {
const pid = typeof payload?.pid === "number" ? payload.pid : null;
const pidAlive = pid !== null ? isPidAlive(pid) : false;
const createdAt = typeof payload?.createdAt === "string" ? payload.createdAt : null;
const createdAtMs = createdAt ? Date.parse(createdAt) : NaN;
const ageMs = Number.isFinite(createdAtMs) ? Math.max(0, nowMs - createdAtMs) : null;
const staleReasons = [];
if (pid === null) staleReasons.push("missing-pid");
else if (!pidAlive) staleReasons.push("dead-pid");
if (ageMs === null) staleReasons.push("invalid-createdAt");
else if (ageMs > staleMs) staleReasons.push("too-old");
return {
pid,
pidAlive,
createdAt,
ageMs,
stale: staleReasons.length > 0,
staleReasons
};
}
async function acquireSessionWriteLock(params) {
registerCleanupHandlers();
const timeoutMs = resolvePositiveMs(params.timeoutMs, 1e4, { allowInfinity: true });
const staleMs = resolvePositiveMs(params.staleMs, DEFAULT_STALE_MS);
const maxHoldMs = resolvePositiveMs(params.maxHoldMs, DEFAULT_MAX_HOLD_MS);
const sessionFile = path.resolve(params.sessionFile);
const sessionDir = path.dirname(sessionFile);
await fs$1.mkdir(sessionDir, { recursive: true });
let normalizedDir = sessionDir;
try {
normalizedDir = await fs$1.realpath(sessionDir);
} catch {}
const normalizedSessionFile = path.join(normalizedDir, path.basename(sessionFile));
const lockPath = `${normalizedSessionFile}.lock`;
const allowReentrant = params.allowReentrant ?? true;
const held = HELD_LOCKS.get(normalizedSessionFile);
if (allowReentrant && held) {
held.count += 1;
return { release: async () => {
await releaseHeldLock(normalizedSessionFile, held);
} };
}
const startedAt = Date.now();
let attempt = 0;
while (Date.now() - startedAt < timeoutMs) {
attempt += 1;
try {
const handle = await fs$1.open(lockPath, "wx");
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
await handle.writeFile(JSON.stringify({
pid: process.pid,
createdAt
}, null, 2), "utf8");
const createdHeld = {
count: 1,
handle,
lockPath,
acquiredAt: Date.now(),
maxHoldMs
};
HELD_LOCKS.set(normalizedSessionFile, createdHeld);
return { release: async () => {
await releaseHeldLock(normalizedSessionFile, createdHeld);
} };
} catch (err) {
if (err.code !== "EEXIST") throw err;
if (inspectLockPayload(await readLockPayload(lockPath), staleMs, Date.now()).stale) {
await fs$1.rm(lockPath, { force: true });
continue;
}
const delay = Math.min(1e3, 50 * attempt);
await new Promise((r) => setTimeout(r, delay));
}
}
const payload = await readLockPayload(lockPath);
const owner = typeof payload?.pid === "number" ? `pid=${payload.pid}` : "unknown";
throw new Error(`session file locked (timeout ${timeoutMs}ms): ${owner} ${lockPath}`);
}
const __testing = {
cleanupSignals: [...CLEANUP_SIGNALS],
handleTerminationSignal,
releaseAllLocksSync,
runLockWatchdogCheck
};
//#endregion
//#region src/sessions/input-provenance.ts
const INPUT_PROVENANCE_KIND_VALUES = [
"external_user",
"inter_session",
"internal_system"
];
function normalizeOptionalString(value) {
if (typeof value !== "string") return;
const trimmed = value.trim();
return trimmed ? trimmed : void 0;
}
function isInputProvenanceKind(value) {
return typeof value === "string" && INPUT_PROVENANCE_KIND_VALUES.includes(value);
}
function normalizeInputProvenance(value) {
if (!value || typeof value !== "object") return;
const record = value;
if (!isInputProvenanceKind(record.kind)) return;
return {
kind: record.kind,
sourceSessionKey: normalizeOptionalString(record.sourceSessionKey),
sourceChannel: normalizeOptionalString(record.sourceChannel),
sourceTool: normalizeOptionalString(record.sourceTool)
};
}
function applyInputProvenanceToUserMessage(message, inputProvenance) {
if (!inputProvenance) return message;
if (message.role !== "user") return message;
if (normalizeInputProvenance(message.provenance)) return message;
return {
...message,
provenance: inputProvenance
};
}
function isInterSessionInputProvenance(value) {
return normalizeInputProvenance(value)?.kind === "inter_session";
}
function hasInterSessionUserProvenance(message) {
if (!message || message.role !== "user") return false;
return isInterSessionInputProvenance(message.provenance);
}
//#endregion
//#region src/utils/transcript-tools.ts
const TOOL_CALL_TYPES$1 = new Set([
"tool_use",
"toolcall",
"tool_call"
]);
const TOOL_RESULT_TYPES = new Set(["tool_result", "tool_result_error"]);
const normalizeType = (value) => {
if (typeof value !== "string") return "";
return value.trim().toLowerCase();
};
const extractToolCallNames = (message) => {
const names = /* @__PURE__ */ new Set();
const toolNameRaw = message.toolName ?? message.tool_name;
if (typeof toolNameRaw === "string" && toolNameRaw.trim()) names.add(toolNameRaw.trim());
const content = message.content;
if (!Array.isArray(content)) return Array.from(names);
for (const entry of content) {
if (!entry || typeof entry !== "object") continue;
const block = entry;
const type = normalizeType(block.type);
if (!TOOL_CALL_TYPES$1.has(type)) continue;
const name = block.name;
if (typeof name === "string" && name.trim()) names.add(name.trim());
}
return Array.from(names);
};
const countToolResults = (message) => {
const content = message.content;
if (!Array.isArray(content)) return {
total: 0,
errors: 0
};
let total = 0;
let errors = 0;
for (const entry of content) {
if (!entry || typeof entry !== "object") continue;
const block = entry;
const type = normalizeType(block.type);
if (!TOOL_RESULT_TYPES.has(type)) continue;
total += 1;
if (block.is_error === true) errors += 1;
}
return {
total,
errors
};
};
//#endregion
//#region src/auto-reply/reply/strip-inbound-meta.ts
/**
* Strips OpenClaw-injected inbound metadata blocks from a user-role message
* text before it is displayed in any UI surface (TUI, webchat, macOS app).
*
* Background: `buildInboundUserContextPrefix` in `inbound-meta.ts` prepends
* structured metadata blocks (Conversation info, Sender info, reply context,
* etc.) directly to the stored user message content so the LLM can access
* them. These blocks are AI-facing only and must never surface in user-visible
* chat history.
*/
/**
* Sentinel strings that identify the start of an injected metadata block.
* Must stay in sync with `buildInboundUserContextPrefix` in `inbound-meta.ts`.
*/
const INBOUND_META_SENTINELS = [
"Conversation info (untrusted metadata):",
"Sender (untrusted metadata):",
"Thread starter (untrusted, for context):",
"Replied message (untrusted, for context):",
"Forwarded message context (untrusted metadata):",
"Chat history since last reply (untrusted, for context):"
];
const SENTINEL_FAST_RE = new RegExp(INBOUND_META_SENTINELS.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"));
//#endregion
//#region src/gateway/session-utils.fs.ts
function resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId) {
const candidates = [];
const pushCandidate = (resolve) => {
try {
candidates.push(resolve());
} catch {}
};
if (storePath) {
const sessionsDir = path.dirname(storePath);
if (sessionFile) pushCandidate(() => resolveSessionFilePath(sessionId, { sessionFile }, {
sessionsDir,
agentId
}));
pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, sessionsDir));
} else if (sessionFile) if (agentId) pushCandidate(() => resolveSessionFilePath(sessionId, { sessionFile }, { agentId }));
else {
const trimmed = sessionFile.trim();
if (trimmed) candidates.push(path.resolve(trimmed));
}
if (agentId) pushCandidate(() => resolveSessionTranscriptPath(sessionId, agentId));
const home = resolveRequiredHomeDir(process.env, os.homedir);
const legacyDir = path.join(home, ".openclaw", "sessions");
pushCandidate(() => resolveSessionTranscriptPathInDir(sessionId, legacyDir));
return Array.from(new Set(candidates));
}
function archiveFileOnDisk(filePath, reason) {
const archived = `${filePath}.${reason}.${(/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-")}`;
fs.renameSync(filePath, archived);
return archived;
}
/**
* Archives all transcript files for a given session.
* Best-effort: silently skips files that don't exist or fail to rename.
*/
function archiveSessionTranscripts(opts) {
const archived = [];
for (const candidate of resolveSessionTranscriptCandidates(opts.sessionId, opts.storePath, opts.sessionFile, opts.agentId)) {
if (!fs.existsSync(candidate)) continue;
try {
archived.push(archiveFileOnDisk(candidate, opts.reason));
} catch {}
}
return archived;
}
function restoreArchiveTimestamp(raw) {
const [datePart, timePart] = raw.split("T");
if (!datePart || !timePart) return raw;
return `${datePart}T${timePart.replace(/-/g, ":")}`;
}
function parseArchivedTimestamp(fileName, reason) {
const marker = `.${reason}.`;
const index = fileName.lastIndexOf(marker);
if (index < 0) return null;
const raw = fileName.slice(index + marker.length);
if (!raw) return null;
const timestamp = Date.parse(restoreArchiveTimestamp(raw));
return Number.isNaN(timestamp) ? null : timestamp;
}
async function cleanupArchivedSessionTranscripts(opts) {
if (!Number.isFinite(opts.olderThanMs) || opts.olderThanMs < 0) return {
removed: 0,
scanned: 0
};
const now = opts.nowMs ?? Date.now();
const reason = opts.reason ?? "deleted";
const directories = Array.from(new Set(opts.directories.map((dir) => path.resolve(dir))));
let removed = 0;
let scanned = 0;
for (const dir of directories) {
const entries = await fs.promises.readdir(dir).catch(() => []);
for (const entry of entries) {
const timestamp = parseArchivedTimestamp(entry, reason);
if (timestamp == null) continue;
scanned += 1;
if (now - timestamp <= opts.olderThanMs) continue;
const fullPath = path.join(dir, entry);
if (!(await fs.promises.stat(fullPath).catch(() => null))?.isFile()) continue;
await fs.promises.rm(fullPath).catch(() => void 0);
removed += 1;
}
}
return {
removed,
scanned
};
}
function jsonUtf8Bytes(value) {
try {
return Buffer.byteLength(JSON.stringify(value), "utf8");
} catch {
return Buffer.byteLength(String(value), "utf8");
}
}
function capArrayByJsonBytes(items, maxBytes) {
if (items.length === 0) return {
items,
bytes: 2
};
const parts = items.map((item) => jsonUtf8Bytes(item));
let bytes = 2 + parts.reduce((a, b) => a + b, 0) + (items.length - 1);
let start = 0;
while (bytes > maxBytes && start < items.length - 1) {
bytes -= parts[start] + 1;
start += 1;
}
return {
items: start > 0 ? items.slice(start) : items,
bytes
};
}
const PREVIEW_READ_SIZES = [
64 * 1024,
256 * 1024,
1024 * 1024
];
//#endregion
//#region src/utils/account-id.ts
function normalizeAccountId(value) {
if (typeof value !== "string") return;
return value.trim() || void 0;
}
//#endregion
//#region src/utils/delivery-context.ts
function normalizeDeliveryContext(context) {
if (!context) return;
const channel = typeof context.channel === "string" ? normalizeMessageChannel(context.channel) ?? context.channel.trim() : void 0;
const to = typeof context.to === "string" ? context.to.trim() : void 0;
const accountId = normalizeAccountId(context.accountId);
const threadId = typeof context.threadId === "number" && Number.isFinite(context.threadId) ? Math.trunc(context.threadId) : typeof context.threadId === "string" ? context.threadId.trim() : void 0;
const normalizedThreadId = typeof threadId === "string" ? threadId ? threadId : void 0 : threadId;
if (!channel && !to && !accountId && normalizedThreadId == null) return;
const normalized = {
channel: channel || void 0,
to: to || void 0,
accountId
};
if (normalizedThreadId != null) normalized.threadId = normalizedThreadId;
return normalized;
}
function normalizeSessionDeliveryFields(source) {
if (!source) return {
deliveryContext: void 0,
lastChannel: void 0,
lastTo: void 0,
lastAccountId: void 0,
lastThreadId: void 0
};
const merged = mergeDeliveryContext(normalizeDeliveryContext({
channel: source.lastChannel ?? source.channel,
to: source.lastTo,
accountId: source.lastAccountId,
threadId: source.lastThreadId
}), normalizeDeliveryContext(source.deliveryContext));
if (!merged) return {
deliveryContext: void 0,
lastChannel: void 0,
lastTo: void 0,
lastAccountId: void 0,
lastThreadId: void 0
};
return {
deliveryContext: merged,
lastChannel: merged.channel,
lastTo: merged.to,
lastAccountId: merged.accountId,
lastThreadId: merged.threadId
};
}
function deliveryContextFromSession(entry) {
if (!entry) return;
return normalizeSessionDeliveryFields({
channel: entry.channel,
lastChannel: entry.lastChannel,
lastTo: entry.lastTo,
lastAccountId: entry.lastAccountId,
lastThreadId: entry.lastThreadId ?? entry.deliveryContext?.threadId ?? entry.origin?.threadId,
deliveryContext: entry.deliveryContext
}).deliveryContext;
}
function mergeDeliveryContext(primary, fallback) {
const normalizedPrimary = normalizeDeliveryContext(primary);
const normalizedFallback = normalizeDeliveryContext(fallback);
if (!normalizedPrimary && !normalizedFallback) return;
return normalizeDeliveryContext({
channel: normalizedPrimary?.channel ?? normalizedFallback?.channel,
to: normalizedPrimary?.to ?? normalizedFallback?.to,
accountId: normalizedPrimary?.accountId ?? normalizedFallback?.accountId,
threadId: normalizedPrimary?.threadId ?? normalizedFallback?.threadId
});
}
function deliveryContextKey(context) {
const normalized = normalizeDeliveryContext(context);
if (!normalized?.channel || !normalized?.to) return;
const threadId = normalized.threadId != null && normalized.threadId !== "" ? String(normalized.threadId) : "";
return `${normalized.channel}|${normalized.to}|${normalized.accountId ?? ""}|${threadId}`;
}
//#endregion
//#region src/config/cache-utils.ts
function resolveCacheTtlMs(params) {
const { envValue, defaultTtlMs } = params;
if (envValue) {
const parsed = Number.parseInt(envValue, 10);
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
}
return defaultTtlMs;
}
function isCacheEnabled(ttlMs) {
return ttlMs > 0;
}
function getFileMtimeMs(filePath) {
try {
return fs.statSync(filePath).mtimeMs;
} catch {
return;
}
}
//#endregion
//#region src/config/sessions/store.ts
const log = createSubsystemLogger("sessions/store");
const SESSION_STORE_CACHE = /* @__PURE__ */ new Map();
const DEFAULT_SESSION_STORE_TTL_MS = 45e3;
function isSessionStoreRecord(value) {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function getSessionStoreTtl() {
return resolveCacheTtlMs({
envValue: process.env.OPENCLAW_SESSION_CACHE_TTL_MS,
defaultTtlMs: DEFAULT_SESSION_STORE_TTL_MS
});
}
function isSessionStoreCacheEnabled() {
return isCacheEnabled(getSessionStoreTtl());
}
function isSessionStoreCacheValid(entry) {
const now = Date.now();
const ttl = getSessionStoreTtl();
return now - entry.loadedAt <= ttl;
}
function invalidateSessionStoreCache(storePath) {
SESSION_STORE_CACHE.delete(storePath);
}
function normalizeSessionEntryDelivery(entry) {
const normalized = normalizeSessionDeliveryFields({
channel: entry.channel,
lastChannel: entry.lastChannel,
lastTo: entry.lastTo,
lastAccountId: entry.lastAccountId,
lastThreadId: entry.lastThreadId ?? entry.deliveryContext?.threadId ?? entry.origin?.threadId,
deliveryContext: entry.deliveryContext
});
const nextDelivery = normalized.deliveryContext;
const sameDelivery = (entry.deliveryContext?.channel ?? void 0) === nextDelivery?.channel && (entry.deliveryContext?.to ?? void 0) === nextDelivery?.to && (entry.deliveryContext?.accountId ?? void 0) === nextDelivery?.accountId && (entry.deliveryContext?.threadId ?? void 0) === nextDelivery?.threadId;
const sameLast = entry.lastChannel === normalized.lastChannel && entry.lastTo === normalized.lastTo && entry.lastAccountId === normalized.lastAccountId && entry.lastThreadId === normalized.lastThreadId;
if (sameDelivery && sameLast) return entry;
return {
...entry,
deliveryContext: nextDelivery,
lastChannel: normalized.lastChannel,
lastTo: normalized.lastTo,
lastAccountId: normalized.lastAccountId,
lastThreadId: normalized.lastThreadId
};
}
function removeThreadFromDeliveryContext(context) {
if (!context || context.threadId == null) return context;
const next = { ...context };
delete next.threadId;
return next;
}
function normalizeSessionStore(store) {
for (const [key, entry] of Object.entries(store)) {
if (!entry) continue;
const normalized = normalizeSessionEntryDelivery(entry);
if (normalized !== entry) store[key] = normalized;
}
}
function loadSessionStore(storePath, opts = {}) {
if (!opts.skipCache && isSessionStoreCacheEnabled()) {
const cached = SESSION_STORE_CACHE.get(storePath);
if (cached && isSessionStoreCacheValid(cached)) {
if (getFileMtimeMs(storePath) === cached.mtimeMs) return structuredClone(cached.store);
invalidateSessionStoreCache(storePath);
}
}
let store = {};
let mtimeMs = getFileMtimeMs(storePath);
const maxReadAttempts = process.platform === "win32" ? 3 : 1;
const retryBuf = maxReadAttempts > 1 ? new Int32Array(new SharedArrayBuffer(4)) : void 0;
for (let attempt = 0; attempt < maxReadAttempts; attempt++) try {
const raw = fs.readFileSync(storePath, "utf-8");
if (raw.length === 0 && attempt < maxReadAttempts - 1) {
Atomics.wait(retryBuf, 0, 0, 50);
continue;
}
const parsed = JSON.parse(raw);
if (isSessionStoreRecord(parsed)) store = parsed;
mtimeMs = getFileMtimeMs(storePath) ?? mtimeMs;
break;
} catch {
if (attempt < maxReadAttempts - 1) {
Atomics.wait(retryBuf, 0, 0, 50);
continue;
}
}
for (const entry of Object.values(store)) {
if (!entry || typeof entry !== "object") continue;
const rec = entry;
if (typeof rec.channel !== "string" && typeof rec.provider === "string") {
rec.channel = rec.provider;
delete rec.provider;
}
if (typeof rec.lastChannel !== "string" && typeof rec.lastProvider === "string") {
rec.lastChannel = rec.lastProvider;
delete rec.lastProvider;
}
if (typeof rec.groupChannel !== "string" && typeof rec.room === "string") {
rec.groupChannel = rec.room;
delete rec.room;
} else if ("room" in rec) delete rec.room;
}
if (!opts.skipCache && isSessionStoreCacheEnabled()) SESSION_STORE_CACHE.set(storePath, {
store: structuredClone(store),
loadedAt: Date.now(),
storePath,
mtimeMs
});
return structuredClone(store);
}
function readSessionUpdatedAt(params) {
try {
return loadSessionStore(params.storePath)[params.sessionKey]?.updatedAt;
} catch {
return;
}
}
const DEFAULT_SESSION_PRUNE_AFTER_MS = 720 * 60 * 60 * 1e3;
const DEFAULT_SESSION_MAX_ENTRIES = 500;
const DEFAULT_SESSION_ROTATE_BYTES = 10485760;
const DEFAULT_SESSION_MAINTENANCE_MODE = "warn";
function resolvePruneAfterMs(maintenance) {
const raw = maintenance?.pruneAfter ?? maintenance?.pruneDays;
if (raw === void 0 || raw === null || raw === "") return DEFAULT_SESSION_PRUNE_AFTER_MS;
try {
return parseDurationMs(String(raw).trim(), { defaultUnit: "d" });
} catch {
return DEFAULT_SESSION_PRUNE_AFTER_MS;
}
}
function resolveRotateBytes(maintenance) {
const raw = maintenance?.rotateBytes;
if (raw === void 0 || raw === null || raw === "") return DEFAULT_SESSION_ROTATE_BYTES;
try {
return parseByteSize(String(raw).trim(), { defaultUnit: "b" });
} catch {
return DEFAULT_SESSION_ROTATE_BYTES;
}
}
/**
* Resolve maintenance settings from openclaw.json (`session.maintenance`).
* Falls back to built-in defaults when config is missing or unset.
*/
function resolveMaintenanceConfig() {
let maintenance;
try {
maintenance = loadConfig().session?.maintenance;
} catch {}
return {
mode: maintenance?.mode ?? DEFAULT_SESSION_MAINTENANCE_MODE,
pruneAfterMs: resolvePruneAfterMs(maintenance),
maxEntries: maintenance?.maxEntries ?? DEFAULT_SESSION_MAX_ENTRIES,
rotateBytes: resolveRotateBytes(maintenance)
};
}
/**
* Remove entries whose `updatedAt` is older than the configured threshold.
* Entries without `updatedAt` are kept (cannot determine staleness).
* Mutates `store` in-place.
*/
function pruneStaleEntries(store, overrideMaxAgeMs, opts = {}) {
const maxAgeMs = overrideMaxAgeMs ?? resolveMaintenanceConfig().pruneAfterMs;
const cutoffMs = Date.now() - maxAgeMs;
let pruned = 0;
for (const [key, entry] of Object.entries(store)) if (entry?.updatedAt != null && entry.updatedAt < cutoffMs) {
opts.onPruned?.({
key,
entry
});
delete store[key];
pruned++;
}
if (pruned > 0 && opts.log !== false) log.info("pruned stale session entries", {
pruned,
maxAgeMs
});
return pruned;
}
/**
* Cap the store to the N most recently updated entries.
* Entries without `updatedAt` are sorted last (removed first when over limit).
* Mutates `store` in-place.
*/
function getEntryUpdatedAt(entry) {
return entry?.updatedAt ?? Number.NEGATIVE_INFINITY;
}
function getActiveSessionMaintenanceWarning(params) {
const activeSessionKey = params.activeSessionKey.trim();
if (!activeSessionKey) return null;
const activeEntry = params.store[activeSessionKey];
if (!activeEntry) return null;
const cutoffMs = (params.nowMs ?? Date.now()) - params.pruneAfterMs;
const wouldPrune = activeEntry.updatedAt != null ? activeEntry.updatedAt < cutoffMs : false;
const keys = Object.keys(params.store);
const wouldCap = keys.length > params.maxEntries && keys.toSorted((a, b) => getEntryUpdatedAt(params.store[b]) - getEntryUpdatedAt(params.store[a])).slice(params.maxEntries).includes(activeSessionKey);
if (!wouldPrune && !wouldCap) return null;
return {
activeSessionKey,
activeUpdatedAt: activeEntry.updatedAt,
totalEntries: keys.length,
pruneAfterMs: params.pruneAfterMs,
maxEntries: params.maxEntries,
wouldPrune,
wouldCap
};
}
function capEntryCount(store, overrideMax, opts = {}) {
const maxEntries = overrideMax ?? resolveMaintenanceConfig().maxEntries;
const keys = Object.keys(store);
if (keys.length <= maxEntries) return 0;
const toRemove = keys.toSorted((a, b) => {
const aTime = getEntryUpdatedAt(store[a]);
return getEntryUpdatedAt(store[b]) - aTime;
}).slice(maxEntries);
for (const key of toRemove) delete store[key];
if (opts.log !== false) log.info("capped session entry count", {
removed: toRemove.length,
maxEntries
});
return toRemove.length;
}
async function getSessionFileSize(storePath) {
try {
return (await fs.promises.stat(storePath)).size;
} catch {
return null;
}
}
/**
* Rotate the sessions file if it exceeds the configured size threshold.
* Renames the current file to `sessions.json.bak.{timestamp}` and cleans up
* old rotation backups, keeping only the 3 most recent `.bak.*` files.
*/
async function rotateSessionFile(storePath, overrideBytes) {
const maxBytes = overrideBytes ?? resolveMaintenanceConfig().rotateBytes;
const fileSize = await getSessionFileSize(storePath);
if (fileSize == null) return false;
if (fileSize <= maxBytes) return false;
const backupPath = `${storePath}.bak.${Date.now()}`;
try {
await fs.promises.rename(storePath, backupPath);
log.info("rotated session store file", {
backupPath: path.basename(backupPath),
sizeBytes: fileSize
});
} catch {
return false;
}
try {
const dir = path.dirname(storePath);
const baseName = path.basename(storePath);
const backups = (await fs.promises.readdir(dir)).filter((f) => f.startsWith(`${baseName}.bak.`)).toSorted().toReversed();
const maxBackups = 3;
if (backups.length > maxBackups) {
const toDelete = backups.slice(maxBackups);
for (const old of toDelete) await fs.promises.unlink(path.join(dir, old)).catch(() => void 0);
log.info("cleaned up old session store backups", { deleted: toDelete.length });
}
} catch {}
return true;
}
async function saveSessionStoreUnlocked(storePath, store, opts) {
invalidateSessionStoreCache(storePath);
normalizeSessionStore(store);
if (!opts?.skipMaintenance) {
const maintenance = resolveMaintenanceConfig();
if (maintenance.mode === "warn") {
const activeSessionKey = opts?.activeSessionKey?.trim();
if (activeSessionKey) {
const warning = getActiveSessionMaintenanceWarning({
store,
activeSessionKey,
pruneAfterMs: maintenance.pruneAfterMs,
maxEntries: maintenance.maxEntries
});
if (warning) {
log.warn("session maintenance would evict active session; skipping enforcement", {
activeSessionKey: warning.activeSessionKey,
wouldPrune: warning.wouldPrune,
wouldCap: warning.wouldCap,
pruneAfterMs: warning.pruneAfterMs,
maxEntries: warning.maxEntries
});
await opts?.onWarn?.(warning);
}
}
} else {
const prunedSessionFiles = /* @__PURE__ */ new Map();
pruneStaleEntries(store, maintenance.pruneAfterMs, { onPruned: ({ entry }) => {
if (!prunedSessionFiles.has(entry.sessionId) || entry.sessionFile) prunedSessionFiles.set(entry.sessionId, entry.sessionFile);
} });
capEntryCount(store, maintenance.maxEntries);
const archivedDirs = /* @__PURE__ */ new Set();
for (const [sessionId, sessionFile] of prunedSessionFiles) {
const archived = archiveSessionTranscripts({
sessionId,
storePath,
sessionFile,
reason: "deleted"
});
for (const archivedPath of archived) archivedDirs.add(path.dirname(archivedPath));
}
if (archivedDirs.size > 0) await cleanupArchivedSessionTranscripts({
directories: [...archivedDirs],
olderThanMs: maintenance.pruneAfterMs,
reason: "deleted"
});
await rotateSessionFile(storePath, maintenance.rotateBytes);
}
}
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
const json = JSON.stringify(store, null, 2);
if (process.platform === "win32") {
const tmp = `${storePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
try {
await fs.promises.writeFile(tmp, json, "utf-8");
for (let i = 0; i < 5; i++) try {
await fs.promises.rename(tmp, storePath);
break;
} catch {
if (i < 4) await new Promise((r) => setTimeout(r, 50 * (i + 1)));
if (i === 4) console.warn(`[session-store] rename failed after 5 attempts: ${storePath}`);
}
} catch (err) {
if ((err && typeof err === "object" && "code" in err ? String(err.code) : null) === "ENOENT") return;
throw err;
} finally {
await fs.promises.rm(tmp, { force: true }).catch(() => void 0);
}
return;
}
const tmp = `${storePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
try {
await fs.promises.writeFile(tmp, json, {
mode: 384,
encoding: "utf-8"
});
await fs.promises.rename(tmp, storePath);
await fs.promises.chmod(storePath, 384);
} catch (err) {
if ((err && typeof err === "object" && "code" in err ? String(err.code) : null) === "ENOENT") {
try {
await fs.promises.mkdir(path.dirname(storePath), { recursive: true });
await fs.promises.writeFile(storePath, json, {
mode: 384,
encoding: "utf-8"
});
await fs.promises.chmod(storePath, 384);
} catch (err2) {
if ((err2 && typeof err2 === "object" && "code" in err2 ? String(err2.code) : null) === "ENOENT") return;
throw err2;
}
return;
}
throw err;
} finally {
await fs.promises.rm(tmp, { force: true });
}
}
async function updateSessionStore(storePath, mutator, opts) {
return await withSessionStoreLock(storePath, async () => {
const store = loadSessionStore(storePath, { skipCache: true });
const result = await mutator(store);
await saveSessionStoreUnlocked(storePath, store, opts);
return result;
});
}
const LOCK_QUEUES = /* @__PURE__ */ new Map();
function lockTimeoutError(storePath) {
return /* @__PURE__ */ new Error(`timeout waiting for session store lock: ${storePath}`);
}
function getOrCreateLockQueue(storePath) {
const existing = LOCK_QUEUES.get(storePath);
if (existing) return existing;
const created = {
running: false,
pending: []
};
LOCK_QUEUES.set(storePath, created);
return created;
}
async function drainSessionStoreLockQueue(storePath) {
const queue = LOCK_QUEUES.get(storePath);
if (!queue || queue.running) return;
queue.running =