mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-08 14:52:42 +08:00
App-created sessions (started by sending a message from cloudcli) were titled with placeholder names — "Untitled Codex Session" from the disk indexer and, briefly, "New session" from the empty canonical upsert — before a later sync finally settled on the right text, causing the sidebar title to flicker. - Codex/OpenCode synchronizers now title app-created sessions (distinct app id mapped to a provider id) from the first user message, while sessions found purely by indexing keep their existing setup. Claude keeps its AI-generated titles; Cursor already used the first message. - Decode OpenCode's JSON-string-literal prompts so titles no longer surface wrapped in quotes; hoist unwrapJsonStringLiteral into shared/utils since it's now used by both the reader and synchronizer. - Guard the sidebar upsert merge so an empty summary can never blank out a title that is already set. - Add codex/opencode synchronizer tests for the app-created vs indexed naming paths.
243 lines
7.8 KiB
TypeScript
243 lines
7.8 KiB
TypeScript
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { readFile } from 'node:fs/promises';
|
|
|
|
import { sessionsDb } from '@/modules/database/index.js';
|
|
import {
|
|
buildLookupMap,
|
|
extractFirstValidJsonlData,
|
|
findFilesRecursivelyCreatedAfter,
|
|
normalizeSessionName,
|
|
readFileTimestamps,
|
|
} from '@/shared/utils.js';
|
|
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
|
|
|
|
type ParsedSession = {
|
|
sessionId: string;
|
|
projectPath: string;
|
|
sessionName?: string;
|
|
};
|
|
|
|
/**
|
|
* Session indexer for Codex transcript artifacts.
|
|
*/
|
|
export class CodexSessionSynchronizer implements IProviderSessionSynchronizer {
|
|
private readonly provider = 'codex' as const;
|
|
private readonly codexHome = path.join(os.homedir(), '.codex');
|
|
|
|
/**
|
|
* Scans ~/.codex/sessions and upserts discovered sessions into DB.
|
|
*/
|
|
async synchronize(since?: Date): Promise<number> {
|
|
const nameMap = await buildLookupMap(path.join(this.codexHome, 'session_index.jsonl'), 'id', 'thread_name');
|
|
const files = await findFilesRecursivelyCreatedAfter(
|
|
path.join(this.codexHome, 'sessions'),
|
|
'.jsonl',
|
|
since ?? null
|
|
);
|
|
|
|
let processed = 0;
|
|
for (const filePath of files) {
|
|
const parsed = await this.processSessionFile(filePath, nameMap);
|
|
if (!parsed) {
|
|
continue;
|
|
}
|
|
|
|
const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId)
|
|
?? sessionsDb.getSessionById(parsed.sessionId);
|
|
if (existingSession) {
|
|
// If session name is untitled and we now have a name, update it
|
|
if (existingSession.custom_name === 'Untitled Codex Session' && parsed.sessionName && parsed.sessionName !== 'Untitled Codex Session') {
|
|
sessionsDb.updateSessionCustomName(existingSession.session_id, parsed.sessionName);
|
|
}
|
|
}
|
|
|
|
const timestamps = await readFileTimestamps(filePath);
|
|
sessionsDb.createSession(
|
|
parsed.sessionId,
|
|
this.provider,
|
|
parsed.projectPath,
|
|
parsed.sessionName,
|
|
timestamps.createdAt,
|
|
timestamps.updatedAt,
|
|
filePath
|
|
);
|
|
processed += 1;
|
|
}
|
|
|
|
return processed;
|
|
}
|
|
|
|
/**
|
|
* Parses and upserts one Codex session JSONL file.
|
|
*/
|
|
async synchronizeFile(filePath: string): Promise<string | null> {
|
|
if (!filePath.endsWith('.jsonl')) {
|
|
return null;
|
|
}
|
|
|
|
const nameMap = await buildLookupMap(path.join(this.codexHome, 'session_index.jsonl'), 'id', 'thread_name');
|
|
const parsed = await this.processSessionFile(filePath, nameMap);
|
|
if (!parsed) {
|
|
return null;
|
|
}
|
|
|
|
const timestamps = await readFileTimestamps(filePath);
|
|
return sessionsDb.createSession(
|
|
parsed.sessionId,
|
|
this.provider,
|
|
parsed.projectPath,
|
|
parsed.sessionName,
|
|
timestamps.createdAt,
|
|
timestamps.updatedAt,
|
|
filePath
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Extracts session metadata from one Codex JSONL session file.
|
|
*/
|
|
private async processSessionFile(
|
|
filePath: string,
|
|
nameMap: Map<string, string>
|
|
): Promise<ParsedSession | null> {
|
|
const parsed = await extractFirstValidJsonlData(filePath, (rawData) => {
|
|
const data = rawData as Record<string, unknown>;
|
|
const payload = data.payload as Record<string, unknown> | undefined;
|
|
const sessionId = typeof payload?.id === 'string' ? payload.id : undefined;
|
|
const projectPath = typeof payload?.cwd === 'string' ? payload.cwd : undefined;
|
|
|
|
if (!sessionId || !projectPath) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
sessionId,
|
|
projectPath,
|
|
};
|
|
});
|
|
|
|
if (!parsed) {
|
|
return null;
|
|
}
|
|
|
|
// App-created sessions are keyed by an app id, so disk-discovered provider
|
|
// ids must be resolved through the provider-id mapping first.
|
|
const existingSession = sessionsDb.getSessionByProviderSessionId(parsed.sessionId)
|
|
?? sessionsDb.getSessionById(parsed.sessionId);
|
|
const existingSessionName = existingSession?.custom_name;
|
|
if (existingSessionName && existingSessionName !== 'Untitled Codex Session') {
|
|
return {
|
|
...parsed,
|
|
sessionName: normalizeSessionName(existingSessionName, 'Untitled Codex Session'),
|
|
};
|
|
}
|
|
|
|
// Sessions started by sending a message from cloudcli carry a distinct
|
|
// app-allocated session_id mapped to the provider id. For these we title the
|
|
// conversation from the first user message the user typed, instead of the
|
|
// generic "Untitled Codex Session" placeholder. Sessions discovered purely
|
|
// by indexing (session_id === provider_session_id) keep the existing
|
|
// thread_name/last-agent-message setup below.
|
|
const isAppCreated =
|
|
existingSession != null &&
|
|
existingSession.provider_session_id != null &&
|
|
existingSession.session_id !== existingSession.provider_session_id;
|
|
|
|
let sessionName = isAppCreated
|
|
? await this.extractFirstUserMessageFromStart(filePath)
|
|
: undefined;
|
|
if (!sessionName) {
|
|
sessionName = nameMap.get(parsed.sessionId);
|
|
}
|
|
if (!sessionName) {
|
|
sessionName = await this.extractLastAgentMessageFromEnd(filePath);
|
|
}
|
|
|
|
return {
|
|
...parsed,
|
|
sessionName: normalizeSessionName(sessionName, 'Untitled Codex Session'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns the first user message text in a Codex transcript, used to title
|
|
* app-created sessions from the prompt the user sent from cloudcli.
|
|
*
|
|
* Reads the `event_msg`/`user_message` payload rather than the raw
|
|
* `response_item` user turn so injected `<environment_context>` boilerplate is
|
|
* never mistaken for the user's prompt.
|
|
*/
|
|
private async extractFirstUserMessageFromStart(filePath: string): Promise<string | undefined> {
|
|
try {
|
|
const content = await readFile(filePath, 'utf8');
|
|
const lines = content.split(/\r?\n/);
|
|
|
|
for (const rawLine of lines) {
|
|
const line = rawLine.trim();
|
|
if (!line) {
|
|
continue;
|
|
}
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(line);
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
const data = parsed as Record<string, unknown>;
|
|
const eventType = typeof data.type === 'string' ? data.type : undefined;
|
|
const payload = data.payload as Record<string, unknown> | undefined;
|
|
const payloadType = typeof payload?.type === 'string' ? payload.type : undefined;
|
|
const message = typeof payload?.message === 'string' ? payload.message : undefined;
|
|
|
|
if (eventType === 'event_msg' && payloadType === 'user_message' && message?.trim()) {
|
|
return message;
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore missing/unreadable files so sync can continue.
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
private async extractLastAgentMessageFromEnd(filePath: string): Promise<string | undefined> {
|
|
try {
|
|
const content = await readFile(filePath, 'utf8');
|
|
const lines = content.split(/\r?\n/);
|
|
|
|
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
const line = lines[index]?.trim();
|
|
if (!line) {
|
|
continue;
|
|
}
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(line);
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
const data = parsed as Record<string, unknown>;
|
|
const eventType = typeof data.type === 'string' ? data.type : undefined;
|
|
const payload = data.payload as Record<string, unknown> | undefined;
|
|
const payloadType = typeof payload?.type === 'string' ? payload.type : undefined;
|
|
const lastAgentMessage = typeof payload?.last_agent_message === 'string'
|
|
? payload.last_agent_message
|
|
: undefined;
|
|
|
|
if (eventType === 'event_msg' && payloadType === 'task_complete' && lastAgentMessage?.trim()) {
|
|
return lastAgentMessage;
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore missing/unreadable files so sync can continue.
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
}
|