Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const clients = new Set();

// Cache for session metadata (refreshed periodically)
let sessionMetadataCache = {};
let sessionAliasCache = {};
let lastMetadataRefresh = 0;
const METADATA_CACHE_TTL = 10000; // 10 seconds

Expand Down Expand Up @@ -110,6 +111,9 @@ function loadSessionMetadata() {
}

const metadata = {};
// 8-hex-prefix -> [full session ids]. Used to build aliases below; a prefix
// claimed by more than one session is dropped rather than guessed at.
const prefixOwners = {};

try {
if (!existsSync(PROJECTS_DIR)) {
Expand Down Expand Up @@ -138,6 +142,11 @@ function loadSessionMetadata() {
project: sessionInfo.projectPath || null,
jsonlPath: jsonlPath
};

const prefix = sessionId.slice(0, 8);
if (/^[0-9a-f]{8}$/i.test(prefix)) {
(prefixOwners[prefix] = prefixOwners[prefix] || []).push(sessionId);
}
}

// Also check sessions-index.json for custom names (if /rename was used)
Expand All @@ -164,11 +173,36 @@ function loadSessionMetadata() {
console.error('Error loading session metadata:', e);
}

// Newer Claude Code names task dirs session-XXXXXXXX (first 8 chars of the
// session UUID) while the transcript is still <full-uuid>.jsonl, so the
// metadata lookup misses and every such session loses its name and project.
//
// 8 hex chars is only 32 bits, so prefixes do collide -- roughly 1% odds by
// ~9,300 sessions. Rather than guess, only alias a prefix owned by exactly
// one session; on a collision we emit nothing and behaviour is unchanged.
const aliases = {};
for (const [prefix, owners] of Object.entries(prefixOwners)) {
if (owners.length !== 1) continue;
aliases[prefix] = owners[0];
aliases[`session-${prefix}`] = owners[0];
}

sessionMetadataCache = metadata;
sessionAliasCache = aliases;
lastMetadataRefresh = now;
return metadata;
}

/**
* Look up session metadata by task-directory name, falling back to an
* unambiguous short-id alias. Exact matches always win.
*/
function getSessionMeta(metadata, dirName) {
if (metadata[dirName]) return metadata[dirName];
const aliased = sessionAliasCache[dirName];
return (aliased && metadata[aliased]) || {};
}

/**
* Get display name for a session: customTitle > slug > null (frontend shows UUID)
*/
Expand Down Expand Up @@ -228,7 +262,7 @@ app.get('/api/sessions', async (req, res) => {
}

// Get metadata for this session
const meta = metadata[entry.name] || {};
const meta = getSessionMeta(metadata, entry.name);

// Use newest task file mtime, or fall back to directory mtime if no tasks
const modifiedAt = newestTaskMtime ? newestTaskMtime.toISOString() : stat.mtime.toISOString();
Expand Down Expand Up @@ -318,7 +352,7 @@ app.get('/api/tasks/all', async (req, res) => {
for (const sessionDir of sessionDirs) {
const sessionPath = path.join(TASKS_DIR, sessionDir.name);
const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
const meta = metadata[sessionDir.name] || {};
const meta = getSessionMeta(metadata, sessionDir.name);

for (const file of taskFiles) {
try {
Expand Down