Skip to content
Closed
Show file tree
Hide file tree
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
79 changes: 72 additions & 7 deletions scripts/hooks/session-start.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +11,59 @@

const {
getSessionsDir,
getSessionSearchDirs,
getLearnedSkillsDir,
findFiles,
ensureDir,
readFile,
stripAnsi,
log,
output
log
} = require('../lib/utils');
const { getPackageManager, getSelectionPrompt } = require('../lib/package-manager');
const { listAliases } = require('../lib/session-aliases');
const { detectProjectType } = require('../lib/project-detect');
const path = require('path');

function dedupeRecentSessions(searchDirs) {
const recentSessionsByName = new Map();

for (const [dirIndex, dir] of searchDirs.entries()) {
const matches = findFiles(dir, '*-session.tmp', { maxAge: 7 });

for (const match of matches) {
const basename = path.basename(match.path);
const current = {
...match,
basename,
dirIndex,
};
const existing = recentSessionsByName.get(basename);

if (
!existing
|| current.mtime > existing.mtime
|| (current.mtime === existing.mtime && current.dirIndex < existing.dirIndex)
) {
recentSessionsByName.set(basename, current);
}
}
}

return Array.from(recentSessionsByName.values())
.sort((left, right) => right.mtime - left.mtime || left.dirIndex - right.dirIndex);
}

async function main() {
const sessionsDir = getSessionsDir();
const learnedDir = getLearnedSkillsDir();
const additionalContextParts = [];

// Ensure directories exist
ensureDir(sessionsDir);
ensureDir(learnedDir);

// Check for recent session files (last 7 days)
const recentSessions = findFiles(sessionsDir, '*-session.tmp', { maxAge: 7 });
const recentSessions = dedupeRecentSessions(getSessionSearchDirs());

if (recentSessions.length > 0) {
const latest = recentSessions[0];
Expand All @@ -43,7 +74,7 @@ async function main() {
const content = stripAnsi(readFile(latest.path));
if (content && !content.includes('[Session context goes here]')) {
// Only inject if the session has actual content (not the blank template)
output(`Previous session summary:\n${content}`);
additionalContextParts.push(`Previous session summary:\n${content}`);
}
}

Expand Down Expand Up @@ -84,15 +115,49 @@ async function main() {
parts.push(`frameworks: ${projectInfo.frameworks.join(', ')}`);
}
log(`[SessionStart] Project detected — ${parts.join('; ')}`);
output(`Project type: ${JSON.stringify(projectInfo)}`);
additionalContextParts.push(`Project type: ${JSON.stringify(projectInfo)}`);
} else {
log('[SessionStart] No specific project type detected');
}

process.exit(0);
await writeSessionStartPayload(additionalContextParts.join('\n\n'));
}

function writeSessionStartPayload(additionalContext) {
return new Promise((resolve, reject) => {
let settled = false;
const payload = JSON.stringify({
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext
}
});

const handleError = (err) => {
if (settled) return;
settled = true;
if (err) {
log(`[SessionStart] stdout write error: ${err.message}`);
}
reject(err || new Error('stdout stream error'));
};

process.stdout.once('error', handleError);
process.stdout.write(payload, (err) => {
process.stdout.removeListener('error', handleError);
if (settled) return;
settled = true;
if (err) {
log(`[SessionStart] stdout write error: ${err.message}`);
reject(err);
return;
}
resolve();
});
});
}

main().catch(err => {
console.error('[SessionStart] Error:', err.message);
process.exit(0); // Don't block on errors
process.exitCode = 0; // Don't block on errors
});
69 changes: 65 additions & 4 deletions scripts/lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,20 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { execSync, spawnSync } = require('child_process');

// Platform detection
const isWindows = process.platform === 'win32';
const isMacOS = process.platform === 'darwin';
const isLinux = process.platform === 'linux';
const SESSION_DATA_DIR_NAME = 'session-data';
const LEGACY_SESSIONS_DIR_NAME = 'sessions';
const WINDOWS_RESERVED_SESSION_IDS = new Set([
'CON', 'PRN', 'AUX', 'NUL',
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'
]);

/**
* Get the user's home directory (cross-platform)
Expand All @@ -31,7 +39,21 @@ function getClaudeDir() {
* Get the sessions directory
*/
function getSessionsDir() {
return path.join(getClaudeDir(), 'sessions');
return path.join(getClaudeDir(), SESSION_DATA_DIR_NAME);
}

/**
* Get the legacy sessions directory used by older ECC installs
*/
function getLegacySessionsDir() {
return path.join(getClaudeDir(), LEGACY_SESSIONS_DIR_NAME);
}

/**
* Get all session directories to search, in canonical-first order
*/
function getSessionSearchDirs() {
return Array.from(new Set([getSessionsDir(), getLegacySessionsDir()]));
}

/**
Expand Down Expand Up @@ -107,16 +129,52 @@ function getProjectName() {
return path.basename(process.cwd()) || null;
}

/**
* Sanitize a string for use as a session filename segment.
* Replaces invalid characters with hyphens, collapses runs, strips
* leading/trailing hyphens, and removes leading dots so hidden-dir names
* like ".claude" map cleanly to "claude".
*
* Pure non-ASCII inputs get a stable 8-char hash so distinct names do not
* collapse to the same fallback session id. Mixed-script inputs retain their
* ASCII part and gain a short hash suffix for disambiguation.
*/
function sanitizeSessionId(raw) {
if (!raw || typeof raw !== 'string') return null;

const hasNonAscii = Array.from(raw).some(char => char.codePointAt(0) > 0x7f);
const normalized = raw.replace(/^\.+/, '');
const sanitized = normalized
.replace(/[^a-zA-Z0-9_-]/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^-+|-+$/g, '');

if (sanitized.length > 0) {
const suffix = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 6);
if (WINDOWS_RESERVED_SESSION_IDS.has(sanitized.toUpperCase())) {
return `${sanitized}-${suffix}`;
}
if (!hasNonAscii) return sanitized;
return `${sanitized}-${suffix}`;
}

const meaningful = normalized.replace(/[\s\p{P}]/gu, '');
if (meaningful.length === 0) return null;

return crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 8);
}

/**
* Get short session ID from CLAUDE_SESSION_ID environment variable
* Returns last 8 characters, falls back to project name then 'default'
* Returns last 8 characters, falls back to a sanitized project name then 'default'.
*/
function getSessionIdShort(fallback = 'default') {
const sessionId = process.env.CLAUDE_SESSION_ID;
if (sessionId && sessionId.length > 0) {
return sessionId.slice(-8);
const sanitized = sanitizeSessionId(sessionId.slice(-8));
if (sanitized) return sanitized;
}
return getProjectName() || fallback;
return sanitizeSessionId(getProjectName()) || sanitizeSessionId(fallback) || 'default';
}

/**
Expand Down Expand Up @@ -525,6 +583,8 @@ module.exports = {
getHomeDir,
getClaudeDir,
getSessionsDir,
getLegacySessionsDir,
getSessionSearchDirs,
getLearnedSkillsDir,
getTempDir,
ensureDir,
Expand All @@ -535,6 +595,7 @@ module.exports = {
getDateTimeString,

// Session/Project
sanitizeSessionId,
getSessionIdShort,
getGitRepoName,
getProjectName,
Expand Down