openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
1,677 lines • 73.5 kB
JavaScript
import { t as sanitizeForLog } from "./ansi-DrXAcdMD.js";
import "./src-vebZIeLe.js";
import { t as expectDefined } from "./expect-CyE8FADM.js";
import { c as isRecord, n as asNullableObjectRecord } from "./record-coerce-DItp3I4t.js";
import { c as normalizeOptionalLowercaseString, l as normalizeOptionalString } from "./string-coerce-CIXf7egm.js";
import { f as normalizeStringEntriesLower, y as uniqueStrings } from "./string-normalization-DsCfAx8q.js";
import { o as resolveRequiredHomeDir } from "./home-dir-BPhrG-aM.js";
import { o as safeRealpathSync } from "./boundary-path-DMNeww4q.js";
import { m as shortenHomePath } from "./utils-P__uGsPB.js";
import { n as normalizeAgentId } from "./agent-id-CeT3w4ap.js";
import { t as formatCliCommand } from "./command-format-C7YfyMTd.js";
import { E as tryResolveDefaultAgentId, o as listAgentIds, v as resolveDefaultAgentDir } from "./agent-scope-config-DcbEhP0R.js";
import { C as resolveOAuthDir, w as resolveStateDir } from "./paths-D2sRr1a_.js";
import { O as parseAgentSessionKey, T as isSubagentSessionKey, b as toAgentStoreSessionKey, f as resolveAgentIdFromSessionKey } from "./session-key-BnWWjqNc.js";
import { n as resolveSessionStoreCompatibilityAgentId } from "./legacy.default-agent-owner-BGwEdQRe.js";
import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js";
import { i as findGitRoot } from "./git-commit-C4lUBwiZ.js";
import { t as decodeMountInfoPath } from "./mountinfo-path-BCOIljp0.js";
import { a as listPluginDoctorSessionRouteStateOwners } from "./doctor-contract-registry-DOa9Ye7j.js";
import { c as normalizeProviderId } from "./model-ref-shared-Dz7QU0Lx.js";
import { t as modelKey } from "./model-key-CMdQNkZf.js";
import { n as formatSessionArchiveTimestamp } from "./artifacts-DMDO9PHy.js";
import { a as resolveSessionFilePathOptions, i as resolveSessionFilePathCore, l as resolveSessionTranscriptsDirForAgent, o as resolveSessionStorePathCore } from "./paths-CXdaYWF_.js";
import { i as resolveAgentModelFallbackValues } from "./model-input-BuGMCNOz.js";
import { u as resolveAgentModelFallbacksOverride } from "./agent-scope-DbtJyKUL.js";
import { n as parseModelRef } from "./model-selection-normalize-D1HuPOqZ.js";
import { t as resolveDefaultModelForAgent } from "./model-selection-config-BrdmmqKD.js";
import { o as listConfiguredChannelIdsForReadOnlyScope } from "./channel-presence-policy-_eDSlojg.js";
import { o as resolveSharedAuthStoreOwnership, s as resolveSharedAuthStorePath } from "./path-resolve-oRkRBkQd.js";
import { y as listOpenClawRegisteredAgentDatabases } from "./openclaw-agent-db-maintenance-wTIy-jt-.js";
import { h as resolveAuthProfileDatabasePath } from "./sqlite-MN_7y26V.js";
import { t as resolveAgentHarnessPolicy } from "./policy-D9i1QMuw.js";
import "./channel-plugin-ids-DIyH-LxI.js";
import { Gt as applySessionEntryReplacements, Wt as applySessionEntryLifecycleMutation, lt as iterateDoctorSessionKeyBatches, pt as scanDoctorSessionEntriesStrict } from "./session-accessor-YsytfDtG.js";
import { u as loadSessionEntryReadOnly, w as loadExactSessionEntryReadOnly } from "./session-accessor.sqlite-entry-CWk3jL7s.js";
import { r as resolveSqliteTargetFromSessionStorePath } from "./session-sqlite-target-Dp-kgpcT.js";
import { t as resolveCanonicalMainSessionKey } from "./main-session-key-DOmwwwt0.js";
import { n as canonicalizeMainSessionAlias } from "./main-session-Br0F9dzh.js";
import { c as resolveConfiguredAgentDatabaseTargets, u as resolveSessionStoreTargets } from "./targets-Cknmo7YZ.js";
import { c as isValidAgentHarnessSessionStoreEntry } from "./agent-harness-session-key-BOz3yx0-.js";
import "./model-selection-di2kjKCB.js";
import { n as formatSubagentRecoveryWedgedReason, r as isSubagentRecoveryWedgedEntry, t as clearWedgedSubagentRecoveryAbort } from "./subagent-recovery-state-Be6LdCFU.js";
import { s as transitionMainSessionRecovery } from "./main-session-recovery-state-DD1DvBOq.js";
import { n as isHeartbeatOkResponse, r as isHeartbeatUserMessage } from "./heartbeat-filter-64LAFCDI.js";
import { i as resolveHeartbeatIntervalMs, n as resolveHeartbeatAgents } from "./heartbeat-config-DYNUPVum.js";
import { t as note } from "./note-DqHk3fA1.js";
import { n as resolveHeartbeatDeliveryTarget } from "./targets-CP7C4mlG.js";
import { i as updateLegacySessionStore, t as loadLegacySessionStore } from "./state-migrations.legacy-session-store-CQJX6yZy.js";
import { i as isSharedAuthStoreOwner } from "./agent-delete-safety-CIoplT3h.js";
import { t as discoverAgentDatabaseMigrationTargets } from "./state-migrations.media-persistence-targets-D45MAuwp.js";
import { n as clearTuiLastSessionPointers } from "./tui-last-session-CLCJUxtw.js";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { StringDecoder } from "node:string_decoder";
//#region src/commands/doctor-state-integrity-format.ts
function countLabel(count, singular, plural = `${singular}s`) {
return `${count} ${count === 1 ? singular : plural}`;
}
//#endregion
//#region src/commands/doctor-heartbeat-main-session-repair.ts
/** Doctor repair for main sessions accidentally occupied by synthetic heartbeat transcripts. */
/** Chunk size for sync transcript scans. */
const TRANSCRIPT_SCAN_CHUNK_BYTES = 65536;
const TRANSCRIPT_RECORD_MAX_CHARS = 262144;
function sessionEntryHasSyntheticHeartbeatOwnership(entry) {
return typeof entry.heartbeatIsolatedBaseSessionKey === "string" && entry.heartbeatIsolatedBaseSessionKey.trim().length > 0;
}
function parseTranscriptMessageLine(line) {
let parsed;
try {
parsed = JSON.parse(line);
} catch {
return null;
}
const record = asNullableObjectRecord(parsed);
if (!record) return null;
const message = asNullableObjectRecord(record.message) ?? record;
const role = message.role;
if (typeof role !== "string") return null;
return {
role,
content: message.content
};
}
function accumulateTranscriptHeartbeatMessage(summary, line) {
const trimmed = line.trim();
if (!trimmed) return;
const message = parseTranscriptMessageLine(trimmed);
if (!message) return;
summary.inspectedMessages += 1;
if (message.role === "user") {
summary.userMessages += 1;
if (isHeartbeatUserMessage(message)) summary.heartbeatUserMessages += 1;
else summary.nonHeartbeatUserMessages += 1;
return;
}
if (message.role === "assistant") {
summary.assistantMessages += 1;
if (isHeartbeatOkResponse(message)) summary.heartbeatOkAssistantMessages += 1;
}
}
/**
* Scans a transcript JSONL file in fixed-size chunks so doctor repair never loads
* the whole file into a single string (large poisoned heartbeat transcripts).
*
* Incomplete lines are retained only up to TRANSCRIPT_RECORD_MAX_CHARS; larger
* records decline classification so repair stays fail-closed.
*/
function scanTranscriptHeartbeatMessages(transcriptPath) {
let fd;
try {
fd = fs.openSync(transcriptPath, "r");
} catch {
return null;
}
const summary = {
inspectedMessages: 0,
userMessages: 0,
heartbeatUserMessages: 0,
nonHeartbeatUserMessages: 0,
assistantMessages: 0,
heartbeatOkAssistantMessages: 0
};
try {
const decoder = new StringDecoder("utf8");
const chunk = Buffer.alloc(TRANSCRIPT_SCAN_CHUNK_BYTES);
let carry = "";
for (;;) {
const bytesRead = fs.readSync(fd, chunk, 0, chunk.length, null);
if (bytesRead <= 0) break;
carry += decoder.write(chunk.subarray(0, bytesRead));
let newline = carry.indexOf("\n");
while (newline >= 0) {
if (newline > TRANSCRIPT_RECORD_MAX_CHARS) return "record-too-large";
const line = carry.slice(0, newline).replace(/\r$/, "");
carry = carry.slice(newline + 1);
accumulateTranscriptHeartbeatMessage(summary, line);
newline = carry.indexOf("\n");
}
if (carry.length > TRANSCRIPT_RECORD_MAX_CHARS) return "record-too-large";
}
carry += decoder.end();
if (carry.length > TRANSCRIPT_RECORD_MAX_CHARS) return "record-too-large";
if (carry) accumulateTranscriptHeartbeatMessage(summary, carry.replace(/\r$/, ""));
} finally {
fs.closeSync(fd);
}
return summary.inspectedMessages > 0 ? summary : null;
}
function summarizeTranscriptHeartbeatMessages(transcriptPath) {
const scan = scanTranscriptHeartbeatMessages(transcriptPath);
return scan === "record-too-large" ? null : scan;
}
/**
* Detects main-session entries that are safe to archive because they only contain heartbeat turns.
*
* Metadata ownership is preferred, but transcript inspection catches older stores that lack the
* heartbeat isolation marker while still containing no human user messages.
*/
function resolveHeartbeatMainSessionRepairCandidate(params) {
const { entry, transcriptPath } = params;
if (!entry) return null;
if (!(entry.lastInteractionAt === void 0)) return null;
const hasSyntheticHeartbeatOwnership = sessionEntryHasSyntheticHeartbeatOwnership(entry);
if (hasSyntheticHeartbeatOwnership && !transcriptPath) return { reason: "metadata" };
if (!transcriptPath) return null;
const summary = scanTranscriptHeartbeatMessages(transcriptPath);
if (summary === "record-too-large") return { declineReason: "record-too-large" };
if (!summary) return null;
if (summary.heartbeatUserMessages > 0 && summary.userMessages === summary.heartbeatUserMessages && summary.nonHeartbeatUserMessages === 0) return {
reason: hasSyntheticHeartbeatOwnership ? "metadata" : "transcript",
summary
};
return null;
}
function resolveHeartbeatMainRecoveryKey(params) {
const parsed = parseAgentSessionKey(params.mainKey);
if (!parsed) return null;
const stamp = formatSessionArchiveTimestamp(params.nowMs).toLowerCase();
const base = `agent:${parsed.agentId}:heartbeat-recovered-${stamp}`;
if (!params.isSessionKeyOccupied(base)) return base;
for (let index = 2; index <= 100; index += 1) {
const candidate = `${base}-${index}`;
if (!params.isSessionKeyOccupied(candidate)) return candidate;
}
return null;
}
/** Moves a poisoned main-session entry to a recovery key without overwriting existing entries. */
function moveHeartbeatMainSessionEntry(params) {
const entry = params.store[params.mainKey];
if (!entry || params.store[params.recoveredKey]) return false;
params.store[params.recoveredKey] = entry;
delete params.store[params.mainKey];
return true;
}
if (process.env.VITEST || false) globalThis[Symbol.for("openclaw.doctorHeartbeatMainSessionRepairTestApi")] = {
TRANSCRIPT_RECORD_MAX_CHARS,
moveHeartbeatMainSessionEntry,
resolveHeartbeatMainSessionRepairCandidate,
summarizeTranscriptHeartbeatMessages
};
/**
* Prompts to archive a heartbeat-owned main session and clears stale TUI restore state.
*
* The session store is rechecked inside the update transaction so concurrent session activity
* prevents moving a newly-human main session.
*/
async function repairHeartbeatPoisonedMainSession(params) {
const mainKey = params.mainKey;
const mainEntry = params.mainEntry;
if (!mainEntry?.sessionId) return false;
let transcriptPath;
try {
transcriptPath = resolveSessionFilePathCore(mainEntry.sessionId, mainEntry, params.sessionPathOpts);
} catch {
transcriptPath = void 0;
}
if (transcriptPath && !fs.existsSync(transcriptPath)) transcriptPath = void 0;
const candidate = resolveHeartbeatMainSessionRepairCandidate({
entry: mainEntry,
transcriptPath
});
if (!candidate) return false;
if ("declineReason" in candidate) {
params.warnings.push(`- Skipped heartbeat main-session recovery for ${mainKey}: the transcript contains a JSONL record larger than ${TRANSCRIPT_RECORD_MAX_CHARS} characters, so doctor left it unchanged.`);
return false;
}
const recoveredKey = resolveHeartbeatMainRecoveryKey({
mainKey,
isSessionKeyOccupied: params.isSessionKeyOccupied
});
if (!recoveredKey) {
params.warnings.push(`- Main session ${mainKey} appears heartbeat-owned, but doctor could not choose a safe recovery key.`);
return false;
}
const reason = candidate.reason === "metadata" ? "heartbeat metadata" : `${candidate.summary?.heartbeatUserMessages ?? 0} heartbeat-only user message(s)`;
params.warnings.push([`- Main session ${mainKey} appears to be a heartbeat-owned session (${reason}).`, ` Doctor can move it to ${recoveredKey} and let the next interactive launch create a fresh main session.`].join("\n"));
if (!await params.prompter.confirmRuntimeRepair({
message: `Move heartbeat-owned main session ${mainKey} to ${recoveredKey} and clear stale TUI restore pointers?`,
initialValue: true
})) return false;
let movedEntry;
if (params.store.kind === "sqlite") {
if ((await applySessionEntryLifecycleMutation({
agentId: params.store.agentId,
removals: [{
archiveRemovedTranscript: false,
expectedEntry: mainEntry,
sessionKey: mainKey
}],
skipMaintenance: true,
storePath: params.store.path,
upserts: [{
entry: mainEntry,
requiresRemovalSessionKey: mainKey,
sessionKey: recoveredKey
}]
})).removedSessionKeys.includes(mainKey)) movedEntry = mainEntry;
} else await updateLegacySessionStore(params.store.path, (currentStore) => {
const currentEntry = currentStore[mainKey];
const currentCandidate = resolveHeartbeatMainSessionRepairCandidate({
entry: currentEntry,
transcriptPath
});
if (!currentCandidate || "declineReason" in currentCandidate) return;
if (moveHeartbeatMainSessionEntry({
store: currentStore,
mainKey,
recoveredKey
})) movedEntry = currentEntry;
});
if (!movedEntry) {
params.warnings.push(`- Main session ${mainKey} changed before repair could move it.`);
return false;
}
let clearedPointers = 0;
try {
clearedPointers = clearTuiLastSessionPointers({
stateDir: params.stateDir,
sessionKeys: /* @__PURE__ */ new Set([mainKey])
});
} catch (error) {
params.warnings.push(`- Moved heartbeat-owned main session ${mainKey}, but could not clear its TUI restore pointers: ${String(error)}`);
}
params.changes.push(`- Moved heartbeat-owned main session ${mainKey} to ${recoveredKey}.`);
if (clearedPointers > 0) params.changes.push(`- Cleared ${countLabel(clearedPointers, "stale TUI last-session pointer")} for ${mainKey}.`);
return true;
}
//#endregion
//#region src/commands/doctor-heartbeat-session-target.ts
/** Doctor warnings for heartbeat.session values that resolve to missing delivery sessions. */
/**
* Detect heartbeat configs that pin a non-existent session. The runtime
* resolves `heartbeat.session` to a sessionKey via `resolveHeartbeatSession`;
* a missing last route skips before the model, while a missing explicit target
* runs and drops its reply. Common cause: the configured Slack
* channel ID does not match any channel the agent has ever joined (e.g.,
* heartbeat pins channel `c0b2eddpw95` but the agent only has sessions in
* `c0ag7jag35g`, or the agent has no Slack bot at all).
*
* Warning only — repair would mean rewriting the config, which is the
* operator's intent to express.
*/
function describeHeartbeatSessionTargetIssues(cfg) {
const warnings = [];
const sessionScope = cfg.session?.scope ?? "per-sender";
for (const { agentId, heartbeat: heartbeatConfig } of resolveHeartbeatAgents(cfg)) {
if (!heartbeatConfig) continue;
if (!resolveHeartbeatIntervalMs(cfg, void 0, heartbeatConfig)) continue;
const configuredSession = normalizeOptionalString(heartbeatConfig.session);
if (!configuredSession) continue;
const normalizedSession = configuredSession.toLowerCase();
if (normalizedSession === "main" || normalizedSession === "global") continue;
if (isSubagentSessionKey(configuredSession)) continue;
if (sessionScope === "global") continue;
const target = normalizeOptionalString(heartbeatConfig.target);
if (target === "none") continue;
const deliveryWithoutSession = resolveHeartbeatDeliveryTarget({
cfg,
agentId,
heartbeat: heartbeatConfig
});
if (deliveryWithoutSession.channel !== "none" && deliveryWithoutSession.to) continue;
const candidateSession = toAgentStoreSessionKey({
agentId,
requestKey: configuredSession,
mainKey: cfg.session?.mainKey
});
if (isSubagentSessionKey(candidateSession)) continue;
const canonicalSession = canonicalizeMainSessionAlias({
cfg,
agentId,
sessionKey: candidateSession
});
if (canonicalSession === "global" || isSubagentSessionKey(canonicalSession) || resolveAgentIdFromSessionKey(canonicalSession) !== agentId) continue;
const storePath = resolveSessionStorePathCore(cfg.session?.store, { agentId });
if (loadSessionEntryReadOnly({
agentId,
sessionKey: canonicalSession,
storePath
}) ?? (!storePath.endsWith(".sqlite") && fs.existsSync(storePath) ? loadLegacySessionStore(storePath)[canonicalSession] : void 0)) continue;
const databasePath = resolveSqliteTargetFromSessionStorePath(storePath, { agentId }).path;
const ownerTarget = target === void 0 || target === "owner";
const missingRouteOutcome = ownerTarget ? ` Heartbeats will skip with reason="no-route" until a configured owner resolves to a direct message.` : deliveryWithoutSession.reason === "no-route" ? ` Heartbeats will skip with reason="no-route" until that session has a delivery route.` : ` Heartbeats will run but resolve delivery to channel="none"/reason="no-target", so replies are dropped.`;
const fix = ownerTarget ? ` Fix: set commands.ownerAllowFrom or a channel allowFrom to a direct-message owner, set heartbeat.target="none", or choose an explicit heartbeat target.` : ` Fix: point heartbeat.session at a session the agent actually owns, set heartbeat.target="none" to suppress delivery, or remove the heartbeat.session field to fall back to the agent main session.`;
warnings.push([
`- Agent ${agentId} heartbeat.session pins ${configuredSession} (resolved to ${canonicalSession}) but that session has no entry in ${databasePath}.`,
missingRouteOutcome,
fix
].join("\n"));
}
return warnings;
}
//#endregion
//#region src/commands/doctor-main-session-recovery.ts
function inspectMainSessionRecoveryEntry(key, entry) {
const internalEntry = entry;
const tombstone = internalEntry.mainRestartRecovery?.tombstone;
return tombstone ? {
clearStaleAbort: internalEntry.abortedLastRun === true,
key,
reason: tombstone.reason.trim() || "main-session restart recovery is tombstoned for this session"
} : void 0;
}
async function noteMainSessionRecoveryIntegrity(params) {
const { wedged } = params;
if (wedged.length === 0) return;
const wedgedCount = params.countLabel(wedged.length, "wedged main session");
params.warnings.push([
`- Found ${wedgedCount} with automatic restart recovery tombstoned.`,
" OpenClaw will not auto-resume these sessions again; inspect the failed turn, then use /new or reset to replace the session.",
` Examples: ${wedged.slice(0, 3).map(({ key }) => key).join(", ")}`
].join("\n"));
const visibleReasons = uniqueStrings(wedged.map(({ reason }) => reason)).slice(0, 2);
if (visibleReasons.length > 0) params.warnings.push(visibleReasons.map((reason) => ` Reason: ${reason}`).join("\n"));
const staleAborted = wedged.filter(({ clearStaleAbort }) => clearStaleAbort);
if (staleAborted.length === 0) return;
const staleCount = params.countLabel(staleAborted.length, "wedged main session");
if (!await params.confirmRepair({
message: `Clear stale aborted recovery flags for ${staleCount}?`,
initialValue: true
})) return;
const repairedAt = Date.now();
let repaired = 0;
for (const sessionKeys of iterateDoctorSessionKeyBatches(staleAborted.map(({ key }) => key))) repaired += await applySessionEntryReplacements({
consumePendingReset: true,
sessionKeys,
storePath: params.storePath,
update: (currentEntries) => {
const replacements = currentEntries.flatMap(({ sessionKey, entry }) => {
return transitionMainSessionRecovery(entry, {
kind: "doctor_repair",
now: repairedAt
}).kind === "doctor_repaired" ? [{
sessionKey,
entry
}] : [];
});
return {
replacements,
result: replacements.length
};
}
});
if (repaired > 0) params.changes.push(`- Cleared aborted restart-recovery flags for ${params.countLabel(repaired, "wedged main session")}.`);
}
//#endregion
//#region src/commands/doctor-session-state-providers.ts
/** Doctor repair for stale plugin-owned routing state persisted in session entries. */
function normalizeIdSet(values) {
return new Set((values ?? []).map((value) => normalizeProviderId(value)));
}
function normalizePrefixList(values) {
return normalizeStringEntriesLower(values);
}
function ownsPrefixedValue(prefixes, value) {
const normalized = normalizeOptionalString(value)?.toLowerCase();
return normalized !== void 0 && prefixes.some((prefix) => normalized.startsWith(prefix));
}
function countSessionLabel(count) {
return countLabel(count, "session");
}
function repairExample(repair) {
return `${repair.key} (${repair.reasons.join(", ")})`;
}
function resolveSessionAgentId(cfg, sessionKey, storeAgentId) {
return parseAgentSessionKey(sessionKey)?.agentId ?? storeAgentId ?? tryResolveDefaultAgentId(cfg);
}
/** Resolves the currently configured provider/model/runtime route for a session key. */
function resolveConfiguredDoctorSessionStateRoute(params) {
const agentId = resolveSessionAgentId(params.cfg, params.sessionKey, params.agentId);
if (!agentId) return;
const primary = resolveDefaultModelForAgent({
cfg: params.cfg,
agentId
});
const configuredModelRefs = /* @__PURE__ */ new Set();
const addRef = (provider, model) => {
configuredModelRefs.add(modelKey(provider, model));
};
addRef(primary.provider, primary.model);
const fallbacks = resolveAgentModelFallbacksOverride(params.cfg, agentId) ?? resolveAgentModelFallbackValues(params.cfg.agents?.defaults?.model);
for (const fallback of fallbacks) {
const parsed = parseModelRef(fallback, primary.provider, {
allowManifestNormalization: false,
allowPluginNormalization: false
});
if (parsed) addRef(parsed.provider, parsed.model);
}
const runtime = resolveAgentHarnessPolicy({
provider: primary.provider,
modelId: primary.model,
config: params.cfg,
agentId,
sessionKey: params.sessionKey
}).runtime;
return {
defaultProvider: primary.provider,
configuredModelRefs: [...configuredModelRefs],
runtime
};
}
function resolvePluginDoctorSessionRouteStateOwners(params) {
return listPluginDoctorSessionRouteStateOwners({
config: params.cfg,
env: params.env
});
}
function entryMayContainPluginSessionRouteState(sessionKey, entry) {
if (isValidAgentHarnessSessionStoreEntry(sessionKey, entry)) return false;
if (!isRecord(entry)) return false;
const record = entry;
return normalizeOptionalString(record.providerOverride) !== void 0 || normalizeOptionalString(record.modelOverride) !== void 0 || normalizeOptionalString(record.modelOverrideSource) !== void 0 || record.liveModelSwitchPending !== void 0 || normalizeOptionalString(record.modelProvider) !== void 0 || normalizeOptionalString(record.model) !== void 0 || normalizeOptionalString(record.agentHarnessId) !== void 0 || normalizeOptionalString(record.agentRuntimeOverride) !== void 0 || record.cliSessionBindings !== void 0 || record.cliSessionIds !== void 0 || normalizeOptionalString(record.authProfileOverride) !== void 0 || normalizeOptionalString(record.authProfileOverrideSource) !== void 0;
}
function resolvePersistedOverrideModelRef(params) {
const overrideModel = normalizeOptionalString(params.overrideModel);
if (!overrideModel) return null;
const overrideProvider = normalizeOptionalString(params.overrideProvider);
return parseModelRef(overrideProvider ? `${overrideProvider}/${overrideModel}` : overrideModel, params.defaultProvider, {
allowManifestNormalization: false,
allowPluginNormalization: false
});
}
function addReason(reasons, reason) {
if (!reasons.includes(reason)) reasons.push(reason);
}
function routeAllowsOwnerState(params) {
const providerIds = normalizeIdSet(params.owner.providerIds);
const runtimeIds = normalizeIdSet(params.owner.runtimeIds);
const routeRuntime = normalizeOptionalString(params.route?.runtime);
if (routeRuntime && runtimeIds.has(normalizeProviderId(routeRuntime))) return true;
return params.route?.configuredModelRefs.some((ref) => {
const slash = ref.indexOf("/");
return slash > 0 && providerIds.has(normalizeProviderId(ref.slice(0, slash)));
}) ?? false;
}
function hasOwnedCliSession(params) {
const bindings = params.entry.cliSessionBindings;
const ids = params.entry.cliSessionIds;
return params.cliSessionKeys.some((key) => {
const normalized = normalizeProviderId(key);
return bindings !== null && typeof bindings === "object" && normalized in bindings && bindings[normalized] !== void 0 || ids !== null && typeof ids === "object" && normalized in ids && ids[normalized] !== void 0;
});
}
function modelRefKey(provider, model) {
return modelKey(provider, model).toLowerCase();
}
function scanEntryForOwner(params) {
const providerIds = normalizeIdSet(params.owner.providerIds);
const runtimeIds = normalizeIdSet(params.owner.runtimeIds);
const cliSessionKeys = [...normalizeIdSet(params.owner.cliSessionKeys)];
const authProfilePrefixes = normalizePrefixList(params.owner.authProfilePrefixes);
const routeAllowsOwner = routeAllowsOwnerState({
owner: params.owner,
route: params.route
});
const routeRuntime = normalizeOptionalString(params.route?.runtime);
const routeAllowsOwnerRuntime = routeRuntime !== void 0 && runtimeIds.has(normalizeProviderId(routeRuntime));
const reasons = [];
const pinnedRuntimeKeys = [];
const directOverride = resolvePersistedOverrideModelRef({
defaultProvider: params.route?.defaultProvider ?? "",
overrideProvider: params.entry.providerOverride,
overrideModel: params.entry.modelOverride
});
const directOverrideKey = directOverride ? modelRefKey(directOverride.provider, directOverride.model) : void 0;
const directOverrideIsOwned = directOverride !== null && providerIds.has(normalizeProviderId(directOverride.provider));
const directOverrideIsConfigured = directOverrideKey !== void 0 && (params.route?.configuredModelRefs.some((ref) => ref.toLowerCase() === directOverrideKey) ?? false);
const directOverrideSource = params.entry.modelOverrideSource === "user" ? "user" : params.entry.modelOverrideSource === "auto" ? "auto" : params.entry.modelOverride ? "legacy" : void 0;
if (directOverrideIsOwned && !directOverrideIsConfigured) {
if (directOverrideSource === "auto") addReason(reasons, "auto model override");
else if (!routeAllowsOwner && directOverride) return { manualReview: {
key: params.key,
ownerLabel: params.owner.label,
message: `${params.key} (${modelRefKey(directOverride.provider, directOverride.model)}, ${directOverrideSource === "user" ? "user" : "legacy"})`
} };
}
const explicitOwnedOverride = directOverrideIsOwned && directOverrideSource !== void 0 && directOverrideSource !== "auto";
if (!routeAllowsOwnerRuntime && !explicitOwnedOverride) {
const harnessId = normalizeOptionalString(params.entry.agentHarnessId);
if (harnessId && runtimeIds.has(normalizeProviderId(harnessId))) {
addReason(reasons, "pinned runtime");
pinnedRuntimeKeys.push("agentHarnessId");
}
const runtimeOverride = normalizeOptionalString(params.entry.agentRuntimeOverride);
if (runtimeOverride && runtimeIds.has(normalizeProviderId(runtimeOverride))) {
addReason(reasons, "pinned runtime");
pinnedRuntimeKeys.push("agentRuntimeOverride");
}
}
if (!routeAllowsOwner && !explicitOwnedOverride) {
const runtimeModel = normalizeOptionalString(params.entry.model);
const runtimeRef = runtimeModel ? parseModelRef(runtimeModel, normalizeOptionalString(params.entry.modelProvider) ?? "", {
allowManifestNormalization: false,
allowPluginNormalization: false
}) : null;
if (runtimeRef && providerIds.has(normalizeProviderId(runtimeRef.provider))) addReason(reasons, "runtime model state");
if (hasOwnedCliSession({
entry: params.entry,
cliSessionKeys
})) addReason(reasons, "CLI session binding");
if (params.entry.authProfileOverrideSource === "auto" && ownsPrefixedValue(authProfilePrefixes, params.entry.authProfileOverride)) addReason(reasons, "auto auth profile override");
}
if (reasons.length === 0) return {};
return { repair: {
key: params.key,
ownerId: params.owner.id,
ownerLabel: params.owner.label,
reasons,
pinnedRuntimeKeys,
cliSessionKeys
} };
}
/** Streams session entries into compact plugin-owned route-state findings. */
function createPluginSessionStateDoctorScanner(params) {
const repairs = [];
const manualReview = [];
let owners;
const routeByAgentId = /* @__PURE__ */ new Map();
return {
scanEntry(key, entry) {
if (!entryMayContainPluginSessionRouteState(key, entry)) return;
if (!isRecord(entry)) return;
owners ??= resolvePluginDoctorSessionRouteStateOwners(params);
if (owners.length === 0) return;
const agentId = resolveSessionAgentId(params.cfg, key, params.agentId);
if (!agentId) return;
let route = routeByAgentId.get(agentId);
if (!route) {
route = resolveConfiguredDoctorSessionStateRoute({
agentId,
cfg: params.cfg,
sessionKey: key,
env: params.env
});
if (!route) return;
routeByAgentId.set(agentId, route);
}
for (const owner of owners) {
const scan = scanEntryForOwner({
key,
entry,
owner,
route
});
if (scan.repair) repairs.push(scan.repair);
if (scan.manualReview) manualReview.push(scan.manualReview);
}
},
result() {
return {
repairs,
manualReview
};
}
};
}
function clearEntryKey(entry, key) {
if (entry[key] !== void 0) {
delete entry[key];
return true;
}
return false;
}
function clearRecordKeys(entry, recordKey, ownedKeys) {
const value = entry[recordKey];
if (value === null || typeof value !== "object") return false;
const record = value;
let changed = false;
const next = { ...record };
for (const key of ownedKeys) {
const normalized = normalizeProviderId(key);
if (next[normalized] !== void 0) {
delete next[normalized];
changed = true;
}
}
if (!changed) return false;
entry[recordKey] = Object.keys(next).length > 0 ? next : void 0;
return true;
}
/** Clears stale plugin-owned routing fields from a session entry and refreshes updatedAt. */
function applySessionRouteStateRepair(params) {
if (isValidAgentHarnessSessionStoreEntry(params.sessionKey, params.entry)) return false;
let changed = false;
const clear = (key) => {
changed = clearEntryKey(params.entry, key) || changed;
};
if (params.repair.reasons.includes("auto model override")) {
clear("providerOverride");
clear("modelOverride");
clear("modelOverrideSource");
clear("modelOverrideFallbackOriginProvider");
clear("modelOverrideFallbackOriginModel");
clear("modelOverrideRouteResolution");
clear("liveModelSwitchPending");
}
if (params.repair.reasons.includes("runtime model state")) {
clear("model");
clear("modelProvider");
clear("contextTokens");
clear("systemPromptReport");
clear("fallbackNotice");
}
if (params.repair.reasons.includes("pinned runtime")) for (const key of params.repair.pinnedRuntimeKeys) clear(key);
if (params.repair.reasons.includes("CLI session binding")) {
changed = clearRecordKeys(params.entry, "cliSessionBindings", params.repair.cliSessionKeys) || changed;
changed = clearRecordKeys(params.entry, "cliSessionIds", params.repair.cliSessionKeys) || changed;
}
if (params.repair.reasons.includes("auto auth profile override")) {
clear("authProfileOverride");
clear("authProfileOverrideSource");
clear("authProfileOverrideCompactionCount");
}
if (changed) params.entry.updatedAt = params.now;
return changed;
}
function groupRepairsByOwner(repairs) {
const grouped = /* @__PURE__ */ new Map();
for (const repair of repairs) {
const key = repair.ownerLabel;
grouped.set(key, [...grouped.get(key) ?? [], repair]);
}
return grouped;
}
/** Prompts for and applies plugin-owned session route state repairs to the session store. */
async function runPluginSessionStateDoctorRepairs(params) {
const { scan } = params;
if (scan.repairs.length > 0) for (const [ownerLabel, repairs] of groupRepairsByOwner(scan.repairs)) {
const staleCount = countSessionLabel(repairs.length);
params.warnings.push([
`- Found stale ${ownerLabel} session routing state in ${staleCount} outside the current configured model/runtime route.`,
" This can keep later message-channel runs pinned to an old runtime/provider after defaults move elsewhere.",
` Examples: ${repairs.slice(0, 3).map(repairExample).join(", ")}`
].join("\n"));
if (await params.prompter.confirmRuntimeRepair({
message: `Clear stale ${ownerLabel} session routing state for ${staleCount}?`,
initialValue: true
})) {
let repaired = 0;
const repairedAt = Date.now();
const repairsByKey = new Map(repairs.map((repair) => [repair.key, repair]));
if (params.store.kind === "sqlite") repaired = await applySessionEntryReplacements({
agentId: params.store.agentId,
sessionKeys: [...repairsByKey.keys()],
storePath: params.store.path,
update: (currentEntries) => {
const replacements = currentEntries.flatMap(({ entry, sessionKey }) => {
const repair = repairsByKey.get(sessionKey);
return repair && isRecord(entry) && applySessionRouteStateRepair({
sessionKey,
entry,
repair,
now: repairedAt
}) ? [{
entry,
sessionKey
}] : [];
});
return {
replacements,
result: replacements.length
};
}
});
else await updateLegacySessionStore(params.store.path, (currentStore) => {
for (const [key, repair] of repairsByKey) {
const current = currentStore[key];
if (isRecord(current) && applySessionRouteStateRepair({
sessionKey: key,
entry: current,
repair,
now: repairedAt
})) repaired += 1;
}
});
if (repaired > 0) params.changes.push(`- Cleared stale ${ownerLabel} session routing state for ${countSessionLabel(repaired)}.`);
}
}
if (scan.manualReview.length > 0) {
const grouped = /* @__PURE__ */ new Map();
for (const hit of scan.manualReview) grouped.set(hit.ownerLabel, [...grouped.get(hit.ownerLabel) ?? [], hit]);
for (const [ownerLabel, hits] of grouped) params.warnings.push([
`- Found explicit ${ownerLabel} model overrides in ${countSessionLabel(hits.length)} outside the current configured route.`,
" Doctor leaves explicit or legacy user selections untouched; switch them with /model or reset the session if that provider is no longer intended.",
` Examples: ${hits.slice(0, 3).map((hit) => hit.message).join(", ")}`
].join("\n"));
}
}
//#endregion
//#region src/commands/doctor-unconfigured-agent-databases.ts
function isDefaultAgentDatabasePath(pathname, stateDir) {
const segments = path.relative(stateDir, pathname).split(path.sep);
return segments.length === 4 && segments[0] === "agents" && segments[2] === "agent" && segments[3] === "openclaw-agent.sqlite";
}
/** Report retained stores without turning roster absence into deletion authority. */
function collectRetainedUnconfiguredAgentDatabaseWarnings(params) {
const env = params.env ?? process.env;
try {
const stateDir = fs.realpathSync.native(resolveStateDir(env));
const registeredAgentDatabases = listOpenClawRegisteredAgentDatabases({
env,
includeIncompatibleSchemaVersions: true
});
const configuredAgentDatabaseTargets = resolveConfiguredAgentDatabaseTargets(params.cfg, {
env,
registeredDatabases: registeredAgentDatabases
});
const discovery = discoverAgentDatabaseMigrationTargets({
configuredAgentDatabaseTargets,
registeredAgentDatabases,
env
});
return [...discovery.targets.flatMap((target) => {
if (target.source === "configured" || isDefaultAgentDatabasePath(target.realPath, stateDir)) return [];
return [`- Retained unconfigured agent database "${sanitizeForLog(target.agentId)}" at ${sanitizeForLog(target.path)}. Doctor will not remove it automatically because it may contain retired or manually managed agent state.`];
}), ...discovery.externalWarnings];
} catch (error) {
return [`- Could not inspect retained unconfigured agent databases: ${sanitizeForLog(formatErrorMessage(error))}`];
}
}
//#endregion
//#region src/commands/doctor-state-integrity.ts
/** Doctor checks and repairs for state dir durability, sessions, transcripts, and credentials. */
const STATE_INTEGRITY_CHECK_ID = "core/doctor/state-integrity";
function existsDir(dir) {
try {
return fs.existsSync(dir) && fs.statSync(dir).isDirectory();
} catch {
return false;
}
}
function existsFile(filePath) {
try {
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
} catch {
return false;
}
}
function tryResolveNativeRealPath(targetPath) {
try {
return fs.realpathSync.native(targetPath);
} catch {
return null;
}
}
function areComparablePathsEqual(leftPath, rightPath) {
const leftRealPath = tryResolveNativeRealPath(leftPath);
const rightRealPath = tryResolveNativeRealPath(rightPath);
return leftRealPath !== null && leftRealPath === rightRealPath;
}
function isReachableConfiguredAgentDir(params) {
if (params.dirName === params.agentId) return true;
const rawDir = path.join(params.agentsRoot, params.dirName, "agent");
const normalizedDir = path.join(params.agentsRoot, params.agentId, "agent");
const rawRealPath = tryResolveNativeRealPath(rawDir);
const normalizedRealPath = tryResolveNativeRealPath(normalizedDir);
return rawRealPath !== null && rawRealPath === normalizedRealPath;
}
function formatOrphanAgentDirLabel(entry) {
return entry.dirName === entry.agentId ? entry.agentId : `${entry.dirName} (id ${entry.agentId})`;
}
function formatOrphanAgentDirPreview(entries, limit = 3) {
const labels = entries.slice(0, limit).map(formatOrphanAgentDirLabel);
const remaining = entries.length - labels.length;
if (remaining > 0) return `${labels.join(", ")}, and ${remaining} more`;
return labels.join(", ");
}
function listOrphanAgentDirs(cfg, stateDir) {
const configuredIds = new Set(listAgentIds(cfg));
const sharedAuthOwnership = resolveSharedAuthStoreOwnership();
const sharedAuthDbPath = resolveSharedAuthStorePath();
const defaultAgentId = tryResolveDefaultAgentId(cfg);
const agentsRoot = path.join(stateDir, "agents");
const liveDefaultAgentDir = defaultAgentId ? resolveDefaultAgentDir(cfg) : void 0;
try {
return fs.readdirSync(agentsRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => ({
dirName: entry.name,
agentId: normalizeAgentId(entry.name)
})).filter(({ dirName, agentId }) => {
const nestedAgentDir = path.join(agentsRoot, dirName, "agent");
if (!existsDir(nestedAgentDir)) return false;
if (isSharedAuthStoreOwner({
ownership: sharedAuthOwnership,
agentAuthDbPath: resolveAuthProfileDatabasePath(nestedAgentDir),
sharedAuthDbPath
})) return false;
if (liveDefaultAgentDir && areComparablePathsEqual(nestedAgentDir, liveDefaultAgentDir)) return false;
if (!configuredIds.has(agentId)) return true;
return !isReachableConfiguredAgentDir({
agentsRoot,
dirName,
agentId
});
}).toSorted((left, right) => left.agentId.localeCompare(right.agentId) || left.dirName.localeCompare(right.dirName));
} catch {
return [];
}
}
function canWriteDir(dir) {
try {
fs.accessSync(dir, fs.constants.W_OK);
return true;
} catch {
return false;
}
}
function ensureDir(dir) {
try {
fs.mkdirSync(dir, { recursive: true });
return { ok: true };
} catch (err) {
return {
ok: false,
error: String(err)
};
}
}
function dirPermissionHint(dir) {
const uid = typeof process.getuid === "function" ? process.getuid() : null;
const gid = typeof process.getgid === "function" ? process.getgid() : null;
try {
const stat = fs.statSync(dir);
if (uid !== null && stat.uid !== uid) return `Owner mismatch (uid ${stat.uid}). Run: sudo chown -R $USER "${dir}"`;
if (gid !== null && stat.gid !== gid) return `Group mismatch (gid ${stat.gid}). If access fails, run: sudo chown -R $USER "${dir}"`;
} catch {
return null;
}
return null;
}
function addUserRwx(mode) {
return mode & 511 | 448;
}
function countJsonlLines(filePath) {
let fd;
try {
fd = fs.openSync(filePath, "r");
} catch {
return 0;
}
try {
const chunk = Buffer.alloc(65536);
let count = 0;
let hasBytes = false;
let endsWithNewline = false;
for (;;) {
const bytesRead = fs.readSync(fd, chunk, 0, chunk.length, null);
if (bytesRead <= 0) break;
hasBytes = true;
endsWithNewline = chunk[bytesRead - 1] === 10;
for (let index = 0; index < bytesRead; index += 1) if (chunk[index] === 10) count += 1;
}
if (hasBytes && !endsWithNewline) count += 1;
return count;
} catch {
return 0;
} finally {
fs.closeSync(fd);
}
}
function isPathUnderRoot(targetPath, rootPath) {
const normalizedTarget = path.resolve(targetPath);
const normalizedRoot = path.resolve(rootPath);
const rootToken = path.parse(normalizedRoot).root;
if (normalizedRoot === rootToken) return normalizedTarget.startsWith(rootToken);
return normalizedTarget === normalizedRoot || normalizedTarget.startsWith(`${normalizedRoot}${path.sep}`);
}
const tryResolveRealPath = safeRealpathSync;
function resolvePathThroughExistingAncestor(targetPath, resolveRealPath, pathOps) {
const missingSegments = [];
let candidate = pathOps.resolve(targetPath);
while (true) {
const resolved = resolveRealPath(candidate);
if (resolved) return pathOps.resolve(resolved, ...missingSegments);
const parent = pathOps.dirname(candidate);
if (parent === candidate) return null;
missingSegments.unshift(pathOps.basename(candidate));
candidate = parent;
}
}
function escapeControlCharsForTerminal(value) {
let escaped = "";
for (const char of value) {
if (char === "\x1B") {
escaped += "\\x1b";
continue;
}
if (char === "\r") {
escaped += "\\r";
continue;
}
if (char === "\n") {
escaped += "\\n";
continue;
}
if (char === " ") {
escaped += "\\t";
continue;
}
const code = char.charCodeAt(0);
if (code >= 0 && code <= 8 || code === 11 || code === 12 || code >= 14 && code <= 31) {
escaped += `\\x${code.toString(16).padStart(2, "0")}`;
continue;
}
if (code === 127) {
escaped += "\\x7f";
continue;
}
escaped += char;
}
return escaped;
}
function parseLinuxMountInfo(rawMountInfo) {
const entries = [];
for (const line of rawMountInfo.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const separatorIndex = trimmed.indexOf(" - ");
if (separatorIndex === -1) continue;
const left = trimmed.slice(0, separatorIndex);
const right = trimmed.slice(separatorIndex + 3);
const leftFields = left.split(" ");
const rightFields = right.split(" ");
if (leftFields.length < 5 || rightFields.length < 2) continue;
entries.push({
mountPoint: decodeMountInfoPath(expectDefined(leftFields[4], "left fields entry at 4")),
fsType: expectDefined(rightFields[0], "right fields entry at 0"),
source: decodeMountInfoPath(expectDefined(rightFields[1], "right fields entry at 1"))
});
}
return entries;
}
function isPathUnderRootWithPathOps(targetPath, rootPath, pathOps) {
const normalizedTarget = pathOps.resolve(targetPath);
const normalizedRoot = pathOps.resolve(rootPath);
const rootToken = pathOps.parse(normalizedRoot).root;
if (normalizedRoot === rootToken) return normalizedTarget.startsWith(rootToken);
return normalizedTarget === normalizedRoot || normalizedTarget.startsWith(`${normalizedRoot}${pathOps.sep}`);
}
function findLinuxMountInfoEntryForPath(targetPath, entries, pathOps) {
const normalizedTarget = pathOps.resolve(targetPath);
let bestMatch = null;
for (const entry of entries) {
if (!isPathUnderRootWithPathOps(normalizedTarget, entry.mountPoint, pathOps)) continue;
if (!bestMatch || pathOps.resolve(entry.mountPoint).length > pathOps.resolve(bestMatch.mountPoint).length) bestMatch = entry;
}
return bestMatch;
}
function isMmcDevicePath(devicePath, pathOps) {
const name = pathOps.basename(devicePath);
return /^mmcblk\d+(?:p\d+)?$/.test(name);
}
function tryReadLinuxMountInfo() {
try {
return fs.readFileSync("/proc/self/mountinfo", "utf8");
} catch {
return null;
}
}
function resolveLinuxStateMount(stateDir, deps) {
const linuxPath = path.posix;
const resolvedStatePath = resolvePathThroughExistingAncestor(stateDir, deps?.resolveRealPath ?? tryResolveRealPath, linuxPath) ?? linuxPath.resolve(stateDir);
const mountInfo = deps?.mountInfo ?? tryReadLinuxMountInfo();
const mountEntry = mountInfo ? findLinuxMountInfoEntryForPath(resolvedStatePath, parseLinuxMountInfo(mountInfo), linuxPath) : null;
return mountEntry ? {
path: linuxPath.resolve(resolvedStatePath),
mountPoint: linuxPath.resolve(mountEntry.mountPoint),
fsType: mountEntry.fsType,
source: mountEntry.source
} : null;
}
/** Detects Linux state directories mounted from SD/eMMC-style block devices. */
function detectLinuxSdBackedStateDir(stateDir, deps) {
if ((deps?.platform ?? process.platform) !== "linux") return null;
const linuxPath = path.posix;
const stateMount = resolveLinuxStateMount(stateDir, deps);
if (!stateMount) return null;
const sourceCandidates = [stateMount.source];
if (stateMount.source.startsWith("/dev/")) {
const resolvedDevicePath = (deps?.resolveDeviceRealPath ?? tryResolveRealPath)(stateMount.source);
if (resolvedDevicePath) sourceCandidates.push(linuxPath.resolve(resolvedDevicePath));
}
if (!sourceCandidates.some((candidate) => isMmcDevicePath(candidate, linuxPath))) return null;
return stateMount;
}
/** Formats the warning for state stored on SD/eMMC media. */
function formatLinuxSdBackedStateDirWarning(displayStateDir, linuxSdBackedStateDir) {
const displayMountPoint = linuxSdBackedStateDir.mountPoint === "/" ? "/" : shortenHomePath(linuxSdBackedStateDir.mountPoint);
return [
`- State directory appears to be on SD/eMMC storage (${displayStateDir}; device ${escapeControlCharsForTerminal(linuxSdBackedStateDir.source)}, fs ${escapeControlCharsForTerminal(linuxSdBackedStateDir.fsType)}, mount ${escapeControlCharsForTerminal(displayMountPoint)}).`,
"- SD/eMMC media can be slower for random I/O and wear faster under session/log churn.",
"- For better startup and state durability, prefer SSD/NVMe (or USB SSD on Raspberry Pi) for OPENCLAW_STATE_DIR."
].join("\n");
}
/** Filesystems whose state disappears on reboot. Docker overlayfs is intentionally excluded. */
const VOLATILE_FS_TYPES = /* @__PURE__ */ new Set(["tmpfs", "ramfs"]);
/** Detects Linux state directories mounted on filesystems that do not survive a reboot. */
function detectLinuxVolatileStateDir(stateDir, deps) {
if ((deps?.platform ?? process.platform) !== "linux") return null;
const stateMount = resolveLinuxStateMount(stateDir, deps);
if (!stateMount || !VOLATILE_FS_TYPES.has(stateMount.fsType)) return null;
const { source: _source, ...volatileStateMount } = stateMount;
return volatileStateMount;
}
/** Formats the warning for state stored on a volatile Linux filesystem. */
function formatLinuxVolatileStateDirWarning(displayStateDir, volatileDir) {
return [
`- State directory is on a volatile filesystem (${displayStateDir}; fs ${escapeControlCharsForTerminal(volatileDir.fsType)}, mount ${volatileDir.mountPoint === "/" ? "/" : escapeControlCharsForTerminal(shortenHomePath(volatileDir.mountPoint))}).`,
"- Sessions, credentials, config, and SQLite state (including WAL/journal sidecars) will be lost on reboot.",
"- Move OPENCLAW_STATE_DIR to a persistent filesystem to avoid data loss."
].join("\n");
}
/** Detects macOS state directories under iCloud Drive or CloudStorage providers. */
function detectMacCloudSyncedStateDir(stateDir, deps) {
if ((deps?.platform ?? process.platform) !== "darwin") return null;
const homedir = deps?.homedir ?? os.homedir();
const roots = [{
storage: "iCloud Drive",
root: path.join(homedir, "Library", "Mobile Documents", "com~apple~CloudDocs")
}, {
storage: "CloudStorage provider",
root: path.join(homedir, "Library", "CloudStorage")
}];
const resolvedStatePath = resolvePathThroughExistingAncestor(stateDir, deps?.resolveRealPath ?? tryResolveRealPath, path) ?? path.resolve(stateDir);
for (const { storage, root } of roots) if (isPathUnderRoot(resolvedStatePath, root)) return {
path: resolvedStatePath,
storage
};
return null;
}
function isPairingPolicy(value) {
return normalizeOptionalLowercaseString(value) === "pairing";
}
function hasPairingPolicy(value) {
const record = asNullableObjectRecord(value);
if (!record) return false;
if (isPairingPolicy(record.dmPolicy)) return true;
const dm = asNullableObjectRecord(record.dm);
if (dm && isPairingPolicy(dm.policy)) return true;
const accounts = asNullableObjectRecord(record.accounts);
if (!accounts) return false;
for (const accountCfg of Object.values(accounts)) if (hasPairingPolicy(accountCfg)) return true;
return false;
}
function shouldRequireOAuthDir(cfg, env) {
if (env.OPENCLAW_OAUTH_DIR?.trim()) return true;
const channels = asNullableObjectRecord(cfg.channels);
if (!channels) return false;
const withPersistedAuth = new Set(listConfiguredChannelIdsForReadOnlyScope({
config: cfg,
env
}));
const withoutPersistedAuth = new Set(listConfiguredChannelIdsForReadOnlyScope({
config: cfg,
env,
includePersistedAuthState: false
}));
if ([...withPersistedAuth].some((channelId) => !withoutPersistedAuth.has(channelId))) return true;
for (const [channelId, channelCfg] of Object.entries(channels)) {
if (channelId === "defaults" || channelId === "modelByChannel") continue;
if (hasPairingPolicy(channelCfg)) return true;
}
return false;
}
function detectStateIntegrityHealthIssues(cfg, params) {
const issues = [];
const env = params?.env ?? process.env;
const homedir = () => resolveRequiredHomeDir(env, params?.homedir ?? os.homedir);
const stateDir = resolveStateDir(env, homedir);
const oauthDir = resolveOAuthDir(env, stateDir);
const agentId = tryResolveDefaultAgentId(cfg);
const sessionsDir = agentId ? resolveSessionTranscriptsDirForAgent(agentId, env, homedir) : void 0;
const storePath = agentId ? resolveSessionStorePathCore(cfg.session?.store, { agentId }) : void 0;
const storeDir = storePath ? path.dirname(storePath) : void 0;
const requireOAuthDir = shouldRequireOAuthDir(cfg, env);
const cloudSyncedStateDir = detectMacCloudSyncedStateDir(stateDir);
if (cloudSyncedStateDir) issues.push({
kind: "mac-cloud-state-dir",
path: cloudSyncedStateDir.path,
storage: cloudSyncedStateDir.storage
});
const linuxSdBackedStateDir = detectLinuxSdBackedStateDir(stateDir);
if (linuxSdBackedStateDir) issues.push({
kind: "linux-sd-state-dir",
path: linuxSdBackedStateDir.path,
mountPoint: linuxSdBackedStateDir.mountPoint,
fsType: linuxSdBackedStateDir.fsType,
source: linuxSdBackedStateDir.source
});
const linuxVolatileStateDir = detectLinuxVolatileStateDir(stateDir);
if (linuxVolatileStateDir) issues.push({
kind: "linux-volatile-state-dir",
path: linuxVolatileStateDir.path,
mountPoint: linuxVolatileStateDir.mountPoint,
fsType: linuxVolatileStateDir.fsType
});
const stateDirExists = existsDir(stateDir);
if (!stateDirExists) issues.push({
kind: "missing-state-dir",
path: stateDir
});
if (stateDirExists && !canWriteDir(stateDir)) {
const hint = dirPermissionHint(stateDir);
issues.push({
kind: "state-dir-not-writable",
path: stateDir,
...hint ? { hint } : {}
});
}
if (stateDirExists && process.platform !== "win32") try {
const dirLstat = fs.lstatSync(stateDir);
const isDirSymlink = dirLstat.isSymbolicLink();
const stat = isDirSymlink ? fs.statSync(stateDir) : dirLstat;
if (!(isDirSymlink ? fs.realpathSync(stateDir) : stateDir).startsWith("/nix/store/") && (stat.mode & 63) !== 0) issues.push({
kind: "state-dir-too-open",
path: stateDir,
mode: stat.mode
});
} catch {}
if (params?.configPath && existsFile(params.configPath) && process.platform !== "win32") try {
const configLstat = fs.lstatSync(params.configPath);
const isSymlink = configLstat.isSymbolicLink();
const stat = isSymlink ? fs.statSync(params.configPath) : configLstat;
if (!(isSymlink ? fs.realpathSync(params.configPath) : params.configPath).startsWith("/nix/store/") && (stat.mode & 63) !== 0) issues.push({
kind: "config-file-too-open",
path: params.configPath,
mode: stat.mode
});
} catch {}
if (stateDirExists) {
const dirCandidates = /* @__PURE__ */ new Map();
if (sessionsDir) dirCandidates.set(sessionsDir, "Sessions dir");
if (storeDir) dirCandidates.set(storeDir, "Session store dir");
if (requireOAuthDir) dirCandidates.set(oauthDir, "OAuth dir");
for (const [dir, label] of dirCandidates) {
if (!existsDir(dir)) {
if (label === "OAuth dir") {
issues.push({
kind: "missing-runtime-dir",
label,
path: dir
});
continue;
}
continue;
}
if (!canWriteDir(dir)) {
const hint = dirPermissionHint(dir);
issues.push({
kind: "runtime-dir-not-writable",
label,
path: dir,
...hint ? { hint } : {}
});
}
}
}
return issues;
}
function stateIntegrityIssueToHealthFinding(issue) {
switch (issue.kind) {
case "mac-cloud-state-dir": return {
checkId: STATE_INTEGRITY_CHECK_ID,
severity: "warning",
message: `State directory is under macOS cloud-synced storage (${issue.storage}), which can cause slow I/O and sync races.`,
path: issue.path,
fixHint: "Move OPENCLAW_STATE_DIR to local non-synced storage such as ~/.openclaw."
};
case "linux-sd-state-dir": return {
checkId: STATE_INTEGRITY_CHECK_ID,
severity: "warning",
message: `State directory appears to be on SD/eMMC storage (${issue.source}, ${issue.fsType}), which can hurt startup and durability.`,
path: issue.path,
target: issue.mountPoint,
fixHint: "Move OPENCLAW_STATE_DIR to SSD/NVMe-backed storage."
};
case "linux-volatile-state-dir": return {
checkId: STATE_INTEGRITY_CHECK_ID,
severity: "warning",
message: `State directory is on volatile ${issue.fsType} storage and may disappear on reboot.`,
path: issue.path,
target: issue.mountPoint,
fixHint: "Move OPENCLAW_STATE_DIR to persistent local storage."
};
case "missing-state-dir": return {
checkId: STATE_INTEGRITY_CHECK_ID,
severity: "error",
message: "State directory is missing. Sessions, credentials, logs, and config are stored there.",
path: issue.path,
fixHint: "Run `openclaw doctor --fix` to create the state directory."
};
case "state-dir-not-writable": return {
checkId: STATE_INTEGRITY_CHECK_ID,
severity: "error",
message: issue.hint ? `State directory is not writable. ${issue.hint}` : "State directory is not writable.",
path: issue.path,
fixHint: "Run `openclaw doctor --fix` to repair state directory permissions."
};
case "state-dir-too-open": return {
checkId: STATE_INTEGRITY_CHECK_ID,
severity: "warning",
message: "State directory permissions are too open. Recommend chmod 700.",
path: issue.path,
fixHint: "Run `openclaw doctor --fix` to tighten state directory permissions."
};
case "config-file-too-open": return {
checkId: STATE_INTEGRITY_CHECK_ID,
severity: "warning",
message: "Config file is group/world readable. Recommend chmod 600.",
path: issue.path,
fixHint: "Run `openclaw doctor --fix` to tighten config file permissions."
};
case "missing-runtime-dir": return {
checkId: STATE_INTEGRITY_CHECK_ID,
severity: "error",
message: `${issue.label} is missing.`,
path: issue.path,
fixHint: "Run `openclaw doctor --fix` to create missing runtime state directories."
};
case "runtime-dir-not-writable": return {
checkId: STATE_INTEGRITY_CHECK_ID,
severity: "error",
message: issue.hint ? `${issue.label} is not writable. ${issue.hint}` : `${issue.label} is not writable.`,
path: issue.path,
fixHint: "Run `openclaw doctor --fix` to repair runtime state directory permissions."
};
}
return assertNeverStateIntegrityIssue(issue);
}
function stateIntegrityIssueToRepairEffect(issue) {
switch (issue.kind) {
case "mac-cloud-state-dir":
case "linux-sd-state-dir":
case "linux-volatile-state-dir": return {
kind: "state",
action: "would-recommend-moving-state-dir",
target: issue.path,
dryRunSafe: true
};
case "missing-state-dir": return {
kind: "state",
action: "would-create-state-dir",
target: issue.path,
dryRunSafe: false
};
case "state-dir-not-writable":
case "state-dir-too-open": return {
kind: "state",
action: "would-repair-state-dir-permissions",
target: issue.path,
dryRunSafe: false
};
case "config-file-too-open": return {
kind: "file",
action: "would-tighten-config-file-permissions",
target: issue.path,
dryRunSafe: false
};
case "missing-runtime-dir": return {
kind: "state",
action: "would-create-runtime-state-dir",
target: issue.path,
dryRunSafe: false
};
case "runtime-dir-not-writable": return {
kind: "state",
action: "would-repair-runtime-state-dir-permissions",
target: issue.path,
dryRunSafe: false
};
}
return assertNeverStateIntegrityIssue(issue);
}
function assertNeverStateIntegrityIssue(issue) {
throw new Error(`Unhandled state integrity issue kind: ${String(issue.kind)}`);
}
/** Emits state integrity warnings and applies selected runtime repairs. */
async function noteStateIntegrity(cfg, prompter, configPath, options) {
const warnings = [];
const changes = [];
const noteFn = prompter.note ?? note;
const env = process.env;
const homedir = () => resolveRequiredHomeDir(env, os.homedir);
const stateDir = resolveStateDir(env, homedir);
const defaultStateDir = path.join(homedir(), ".openclaw");
const oauthDir = resolveOAuthDir(env, stateDir);
const runtimeAgentId = tryResolveDefaultAgentId(cfg);
const runtimeSessionsDir = runtimeAgentId ? resolveSessionTranscriptsDirForAgent(runtimeAgentId, env, homedir) : void 0;
const runtimeStorePath = runtimeAgentId ? resolveSessionStorePathCore(cfg.session?.store, { agentId: runtimeAgentId }) : void 0;
const runtimeStoreDir = runtimeStorePath ? path.dirname(runtimeStorePath) : void 0;
const displayStateDir = shortenHomePath(stateDir);
const displayOauthDir = shortenHomePath(oauthDir);
const displayConfigPath = configPath ? shortenHomePath(configPath) : void 0;
const requireOAuthDir = shouldRequireOAuthDir(cfg, env);
const cloudSyncedStateDir = detectMacCloudSyncedStateDir(stateDir);
const linuxSdBackedStateDir = detectLinuxSdBackedStateDir(stateDir);
const linuxVolatileStateDir = detectLinuxVolatileStateDir(stateDir);
if (cloudSyncedStateDir) warnings.push([
`- State directory is under macOS cloud-synced storage (${displayStateDir}; ${cloudSyncedStateDir.storage}).`,
"- This can cause slow I/O and sync/lock races for sessions and credentials.",
"- Prefer a local non-synced state dir (for example: ~/.openclaw).",
` Set locally: OPENCLAW_STATE_DIR=~/.openclaw ${formatCliCommand("openclaw doctor")}`
].join("\n"));
if (linuxSdBackedStateDir) warnings.push(formatLinuxSdBackedStateDirWarning(displayStateDir, linuxSdBackedStateDir));
if (linuxVolatileStateDir) warnings.push(formatLinuxVolatileStateDirWarning(displayStateDir, linuxVolatileStateDir));
let stateDirExists = existsDir(stateDir);
if (stateDirExists && options?.stateDirExistedAtStart === false) warnings.push(`- State directory was missing at doctor start and was initialized during startup checks (${displayStateDir}).`);
if (!stateDirExists) {
warnings.push(`- CRITICAL: state directory missing (${displayStateDir}). Sessions, credentials, logs, and config are stored there.`);
if (cfg.gateway?.mode === "remote") warnings.push("- Gateway is in remote mode; run doctor on the remote host where the gateway runs.");
if (await prompter.confirmRuntimeRepair({
message: `Create ${displayStateDir} now?`,
initialValue: false
})) {
const created = ensureDir(stateDir);
if (created.ok) {
changes.push(`- Created ${displayStateDir}`);
stateDirExists = true;
} else warnings.push(`- Failed to create ${displayStateDir}: ${created.error}`);
}
}
if (stateDirExists && !canWriteDir(stateDir)) {
warnings.push(`- State directory not writable (${displayStateDir}).`);
const hint = dirPermissionHint(stateDir);
if (hint) warnings.push(` ${hint}`);
if (await prompter.confirmRuntimeRepair({
message: `Repair permissions on ${displayStateDir}?`,
initialValue: true
})) try {
const target = addUserRwx(fs.statSync(stateDir).mode);
fs.chmodSync(stateDir, target);
changes.push(`- Repaired permissions on ${displayStateDir}`);
} catch (err) {
warnings.push(`- Failed to repair ${displayStateDir}: ${String(err)}`);
}
}
if (stateDirExists && process.platform !== "win32") try {
const dirLstat = fs.lstatSync(stateDir);
const isDirSymlink = dirLstat.isSymbolicLink();
const stat = isDirSymlink ? fs.statSync(stateDir) : dirLstat;
if (!(isDirSymlink ? fs.realpathSync(stateDir) : stateDir).startsWith("/nix/store/") && (stat.mode & 63) !== 0) {
warnings.push(`- State directory permissions are too open (${displayStateDir}). Recommend chmod 700.`);
if (await prompter.confirmRuntimeRepair({
message: `Tighten permissions on ${displayStateDir} to 700?`,
initialValue: true
})) {
fs.chmodSync(stateDir, 448);
changes.push(`- Tightened permissions on ${displayStateDir} to 700`);
}
}
} catch (err) {
warnings.push(`- Failed to read ${displayStateDir} permissions: ${String(err)}`);
}
if (configPath && existsFile(configPath) && process.platform !== "win32") try {
const configLstat = fs.lstatSync(configPath);
const isSymlink = configLstat.isSymbolicLink();
const stat = isSymlink ? fs.statSync(configPath) : configLstat;
if (!(isSymlink ? fs.realpathSync(configPath) : configPath).startsWith("/nix/store/") && (stat.mode & 63) !== 0) {
warnings.push(`- Config file is group/world readable (${displayConfigPath ?? configPath}). Recommend chmod 600.`);
if (await prompter.confirmRuntimeRepair({
message: `Tighten permissions on ${displayConfigPath ?? configPath} to 600?`,
initialValue: true
})) {
fs.chmodSync(configPath, 384);
changes.push(`- Tightened permissions on ${displayConfigPath ?? configPath} to 600`);
}
}
} catch (err) {
warnings.push(`- Failed to read config permissions (${displayConfigPath ?? configPath}): ${String(err)}`);
}
if (stateDirExists) {
const dirCandidates = /* @__PURE__ */ new Map();
if (runtimeSessionsDir) dirCandidates.set(runtimeSessionsDir, "Sessions dir");
if (runtimeStoreDir) dirCandidates.set(runtimeStoreDir, "Session store dir");
if (requireOAuthDir) dirCandidates.set(oauthDir, "OAuth dir");
else if (!existsDir(oauthDir)) warnings.push(`- OAuth dir not present (${displayOauthDir}). Skipping create because no WhatsApp/pairing channel config is active.`);
for (const [dir, label] of dirCandidates) {
const displayDir = shortenHomePath(dir);
if (!existsDir(dir)) {
if (label !== "OAuth dir") continue;
warnings.push(`- CRITICAL: ${label} missing (${displayDir}).`);
if (await prompter.confirmRuntimeRepair({
message: `Create ${label} at ${displayDir}?`,
initialValue: true
})) {
const created = ensureDir(dir);
if (created.ok) changes.push(`- Created ${label}: ${displayDir}`);
else warnings.push(`- Failed to create ${displayDir}: ${created.error}`);
}
continue;
}
if (!canWriteDir(dir)) {
warnings.push(`- ${label} not writable (${displayDir}).`);
const hint = dirPermissionHint(dir);
if (hint) warnings.push(` ${hint}`);
if (await prompter.confirmRuntimeRepair({
message: `Repair permissions on ${label}?`,
initialValue: true
})) try {
const target = addUserRwx(fs.statSync(dir).mode);
fs.chmodSync(dir, target);
changes.push(`- Repaired permissions on ${label}: ${displayDir}`);
} catch (err) {
warnings.push(`- Failed to repair ${displayDir}: ${String(err)}`);
}
}
}
}
if (path.resolve(stateDir) !== path.resolve(defaultStateDir) && existsDir(defaultStateDir)) warnings.push([
"- Multiple state directories detected. This can split session history.",
` - ${shortenHomePath(defaultStateDir)}`,
` Active state dir: ${displayStateDir}`
].join("\n"));
const orphanAgentDirs = listOrphanAgentDirs(cfg, stateDir);
if (orphanAgentDirs.length > 0) warnings.push([
`- Found ${countLabel(orphanAgentDirs.length, "agent directory", "agent directories")} on disk without a matching agents.list entry.`,
" These agents can still have sessions/auth state on disk, but config-driven routing, identity, and model selection will ignore them.",
` Examples: ${formatOrphanAgentDirPreview(orphanAgentDirs)}`,
` Restore the missing agents.list entries or remove stale dirs after confirming they are no longer needed: ${shortenHomePath(path.join(stateDir, "agents"))}`
].join("\n"));
if (stateDirExists) warnings.push(...collectRetainedUnconfiguredAgentDatabaseWarnings({
cfg,
env
}));
const compatibilityAgentId = resolveSessionStoreCompatibilityAgentId(cfg);
const sessionTargets = resolveSessionStoreTargets(cfg, { allAgents: true }, { env }).toSorted((left, right) => left.agentId === compatibilityAgentId ? -1 : right.agentId === compatibilityAgentId ? 1 : 0);
const inspectAgentSessionIntegrity = async (target, inspectLegacyStore) => {
const { agentId, storePath } = target;
const absoluteStorePath = path.resolve(storePath);
const sqliteStorePath = resolveSqliteTargetFromSessionStorePath(absoluteStorePath, {
agentId,
defaultAgentId: compatibilityAgentId,
env
}).path;
const legacyStore = inspectLegacyStore && existsFile(absoluteStorePath) ? loadLegacySessionStore(absoluteStorePath) : {};
const legacyEntries = Object.entries(legacyStore).filter((candidate) => candidate[1] != null && typeof candidate[1] === "object");
const legacySessionKeys = new Set(legacyEntries.map(([sessionKey]) => sessionKey));
const sqliteSessionKeys = /* @__PURE__ */ new Set();
const isSessionKeyOccupied = (sessionKey) => sqliteSessionKeys.has(sessionKey) || legacySessionKeys.has(sessionKey);
const mainKey = resolveCanonicalMainSessionKey({
agentId,
mainKey: cfg.session?.mainKey,
sessionScope: cfg.session?.scope
});
const mainRecoveryWedged = [];
const wedgedSubagentSessions = [];
const sqlitePluginStateScanner = createPluginSessionStateDoctorScanner({
agentId,
cfg,
env
});
const legacyPluginStateScanner = createPluginSessionStateDoctorScanner({
agentId,
cfg,
env
});
let mainEntry;
const inspectMergedEntry = (sessionKey, entry) => {
if (sessionKey === mainKey) mainEntry = entry;
if (isSubagentRecoveryWedgedEntry(entry)) wedgedSubagentSessions.push({
key: sessionKey,
reason: formatSubagentRecoveryWedgedReason(entry)
});
};
const sqliteEntryCount = scanDoctorSessionEntriesStrict({
agentId,
storePath: sqliteStorePath
}, ({ entry, sessionKey }) => {
sqliteSessionKeys.add(sessionKey);
sqlitePluginStateScanner.scanEntry(sessionKey, entry);
inspectMergedEntry(sessionKey, entry);
const recovery = inspectMainSessionRecoveryEntry(sessionKey, entry);
if (recovery) mainRecoveryWedged.push(recovery);
});
for (const [sessionKey, entry] of legacyEntries) {
if (sqliteSessionKeys.has(sessionKey)) continue;
legacyPluginStateScanner.scanEntry(sessionKey, entry);
inspectMergedEntry(sessionKey, entry);
}
const sessionPathOpts = resolveSessionFilePathOptions({
agentId,
storePath
});
await noteMainSessionRecoveryIntegrity({
storePath: sqliteStorePath,
wedged: mainRecoveryWedged,
warnings,
changes,
confirmRepair: (params) => prompter.confirmRuntimeRepair(params),
countLabel
});
if (sqliteEntryCount > 0 || legacyEntries.length > 0) {
if (wedgedSubagentSessions.length > 0) {
const wedgedCount = countLabel(wedgedSubagentSessions.length, "wedged subagent session");
warnings.push([
`- Found ${wedgedCount} with automatic restart recovery tombstoned.`,
" OpenClaw will not auto-resume these child sessions on restart; reconcile their task records instead.",
` Examples: ${wedgedSubagentSessions.slice(0, 3).map(({ key }) => key).join(", ")}`,
` Fix: ${formatCliCommand("openclaw tasks maintenance --apply")}`
].join("\n"));
if (await prompter.confirmRuntimeRepair({
message: `Clear stale aborted recovery flags for ${wedgedCount}?`,
initialValue: true
})) {
let repaired = 0;
const repairedAt = Date.now();
const sqliteKeys = wedgedSubagentSessions.map(({ key }) => key).filter((key) => sqliteSessionKeys.has(key));
for (const sessionKeys of iterateDoctorSessionKeyBatches(sqliteKeys)) repaired += await applySessionEntryReplacements({
agentId,
sessionKeys,
storePath: sqliteStorePath,
update: (currentEntries) => {
const replacements = currentEntries.flatMap(({ entry, sessionKey }) => clearWedgedSubagentRecoveryAbort(entry, repairedAt) ? [{
entry,
sessionKey
}] : []);
return {
replacements,
result: replacements.length
};
}
});
const legacyKeys = wedgedSubagentSessions.map(({ key }) => key).filter((key) => !sqliteSessionKeys.has(key));
if (legacyKeys.length > 0 && existsFile(absoluteStorePath)) await updateLegacySessionStore(absoluteStorePath, (currentStore) => {
for (const key of legacyKeys) {
const current = currentStore[key];
if (current && clearWedgedSubagentRecoveryAbort(current, repairedAt)) {
repaired += 1;
currentStore[key] = current;
}
}
});
if (repaired > 0) changes.push(`- Cleared aborted restart-recovery flags for ${countLabel(repaired, "wedged subagent session")}.`);
}
const wedgedReasons = wedgedSubagentSessions.map(({ reason }) => reason);
const visibleWedgedReasons = uniqueStrings(wedgedReasons).slice(0, 2);
if (visibleWedgedReasons.length > 0) warnings.push(visibleWedgedReasons.map((reason) => ` Reason: ${reason}`).join("\n"));
}
await runPluginSessionStateDoctorRepairs({
scan: sqlitePluginStateScanner.result(),
store: {
kind: "sqlite",
agentId,
path: sqliteStorePath
},
prompter,
warnings,
changes
});
await runPluginSessionStateDoctorRepairs({
scan: legacyPluginStateScanner.result(),
store: {
kind: "legacy",
path: absoluteStorePath
},
prompter,
warnings,
changes
});
if (sqliteSessionKeys.has(mainKey)) mainEntry = loadExactSessionEntryReadOnly({
agentId,
sessionKey: mainKey,
storePath: sqliteStorePath
})?.entry;
if (!await repairHeartbeatPoisonedMainSession({
mainKey,
mainEntry,
isSessionKeyOccupied,
store: sqliteSessionKeys.has(mainKey) ? {
kind: "sqlite",
agentId,
path: sqliteStorePath
} : {
kind: "legacy",
path: absoluteStorePath
},
stateDir,
sessionPathOpts,
prompter,
warnings,
changes
}) && mainEntry?.sessionId && !sqliteSessionKeys.has(mainKey)) {
const transcriptPath = resolveSessionFilePathCore(mainEntry.sessionId, mainEntry, sessionPathOpts);
if (!existsFile(transcriptPath)) warnings.push(`- Main session transcript missing (${shortenHomePath(transcriptPath)}). History will appear to reset.`);
else {
const lineCount = countJsonlLines(transcriptPath);
if (lineCount <= 1) warnings.push(`- Main session transcript has only ${lineCount} line. Session history may not be appending.`);
}
}
}
};
const inspectedLegacyStores = /* @__PURE__ */ new Set();
for (const target of sessionTargets) {
const legacyStorePath = path.resolve(target.storePath);
await inspectAgentSessionIntegrity(target, !legacyStorePath.endsWith(".sqlite") && !inspectedLegacyStores.has(legacyStorePath));
inspectedLegacyStores.add(legacyStorePath);
}
for (const warning of describeHeartbeatSessionTargetIssues(cfg)) warnings.push(warning);
if (warnings.length > 0) noteFn(warnings.join("\n"), "State integrity");
if (changes.length > 0) noteFn(changes.join("\n"), "Doctor changes");
}
/** Returns the workspace git-backup tip when the workspace exists but is not a git repo. */
function collectWorkspaceBackupTip(workspaceDir) {
if (!existsDir(workspaceDir)) return null;
const resolvedWorkspaceDir = safeRealpathSync(workspaceDir);
if (!resolvedWorkspaceDir || findGitRoot(resolvedWorkspaceDir)) return null;
return "- Tip: back up the agent workspace in a private git repo; keep ~/.openclaw out of git (credentials, sessions). Details: /concepts/agent-workspace#git-backup-recommended";
}
/** Emits the workspace backup tip when applicable. */
function noteWorkspaceBackupTip(workspaceDir) {
const tip = collectWorkspaceBackupTip(workspaceDir);
if (tip) note(tip, "Workspace");
}
//#endregion
export { collectWorkspaceBackupTip, detectLinuxSdBackedStateDir, detectLinuxVolatileStateDir, detectMacCloudSyncedStateDir, detectStateIntegrityHealthIssues, formatLinuxSdBackedStateDirWarning, formatLinuxVolatileStateDirWarning, noteStateIntegrity, noteWorkspaceBackupTip, stateIntegrityIssueToHealthFinding, stateIntegrityIssueToRepairEffect };