"use strict"; /** * Merge two scoped snapshots without letting an older cross-scope copy * overwrite newer connection state. Metadata revisions are assigned by the * main-process bridge when a renderer update arrives. * * @param {Record | null | undefined} previous * @param {Record | null | undefined} fallback * @returns {Record | null} */ function mergeRetentionMeta(previous, fallback) { if (!previous && !fallback) return null; if (!previous) return fallback && typeof fallback === "object" ? fallback : null; if (!fallback || typeof fallback !== "object") return previous; const previousRevision = Number.isSafeInteger(previous._revision) ? previous._revision : 0; const fallbackRevision = Number.isSafeInteger(fallback._revision) ? fallback._revision : 0; // Unversioned direct helper inputs retain the historical fallback-wins // behavior. Bridge-owned metadata is always versioned. const fallbackIsNewer = fallbackRevision >= previousRevision; const newer = fallbackIsNewer ? fallback : previous; const older = fallbackIsNewer ? previous : fallback; const connected = Object.prototype.hasOwnProperty.call(newer, "connected") ? newer.connected !== false : older.connected !== false; return { ...older, ...newer, hostname: newer.hostname || older.hostname, label: newer.label || older.label, os: newer.os || older.os, username: newer.username || older.username, protocol: newer.protocol || older.protocol, shellType: newer.shellType || older.shellType, deviceType: newer.deviceType || older.deviceType, hostId: newer.hostId || older.hostId, hostChain: Array.isArray(newer.hostChain) ? newer.hostChain : older.hostChain, // Explicit empty arrays in the newer snapshot clear stopped forwards. activePortForwards: Array.isArray(newer.activePortForwards) ? newer.activePortForwards : older.activePortForwards, connected, ...(Math.max(previousRevision, fallbackRevision) > 0 ? { _revision: Math.max(previousRevision, fallbackRevision) } : {}), }; } /** * Keep host_open-owned sessions in a chat scope when a full metadata replace * would otherwise drop them (e.g. AIChatSidePanel pushing only the current * terminal tab after a mid-turn host_open). * * Empty incoming lists are treated as authoritative clears and are not retained. * * @param {{ * incomingSessions: Array>, * ownedSessionIds: string[], * previousById?: Map> | null, * findFallbackMeta?: ((sessionId: string) => Record | null | undefined) | null, * }} args * @returns {Array>} */ function retainOwnedSessions({ incomingSessions, ownedSessionIds, previousById = null, findFallbackMeta = null, }) { if (!Array.isArray(incomingSessions) || incomingSessions.length === 0) { return incomingSessions; } if (!Array.isArray(ownedSessionIds) || ownedSessionIds.length === 0) { return incomingSessions; } const byId = new Map(); for (const entry of incomingSessions) { if (!entry || typeof entry !== "object" || !entry.sessionId) continue; byId.set(String(entry.sessionId), entry); } for (const ownedIdRaw of ownedSessionIds) { const ownedId = typeof ownedIdRaw === "string" ? ownedIdRaw.trim() : ""; if (!ownedId || byId.has(ownedId)) continue; const previous = previousById?.get?.(ownedId) || null; // Always consult fallback — a stale previous connected:false must not // block a fresher cross-scope snapshot (e.g. External MCP / other tab). const fallback = typeof findFallbackMeta === "function" ? findFallbackMeta(ownedId) : null; const meta = mergeRetentionMeta(previous, fallback); if (!meta || typeof meta !== "object") continue; byId.set(ownedId, { sessionId: ownedId, hostname: meta.hostname || "", label: meta.label || "", os: meta.os || "", username: meta.username || "", protocol: meta.protocol || "", shellType: meta.shellType || "", deviceType: meta.deviceType || "", connected: meta.connected !== false, hostId: meta.hostId || "", hostChain: Array.isArray(meta.hostChain) ? meta.hostChain : [], activePortForwards: Array.isArray(meta.activePortForwards) ? meta.activePortForwards : [], ...(Number.isSafeInteger(meta._revision) ? { _revision: meta._revision } : {}), }); } return Array.from(byId.values()); } module.exports = { retainOwnedSessions, mergeRetentionMeta };