relay-companion
Version:
Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.
857 lines (803 loc) • 33.6 kB
JavaScript
// Claude Desktop / Claude Code native session writer + title-repair loop.
// Ported faithfully from granular/tools/relay-companion/src/claude-session-writer.js.
//
// HOST MECHANICS ARE UNCHANGED: it forges a ~/.claude/projects/<key>/<id>.jsonl
// transcript (custom-title / ai-title / agent-name rows + one visible assistant
// turn), writes the Claude Desktop local_<id>.json session metadata, fires
// `claude://resume?session=...`, and then runs the post-import title-repair loop
// that fixes the "General coding session" rail-title bug by re-writing the saved
// metadata title (titleSource:"user") with retries and repairing duplicates.
//
// Adaptations (input side only):
// - paths -> ./host-paths.js
// - title/briefing come from the cloud row via ./relay-briefing.js instead of a
// granular packet's displayTitle/renderRelayBriefing.
import crypto from "node:crypto";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { relayRowTitle, renderRelayRowSeed } from "./relay-briefing.js";
import { claudeDesktopSessionsDir, claudeProjectsDir } from "./host-paths.js";
import { relayClaudePermissionMode } from "./claude-session-runtime.js";
const DEFAULT_CLAUDE_METADATA_MODEL = "claude-opus-5";
const CLAUDE_DESKTOP_LIVE_TITLE_LIMITATION = {
attempted: true,
supported: "best-effort",
reason: "claude-desktop-resume-import-uses-in-memory-session-cache",
detail:
"Relay writes Claude Code title rows and Claude Desktop metadata before import, then repairs saved metadata after import. Already-open Claude Desktop builds can still cache the imported row before the saved title is observed.",
};
export function writeClaudeNativeSession({ row, cwd = process.cwd(), seed = renderRelayRowSeed(row), model = "", effort = "" }) {
const sessionId = crypto.randomUUID();
const createdAt = new Date().toISOString();
const title = relayRowTitle(row);
// The human reads only `visible`. The agent-only `operatorNote` (real ids + the
// tool call to make) goes on a hidden system-meta row that Claude Desktop's
// transcript view does not render as a chat bubble (the same way the title rows
// above are non-rendered), so the human never sees ids or tool-call syntax.
const visible = String(seed?.visible || "").trim();
const operatorNote = String(seed?.operatorNote || "").trim();
const sessionDir = path.join(claudeProjectsDir(), claudeProjectKey(cwd));
const sessionPath = path.join(sessionDir, `${sessionId}.jsonl`);
const assistantUuid = crypto.randomUUID();
const operatorUuid = crypto.randomUUID();
const rows = [
{ type: "custom-title", customTitle: title, sessionId },
{ type: "ai-title", aiTitle: title, sessionId },
{ type: "agent-name", agentName: title, sessionId },
{ type: "mode", mode: "normal", sessionId },
{ type: "permission-mode", permissionMode: relayClaudePermissionMode(), sessionId },
];
if (operatorNote) {
// Hidden agent-only channel: a system-meta row. isMeta marks it non-visible to
// the user; the agent still has it in context. Carries no chat role, so it is
// not rendered in the human-visible transcript.
rows.push({
type: "system",
subtype: "relay-operator-note",
isMeta: true,
isVisibleInTranscriptOnly: false,
content: operatorNote,
level: "info",
uuid: operatorUuid,
parentUuid: null,
timestamp: createdAt,
sessionId,
});
}
rows.push(
{
parentUuid: operatorNote ? operatorUuid : null,
isSidechain: false,
message: {
// A real model id when the task runtime picker chose one — the CLI
// reads the session model off the last assistant row on resume, and a
// placeholder here made it warn and fall back to the default.
model: String(model || "").trim() || "relay-companion",
id: claudeMessageId(),
type: "message",
role: "assistant",
content: [{ type: "text", text: visible }],
stop_reason: "end_turn",
stop_sequence: null,
usage: {},
},
requestId: relayRequestId(),
type: "assistant",
uuid: assistantUuid,
timestamp: createdAt,
userType: "external",
entrypoint: "relay-companion",
cwd,
sessionId,
version: "relay-companion",
gitBranch: process.env.GIT_BRANCH || null,
},
{
type: "last-prompt",
lastPrompt: row?.relayNotificationKind === "sent_relay"
? `Relay to ${row?.recipient?.name || row?.recipient?.email || "recipient"}`
: `Relay from ${row?.senderName || row?.sender?.name || row?.sender?.handle || "sender"}`,
leafUuid: assistantUuid,
sessionId,
},
);
fs.mkdirSync(sessionDir, { recursive: true });
writeJsonlAtomic(sessionPath, rows);
const desktopMetadataPath = writeClaudeDesktopSessionMetadata({ sessionId, title, cwd, createdAt, model, effort });
if (process.env.RELAY_IMPORT_CLAUDE_DESKTOP !== "0") {
sleepSync(Number(process.env.RELAY_CLAUDE_PREIMPORT_DELAY_MS || 750));
}
const deepLink = claudeResumeDeepLink(sessionId);
const desktopImport = importClaudeDesktopSession({ sessionId });
const desktopTitleBeforeRefocus = restoreClaudeDesktopSessionTitle({
sessionId,
title,
cwd,
createdAt,
metadataPath: desktopMetadataPath,
retries: desktopImport.attempted ? 10 : 1,
});
const desktopRefocus =
desktopImport.attempted && desktopImport.status === 0
? importClaudeDesktopSession({ sessionId })
: { attempted: false, reason: desktopImport.reason || "import-not-successful", deepLink };
const desktopTitle =
desktopRefocus.attempted && desktopRefocus.status === 0
? restoreClaudeDesktopSessionTitle({
sessionId,
title,
cwd,
createdAt,
metadataPath: desktopTitleBeforeRefocus.metadataPath || desktopMetadataPath,
retries: 10,
})
: desktopTitleBeforeRefocus;
const desktopRepair = repairClaudeDesktopRelaySessions({ sessionIds: [sessionId] });
// Last word on the mode: the repair pass rewrites whole metadata files from
// what is on disk, so assert after it, not before.
const desktopPermissionMode = setClaudeDesktopSessionPermissionMode({ sessionId });
return {
sessionId,
title,
sessionPath,
desktopMetadataPath: desktopTitle.metadataPath || desktopMetadataPath,
deepLink,
desktopImport,
desktopTitleBeforeRefocus,
desktopTitle,
desktopRefocus,
desktopRepair,
desktopPermissionMode,
desktopLiveTitle: CLAUDE_DESKTOP_LIVE_TITLE_LIMITATION,
};
}
export function writeClaudeDesktopSessionMetadata({
sessionId,
title,
cwd,
createdAt = new Date().toISOString(),
model = "",
effort = "",
}) {
const groupDir = findClaudeDesktopSessionGroupDir();
if (!groupDir) return null;
const nowMs = Date.now();
const createdAtMs = Number.isFinite(Date.parse(createdAt)) ? Date.parse(createdAt) : nowMs;
const desktopSessionId = `local_${sessionId}`;
const metadataPath = path.join(groupDir, `${desktopSessionId}.json`);
const metadata = {
sessionId: desktopSessionId,
cliSessionId: sessionId,
cwd,
originCwd: cwd,
lastFocusedAt: nowMs,
createdAt: createdAtMs,
lastActivityAt: nowMs,
// Explicit choice (the task runtime picker) beats the env override beats
// the default; effort mirrors model.
model: String(model || "").trim() || process.env.RELAY_CLAUDE_METADATA_MODEL || DEFAULT_CLAUDE_METADATA_MODEL,
effort: String(effort || "").trim() || "high",
isArchived: false,
title,
titleSource: "user",
permissionMode: relayClaudePermissionMode(),
enabledMcpTools: {},
remoteMcpServersConfig: [],
chromePermissionMode: "skip_all_permission_checks",
completedTurns: 1,
bridgeSessionIds: [],
alwaysAllowedReasons: [],
sessionPermissionUpdates: [],
classifierSummaryEnabled: true,
};
writeJsonAtomic(metadataPath, metadata);
return metadataPath;
}
export function setClaudeDesktopSessionPermissionMode({
sessionId,
permissionMode = relayClaudePermissionMode(),
} = {}) {
if (!sessionId) return { attempted: false, reason: "missing-session-id", metadataPath: null };
const metadataPath = claudeDesktopSessionMetadataPath(sessionId);
if (!metadataPath || !fs.existsSync(metadataPath)) {
return { attempted: false, reason: "metadata-not-found", metadataPath: metadataPath || null };
}
const metadata = readJsonIfExists(metadataPath, null);
if (!metadata) return { attempted: false, reason: "metadata-unreadable", metadataPath };
if (metadata.permissionMode === permissionMode) {
return { attempted: true, updated: false, metadataPath, permissionMode };
}
writeJsonAtomic(metadataPath, { ...metadata, permissionMode });
return { attempted: true, updated: true, metadataPath, permissionMode };
}
export function restoreClaudeDesktopSessionTitle({
sessionId,
title,
cwd = process.cwd(),
createdAt = new Date().toISOString(),
metadataPath = null,
retries = 10,
delayMs = 250,
} = {}) {
if (!sessionId || !title) {
return { attempted: false, reason: "missing-session-or-title", metadataPath: metadataPath || null };
}
let currentPath = metadataPath;
let lastError = null;
let lastSuccess = null;
const totalAttempts = Math.max(1, retries);
for (let attempt = 0; attempt < totalAttempts; attempt += 1) {
if (attempt > 0 || totalAttempts > 1) sleepSync(delayMs);
currentPath = currentPath || claudeDesktopSessionMetadataPath(sessionId);
if (!currentPath) {
lastError = "no-claude-desktop-session-dir";
continue;
}
try {
const nowMs = Date.now();
const createdAtMs = Number.isFinite(Date.parse(createdAt)) ? Date.parse(createdAt) : nowMs;
const existing = fs.existsSync(currentPath) ? readJsonIfExists(currentPath, {}) : {};
writeJsonAtomic(currentPath, {
sessionId: `local_${sessionId}`,
cliSessionId: sessionId,
cwd,
originCwd: cwd,
lastFocusedAt: existing.lastFocusedAt || nowMs,
createdAt: existing.createdAt || createdAtMs,
lastActivityAt: existing.lastActivityAt || nowMs,
model: process.env.RELAY_CLAUDE_METADATA_MODEL || existing.model || DEFAULT_CLAUDE_METADATA_MODEL,
effort: existing.effort || "high",
isArchived: false,
enabledMcpTools: existing.enabledMcpTools || {},
remoteMcpServersConfig: existing.remoteMcpServersConfig || [],
chromePermissionMode: existing.chromePermissionMode || "skip_all_permission_checks",
completedTurns: existing.completedTurns || 1,
bridgeSessionIds: existing.bridgeSessionIds || [],
alwaysAllowedReasons: existing.alwaysAllowedReasons || [],
sessionPermissionUpdates: existing.sessionPermissionUpdates || [],
classifierSummaryEnabled: existing.classifierSummaryEnabled ?? true,
...existing,
title,
titleSource: "user",
// Re-pinned after the spread, exactly like the title, and for the same
// reason: Claude Desktop's importCliSession builds the session with
// permissionMode Default hardcoded and saves it over what we wrote before
// the deep link. Preserving `existing` here re-adopted that Default, so a
// Relay-opened session stayed on Manual for the rest of its life.
permissionMode: relayClaudePermissionMode(),
});
lastSuccess = {
attempted: true,
restored: true,
metadataPath: currentPath,
title,
titleSource: "user",
attempts: attempt + 1,
};
if (totalAttempts === 1) return lastSuccess;
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
if (lastSuccess) return { ...lastSuccess, attempts: totalAttempts };
return {
attempted: true,
restored: false,
metadataPath: currentPath || null,
title,
reason: lastError || "unknown",
attempts: Math.max(1, retries),
};
}
export function claudeResumeDeepLink(sessionId) {
return `claude://resume?session=${encodeURIComponent(sessionId)}`;
}
export function importClaudeDesktopSession({ sessionId, activate = process.env.RELAY_ACTIVATE_CLAUDE === "1" } = {}) {
const deepLink = claudeResumeDeepLink(sessionId);
if (process.env.RELAY_IMPORT_CLAUDE_DESKTOP === "0") {
return { attempted: false, reason: "disabled", deepLink };
}
if (process.platform !== "darwin" && process.platform !== "win32") {
return { attempted: false, reason: "unsupported-platform", deepLink };
}
const command = process.platform === "win32" ? "cmd.exe" : "open";
const args =
process.platform === "win32"
? ["/c", "start", "", deepLink]
: activate ? [deepLink] : ["-g", deepLink];
const result = spawnSync(command, args, { encoding: "utf8", timeout: 5000, windowsHide: true });
return {
attempted: true,
deepLink,
command,
args,
status: result.status,
signal: result.signal,
error: result.error ? result.error.message : null,
stdout: result.stdout || "",
stderr: result.stderr || "",
};
}
// THE APP READS THE TRANSCRIPT TO FIND THE WORKING DIRECTORY, and refuses the
// import when it cannot: "Cannot determine working directory for CLI session
// <id> — the transcript may be incomplete", logged under category
// `transcript_missing`. A forked background run writes its seed rows FIRST —
// custom-title, ai-title, agent-name, mode, permission-mode — and not one of
// them carries a cwd; the first row that does is the `system` row, several
// writes later (measured: row 7 of the fork, row 6 of a forge). So "the file
// exists" is still too early a signal, and firing the deep link on it is what
// silently dropped the run from the reader's session list. Readiness is a row
// with a cwd, not a file on disk.
export function claudeTranscriptCwd(sessionPath) {
if (!sessionPath || !fs.existsSync(sessionPath)) return "";
let raw = "";
try {
raw = fs.readFileSync(sessionPath, "utf8");
} catch {
return "";
}
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
// The tail of a file being appended to is routinely a half-written line;
// an unparseable row is "not yet", never a failure.
const cwd = String(JSON.parse(trimmed)?.cwd || "").trim();
if (cwd) return cwd;
} catch {}
}
return "";
}
/**
* Hand Claude Code a session it can actually open, and keep the title on it.
*
* This is the FORK's adoption path, and it is deliberately async: it runs in
* the Electron main process, where the writeClaudeNativeSession forge's
* sleepSync retry loop would freeze the pill for seconds. Every wait here
* yields.
*
* Order is the whole point:
* 1. wait for the transcript to carry a cwd (see claudeTranscriptCwd) —
* without it the app rejects the deep link outright;
* 2. write our metadata, then fire the import;
* 3. restore the title AFTER the import, because the app's import REWRITES
* local_<id>.json wholesale and drops the title we wrote in step 2 —
* measured live: the run showed up in the session list untitled.
*/
export async function adoptClaudeSessionIntoDesktop({
sessionId,
title,
cwd = "",
createdAt = new Date().toISOString(),
model = "",
effort = "",
sessionPath = "",
timeoutMs = 30_000,
pollMs = 250,
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
// A Relay-owned native worker must remain the transcript's only owner until
// it has completely settled. Materializing makes the finished transcript
// openable in Desktop; importing fires claude://resume and starts Desktop's
// own worker, so the Relay runner deliberately defers that second step until
// the human presses Open.
importIntoDesktop = true,
} = {}) {
if (!sessionId) return { attempted: false, reason: "missing-session" };
let transcriptCwd = "";
let waitedMs = 0;
if (sessionPath) {
for (;;) {
transcriptCwd = claudeTranscriptCwd(sessionPath);
if (transcriptCwd || waitedMs >= timeoutMs) break;
await sleep(pollMs);
waitedMs += pollMs;
}
if (!transcriptCwd) {
// Say so instead of firing a deep link we know the app will reject: a
// recorded miss is debuggable, a silent one is what produced "i dont see
// it in my claude code at all".
return { attempted: false, reason: "transcript-has-no-cwd", sessionPath, waitedMs };
}
}
const resolvedCwd = cwd || transcriptCwd || "";
// A stream-json worker writes a real Claude transcript but does not emit the
// CLI's --name title rows. Put the user-visible title in the transcript
// before Desktop reads it; metadata alone is too late and leaves the open
// session header as "Untitled" even when the rail row is later repaired.
if (title) appendClaudeSessionTitleRows({ sessionId, title, cwd: resolvedCwd });
const metadataPath = writeClaudeDesktopSessionMetadata({
sessionId,
title,
cwd: resolvedCwd,
createdAt,
model,
effort,
});
if (!importIntoDesktop) {
return {
attempted: true,
materialized: true,
sessionId,
cwd: resolvedCwd,
sessionPath,
waitedMs,
metadataPath,
deepLink: claudeResumeDeepLink(sessionId),
desktopImport: { attempted: false, reason: "deferred-until-user-open" },
desktopRefocus: { attempted: false, reason: "deferred-until-user-open" },
desktopTitle: { attempted: true, restored: true, metadataPath, title, titleSource: "user" },
desktopLiveTitle: CLAUDE_DESKTOP_LIVE_TITLE_LIMITATION,
};
}
const desktopImport = importClaudeDesktopSession({ sessionId, activate: false });
// The title survives only if we win the last write. Re-assert it until the
// app stops clobbering, with retries:1 so the writer never sleeps
// synchronously on this thread.
let desktopTitle = { attempted: false, reason: "import-not-successful", metadataPath };
if (title && desktopImport.attempted && desktopImport.status === 0) {
const deadline = Date.now() + Math.max(pollMs, Math.min(timeoutMs, 2_000));
for (;;) {
await sleep(pollMs);
desktopTitle = restoreClaudeDesktopSessionTitle({
sessionId,
title,
cwd: resolvedCwd,
createdAt,
metadataPath,
retries: 1,
});
const saved = readJsonIfExists(desktopTitle.metadataPath || metadataPath, null);
if (saved?.title === title && saved?.titleSource === "user" && Date.now() >= deadline) break;
if (Date.now() >= deadline) break;
}
}
// The first import constructs an in-memory rail row before the post-import
// title repair wins its final disk write. Re-open once from the repaired
// metadata so the live rail/header sees the same title immediately instead
// of showing "General coding session" until Claude restarts.
const desktopRefocus =
title && desktopImport.attempted && desktopImport.status === 0
? importClaudeDesktopSession({ sessionId, activate: false })
: { attempted: false, reason: "import-not-successful" };
if (desktopRefocus.attempted && desktopRefocus.status === 0) {
const deadline = Date.now() + Math.max(pollMs, Math.min(timeoutMs, 5_000));
for (;;) {
await sleep(pollMs);
desktopTitle = restoreClaudeDesktopSessionTitle({
sessionId,
title,
cwd: resolvedCwd,
createdAt,
metadataPath,
retries: 1,
});
if (Date.now() >= deadline) break;
}
}
return {
attempted: true,
sessionId,
cwd: resolvedCwd,
sessionPath,
waitedMs,
metadataPath: desktopTitle.metadataPath || metadataPath,
deepLink: claudeResumeDeepLink(sessionId),
desktopImport,
desktopRefocus,
desktopTitle,
desktopLiveTitle: CLAUDE_DESKTOP_LIVE_TITLE_LIMITATION,
};
}
export function isClaudeNativeSessionImported(nativeSession) {
if (!nativeSession?.sessionId) return false;
const deepLink = nativeSession.deepLink || nativeSession.desktopImport?.deepLink || "";
if (!deepLink.startsWith("claude://resume?session=")) return false;
const desktopImport = nativeSession.desktopImport;
if (!desktopImport) return false;
if (desktopImport.attempted) return desktopImport.status === 0 && isClaudeDesktopTitleRestored(nativeSession);
return desktopImport.reason === "disabled" || desktopImport.reason === "unsupported-platform";
}
export function ensureClaudeDesktopImported(nativeSession, { title, cwd, createdAt } = {}) {
if (!nativeSession?.sessionId) return nativeSession;
const nextTitle = title || nativeSession.title;
if (isClaudeNativeSessionImported(nativeSession)) return nativeSession;
const desktopImport =
nativeSession.desktopImport?.attempted && nativeSession.desktopImport.status === 0
? nativeSession.desktopImport
: importClaudeDesktopSession({ sessionId: nativeSession.sessionId });
const desktopTitleBeforeRefocus = restoreClaudeDesktopSessionTitle({
sessionId: nativeSession.sessionId,
title: nextTitle,
cwd: cwd || nativeSession.cwd || process.cwd(),
createdAt: createdAt || nativeSession.createdAt || new Date().toISOString(),
metadataPath: nativeSession.desktopMetadataPath || nativeSession.desktopTitle?.metadataPath || null,
retries: desktopImport.attempted ? 10 : 1,
});
const desktopRefocus =
desktopImport.attempted && desktopImport.status === 0
? importClaudeDesktopSession({ sessionId: nativeSession.sessionId })
: {
attempted: false,
reason: desktopImport.reason || "import-not-successful",
deepLink: claudeResumeDeepLink(nativeSession.sessionId),
};
const desktopTitle =
desktopRefocus.attempted && desktopRefocus.status === 0
? restoreClaudeDesktopSessionTitle({
sessionId: nativeSession.sessionId,
title: nextTitle,
cwd: cwd || nativeSession.cwd || process.cwd(),
createdAt: createdAt || nativeSession.createdAt || new Date().toISOString(),
metadataPath: desktopTitleBeforeRefocus.metadataPath || nativeSession.desktopMetadataPath || null,
retries: 10,
})
: desktopTitleBeforeRefocus;
const desktopRepair = repairClaudeDesktopRelaySessions({ sessionIds: [nativeSession.sessionId] });
const desktopPermissionMode = setClaudeDesktopSessionPermissionMode({ sessionId: nativeSession.sessionId });
return {
...nativeSession,
title: nextTitle,
deepLink: claudeResumeDeepLink(nativeSession.sessionId),
desktopMetadataPath: desktopTitle.metadataPath || nativeSession.desktopMetadataPath || null,
desktopImport,
desktopTitleBeforeRefocus,
desktopTitle,
desktopRefocus,
desktopRepair,
desktopPermissionMode,
desktopLiveTitle: CLAUDE_DESKTOP_LIVE_TITLE_LIMITATION,
};
}
export function setClaudeNativeSessionAttention(
nativeSession,
{ packetId, title = nativeSession?.title || null, state = "unread", nowMs = Date.now() } = {},
) {
const sessionId = nativeSession?.sessionId;
if (!sessionId) return { attempted: false, reason: "missing-session-id" };
const metadataPath = nativeSession.desktopMetadataPath || claudeDesktopSessionMetadataPath(sessionId);
if (!metadataPath || !fs.existsSync(metadataPath)) {
return { attempted: false, reason: "metadata-not-found", metadataPath: metadataPath || null };
}
const metadata = readJsonIfExists(metadataPath, null);
if (!metadata) return { attempted: false, reason: "metadata-unreadable", metadataPath };
const previousAttention = metadata.relayAttention || {};
const originalLastActivityAt =
previousAttention.originalLastActivityAt ?? (Number.isFinite(metadata.lastActivityAt) ? metadata.lastActivityAt : nowMs);
const originalLastFocusedAt =
previousAttention.originalLastFocusedAt ?? (Number.isFinite(metadata.lastFocusedAt) ? metadata.lastFocusedAt : null);
const timestamp = new Date(nowMs).toISOString();
const nextAttention = {
...previousAttention,
packetId: packetId || previousAttention.packetId || null,
title: title || previousAttention.title || metadata.title || null,
state,
originalLastActivityAt,
originalLastFocusedAt,
updatedAt: timestamp,
};
const nextMetadata = {
...metadata,
isArchived: false,
relayAttention: nextAttention,
};
if (isRelayTitle(title)) {
nextMetadata.title = title;
nextMetadata.titleSource = "user";
}
if (state === "unread") {
nextAttention.lastAppliedAt = nowMs;
nextMetadata.lastActivityAt = Math.max(nowMs, Number(metadata.lastActivityAt) || 0);
} else {
nextAttention.acknowledgedAt = timestamp;
nextMetadata.lastActivityAt = originalLastActivityAt;
}
writeJsonAtomic(metadataPath, nextMetadata);
return {
attempted: true,
metadataPath,
sessionId,
state,
lastActivityAt: nextMetadata.lastActivityAt,
originalLastActivityAt,
};
}
export function repairClaudeDesktopRelaySessions({ sessionIds = null } = {}) {
const groupDir = findClaudeDesktopSessionGroupDir();
if (!groupDir) return { attempted: false, reason: "no-claude-desktop-session-dir", repaired: [], archived: [] };
const requested = sessionIds ? new Set(sessionIds) : null;
const metadataFiles = childJsonFiles(groupDir)
.map((filePath) => ({ filePath, metadata: readJsonIfExists(filePath, null) }))
.filter((entry) => entry.metadata?.cliSessionId)
.filter((entry) => !requested || requested.has(entry.metadata.cliSessionId));
const byCliSession = new Map();
for (const entry of metadataFiles) {
const list = byCliSession.get(entry.metadata.cliSessionId) || [];
list.push(entry);
byCliSession.set(entry.metadata.cliSessionId, list);
}
const repaired = [];
const archived = [];
for (const [sessionId, entries] of byCliSession) {
const title = inferRelayTitleForClaudeSession(sessionId, entries);
if (!title) continue;
const canonicalSessionId = `local_${sessionId}`;
let canonical = entries.find((entry) => entry.metadata.sessionId === canonicalSessionId);
if (!canonical) {
const seed = entries[0];
const canonicalPath = path.join(groupDir, `${canonicalSessionId}.json`);
const metadata = {
...seed.metadata,
sessionId: canonicalSessionId,
cliSessionId: sessionId,
title,
titleSource: "user",
isArchived: false,
lastActivityAt: seed.metadata.lastActivityAt || Date.now(),
};
writeJsonAtomic(canonicalPath, metadata);
canonical = { filePath: canonicalPath, metadata };
entries.push(canonical);
repaired.push({ sessionId, metadataPath: canonicalPath, title });
}
const targets = canonical ? [canonical] : entries;
for (const entry of targets) {
if (entry.metadata.title === title && entry.metadata.titleSource === "user") continue;
writeJsonAtomic(entry.filePath, {
...entry.metadata,
sessionId: entry.metadata.sessionId || canonicalSessionId,
cliSessionId: sessionId,
title,
titleSource: "user",
isArchived: false,
lastActivityAt: entry.metadata.lastActivityAt || Date.now(),
});
appendClaudeSessionTitleRows({ sessionId, title, cwd: entry.metadata.cwd || entry.metadata.originCwd });
repaired.push({ sessionId, metadataPath: entry.filePath, title });
}
for (const entry of entries) {
if (entry === canonical) continue;
const duplicateTitle = isRelayTitle(entry.metadata.title) ? entry.metadata.title : title;
if (entry.metadata.isArchived === true) continue;
writeJsonAtomic(entry.filePath, {
...entry.metadata,
title: duplicateTitle,
titleSource: entry.metadata.titleSource || "user",
isArchived: true,
});
archived.push({ sessionId, metadataPath: entry.filePath, title: duplicateTitle });
}
}
return { attempted: true, groupDir, repaired, archived };
}
export function appendClaudeSessionTitleRows({ sessionId, title, cwd = null }) {
if (!sessionId || !title) return { appended: false, reason: "missing-session-or-title" };
const sessionPath = findClaudeSessionPath({ sessionId, cwd });
if (!sessionPath) return { appended: false, reason: "session-transcript-not-found" };
const timestamp = new Date().toISOString();
appendJsonlAtomic(sessionPath, [
{ type: "custom-title", customTitle: title, sessionId, uuid: crypto.randomUUID(), timestamp },
{ type: "ai-title", aiTitle: title, sessionId, uuid: crypto.randomUUID(), timestamp },
{ type: "agent-name", agentName: title, sessionId, uuid: crypto.randomUUID(), timestamp },
]);
return { appended: true, sessionPath, title };
}
export function claudeProjectKey(cwd) {
return path.resolve(String(cwd || process.cwd())).replace(/[^0-9A-Za-z]/g, "-");
}
function findClaudeDesktopSessionGroupDir() {
const root = claudeDesktopSessionsDir();
if (!fs.existsSync(root)) return null;
const accountDirs = childDirectories(root);
for (const accountDir of accountDirs) {
const groupDirs = childDirectories(accountDir);
if (groupDirs.length) return groupDirs[0];
}
return null;
}
function claudeDesktopSessionMetadataPath(sessionId) {
const groupDir = findClaudeDesktopSessionGroupDir();
if (!groupDir) return null;
return path.join(groupDir, `local_${sessionId}.json`);
}
function inferRelayTitleForClaudeSession(sessionId, entries) {
for (const entry of entries) {
if (isRelayTitle(entry.metadata.title)) return entry.metadata.title;
}
const transcriptTitle = readRelayTitleFromClaudeTranscript(sessionId, entries[0]?.metadata?.cwd || entries[0]?.metadata?.originCwd);
if (transcriptTitle) return transcriptTitle;
return null;
}
function readRelayTitleFromClaudeTranscript(sessionId, cwd = null) {
const sessionPath = findClaudeSessionPath({ sessionId, cwd });
if (!sessionPath) return null;
let latestTitle = null;
for (const line of fs.readFileSync(sessionPath, "utf8").split("\n")) {
if (!line.trim()) continue;
let row = null;
try {
row = JSON.parse(line);
} catch {
continue;
}
if (isRelayTitle(row.customTitle)) latestTitle = row.customTitle;
if (isRelayTitle(row.aiTitle)) latestTitle = row.aiTitle;
if (isRelayTitle(row.agentName)) latestTitle = row.agentName;
const text = row.message?.content?.find?.((item) => item?.type === "text")?.text || "";
const heading = text.match(/^#\s*(🔁\s+(?:From|To|Task:)[^\n]+)/m)?.[1];
if (isRelayTitle(heading)) latestTitle = heading;
}
return latestTitle;
}
function findClaudeSessionPath({ sessionId, cwd = null }) {
if (cwd) {
const direct = path.join(claudeProjectsDir(), claudeProjectKey(cwd), `${sessionId}.jsonl`);
if (fs.existsSync(direct)) return direct;
}
const root = claudeProjectsDir();
if (!fs.existsSync(root)) return null;
for (const dir of childDirectories(root)) {
const candidate = path.join(dir, `${sessionId}.jsonl`);
if (fs.existsSync(candidate)) return candidate;
}
return null;
}
function isRelayTitle(value) {
return /^🔁\s+((?:From|To)\s+.+:|Task:)/u.test(String(value || "").trim());
}
function childDirectories(dir) {
return fs
.readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(dir, entry.name))
.sort();
}
function childJsonFiles(dir) {
return fs
.readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
.map((entry) => path.join(dir, entry.name))
.sort();
}
function claudeMessageId() {
return `msg_01relay${crypto.randomBytes(18).toString("hex")}`;
}
function relayRequestId() {
return `req_relay_${crypto.randomBytes(12).toString("hex")}`;
}
function writeJsonlAtomic(filePath, rows) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tmp, `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`, { mode: 0o600 });
fs.renameSync(tmp, filePath);
}
function writeJsonAtomic(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
fs.renameSync(tmp, filePath);
}
function appendJsonlAtomic(filePath, rows) {
fs.appendFileSync(filePath, `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`, { mode: 0o600 });
}
function readJsonIfExists(filePath, fallback) {
try {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return fallback;
}
}
function isClaudeDesktopTitleRestored(nativeSession) {
const desktopImport = nativeSession.desktopImport;
if (!desktopImport?.attempted) return true;
const expectedTitle = nativeSession.title || nativeSession.desktopTitle?.title;
const metadataPath = nativeSession.desktopTitle?.metadataPath || nativeSession.desktopMetadataPath;
if (expectedTitle && metadataPath && fs.existsSync(metadataPath)) {
const metadata = readJsonIfExists(metadataPath, null);
return metadata?.title === expectedTitle && metadata?.titleSource === "user";
}
return nativeSession.desktopTitle?.restored === true;
}
function sleepSync(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}