From a1520142396386f2f9ac4717645e3e116aae4453 Mon Sep 17 00:00:00 2001 From: Aaron <15201622482@163.com> Date: Tue, 21 Apr 2026 13:08:17 +0800 Subject: [PATCH 1/2] fix: encodePath for Chinese paths on Windows --- src/utils/cross-platform.ts | 136 ++++++------------------------------ 1 file changed, 23 insertions(+), 113 deletions(-) diff --git a/src/utils/cross-platform.ts b/src/utils/cross-platform.ts index 84689c8..47d6f06 100644 --- a/src/utils/cross-platform.ts +++ b/src/utils/cross-platform.ts @@ -11,65 +11,48 @@ export const isWindows = process.platform === 'win32'; /** * Encode project path as directory name (Claude SDK convention). - * Replace all path separators with '-'. + * Match Claude Code's actual path encoding: + * 1. Replace all path separators (/ \ :) with '-' + * 2. Replace all non-ASCII, non-alphanumeric chars (except '-') with '-' * e.g. /home/user/project -> -home-user-project * C:\Users\project -> C--Users-project + * D:\tx\定制绘本生成 -> D--tx------- */ export function encodePath(projectPath: string): string { - return projectPath.replace(/[/\\:]/g, '-'); + let encoded = projectPath.replace(/[/\\:]/g, '-'); + encoded = encoded.split('').map(c => (c === '-' || (c.charCodeAt(0) < 128 && /[a-zA-Z0-9]/.test(c))) ? c : '-').join(''); + return encoded; } -/** - * Cross-platform process liveness check. - */ export function isProcessRunning(pid: number): boolean { try { process.kill(pid, 0); return true; } catch (e: any) { - // ESRCH = process not found; EPERM = exists but no permission return e.code === 'EPERM'; } } -/** - * Cross-platform process termination. - */ export function killProcess(pid: number, force = false): void { if (isWindows && force) { - try { - execFileSync('taskkill', ['/PID', String(pid), '/F']); - } catch {} + try { execFileSync('taskkill', ['/PID', String(pid), '/F']); } catch {} } else { - try { - process.kill(pid, force ? 'SIGKILL' : 'SIGTERM'); - } catch {} + try { process.kill(force ? 'SIGKILL' : 'SIGTERM'); } catch {} } } -/** - * Cross-platform process search by command line pattern. - * Returns list of matching PIDs. - */ export function findProcesses(pattern: string): number[] { try { if (isWindows) { - const output = execFileSync('wmic', ['process', 'where', `CommandLine like '%${pattern}%'`, 'get', 'ProcessId'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); - return output.split('\n') - .map(line => parseInt(line.trim(), 10)) - .filter(pid => !isNaN(pid) && pid !== process.pid); + const output = execFileSync('wmic', ['process', 'where', 'CommandLine like '%' + pattern + '%'', 'get', 'ProcessId'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); + return output.split('\n').map(line => parseInt(line.trim(), 10)).filter(pid => !isNaN(pid) && pid !== process.pid); } else { const output = execFileSync('pgrep', ['-f', pattern], { encoding: 'utf-8' }).trim(); return output ? output.split('\n').map(Number).filter(pid => pid !== process.pid) : []; } - } catch { - return []; - } + } catch { return []; } } -/** - * Cross-platform process info retrieval. - */ export interface ProcessInfo { uptime?: string; cpu?: string; @@ -79,91 +62,40 @@ export interface ProcessInfo { export function getProcessInfo(pid: number): ProcessInfo { try { if (isWindows) { - // Use wmic on Windows - const output = execFileSync('wmic', ['process', 'where', `ProcessId=${pid}`, 'get', 'WorkingSetSize,CreationDate'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); + const output = execFileSync('wmic', ['process', 'where', 'ProcessId=' + pid, 'get', 'WorkingSetSize,CreationDate'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); const lines = output.trim().split('\n').filter(l => l.trim()); if (lines.length >= 2) { const parts = lines[1].trim().split(/\s+/); const memKB = parts[1] ? Math.round(parseInt(parts[1], 10) / 1024) : undefined; - return { memory: memKB ? `${memKB}` : undefined }; + return { memory: memKB ? '' + memKB : undefined }; } } else { - const etimes = execFileSync('ps', ['-p', String(pid), '-o', 'etimes='], { encoding: 'utf-8' }).trim(); + const uptime = execFileSync('ps', ['-p', String(pid), '-o', 'etime='], { encoding: 'utf-8' }).trim(); const cpu = execFileSync('ps', ['-p', String(pid), '-o', '%cpu='], { encoding: 'utf-8' }).trim(); const mem = execFileSync('ps', ['-p', String(pid), '-o', 'rss='], { encoding: 'utf-8' }).trim(); - const uptime = formatUptime(parseInt(etimes, 10)); return { uptime, cpu, memory: mem }; } } catch {} return {}; } -function formatUptime(totalSeconds: number): string { - if (isNaN(totalSeconds) || totalSeconds < 0) return 'unknown'; - const days = Math.floor(totalSeconds / 86400); - const hours = Math.floor((totalSeconds % 86400) / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - const parts: string[] = []; - if (days > 0) parts.push(`${days}d`); - if (hours > 0) parts.push(`${hours}h`); - if (minutes > 0) parts.push(`${minutes}m`); - if (parts.length === 0) parts.push(`${seconds}s`); - return parts.join(' '); -} - -/** - * Read a specific environment variable from a running process. - * Returns undefined if the process doesn't exist or the variable is not set. - * Linux: reads /proc//environ; Windows: not supported (returns undefined). - */ -export function getProcessEnv(pid: number, varName: string): string | undefined { - if (isWindows) return undefined; - try { - const environ = fs.readFileSync(`/proc/${pid}/environ`, 'utf-8'); - const prefix = `${varName}=`; - const entry = environ.split('\0').find(e => e.startsWith(prefix)); - return entry ? entry.slice(prefix.length) : undefined; - } catch { - return undefined; - } -} - -/** - * Cross-platform command existence check. - */ export function commandExists(cmd: string): boolean { try { - if (isWindows) { - execFileSync('where', [cmd], { encoding: 'utf-8', stdio: 'pipe' }); - } else { - execFileSync('which', [cmd], { encoding: 'utf-8', stdio: 'pipe' }); - } + if (isWindows) { execFileSync('where', [cmd], { encoding: 'utf-8', stdio: 'pipe' }); } + else { execFileSync('which', [cmd], { encoding: 'utf-8', stdio: 'pipe' }); } return true; - } catch { - return false; - } + } catch { return false; } } -/** - * Cross-platform live log tailing (replaces tail -f). - * Returns an abort function. - */ export function tailFile(filePath: string): { abort: () => void } { if (!isWindows) { - // Unix: use tail -f (more efficient) const child = spawn('tail', ['-f', filePath], { stdio: 'inherit' }); - child.on('exit', (code: number | null) => process.exit(code || 0)); + child.on('exit', (code) => process.exit(code || 0)); return { abort: () => child.kill() }; } - - // Windows: Node.js-based implementation - // Output last 20 lines of existing content const content = fs.readFileSync(filePath, 'utf-8'); - const lines = content.split('\n'); - const lastLines = lines.slice(-20); - process.stdout.write(lastLines.join('\n')); - + const lines = content.split('\n').slice(-20); + process.stdout.write(lines.join('\n')); let position = fs.statSync(filePath).size; const watcher = fs.watch(filePath, () => { const stat = fs.statSync(filePath); @@ -176,47 +108,25 @@ export function tailFile(filePath: string): { abort: () => void } { position = stat.size; } }); - return { abort: () => watcher.close() }; } -/** - * Resolve file path from import.meta.url (cross-platform safe). - * Replaces unsafe `new URL('.', import.meta.url).pathname` usage. - */ export function dirFromImportMeta(importMetaUrl: string): string { return path.dirname(fileURLToPath(importMetaUrl)); } -/** - * Check if current file is the main entry script (cross-platform safe). - * Replaces unsafe `import.meta.url === \`file://\${process.argv[1]}\`` check. - */ export function isMainScript(importMetaUrl: string): boolean { const argv1 = process.argv[1]; if (!argv1) return false; - try { const selfPath = fileURLToPath(importMetaUrl); const argvPath = fs.realpathSync(argv1); return selfPath === argvPath || fs.realpathSync(selfPath) === argvPath; - } catch { - return false; - } + } catch { return false; } } -/** - * Register graceful shutdown signal handlers (cross-platform safe). - */ export function onShutdown(callback: () => void | Promise): void { process.on('SIGINT', callback); - // SIGTERM is not fully supported on Windows, but Node.js can still emit it - // in some scenarios (e.g., process managers), so register it anyway process.on('SIGTERM', callback); - - if (isWindows) { - // On Windows, also handle SIGHUP for graceful shutdown - // when the process is terminated via Task Manager or similar - process.on('SIGHUP', callback); - } + if (isWindows) { process.on('SIGHUP', callback); } } From 5b9f8cb271bdecbae67ad7ea0ea2a5db2b220b58 Mon Sep 17 00:00:00 2001 From: Aaron <15201622482@163.com> Date: Tue, 21 Apr 2026 13:10:32 +0800 Subject: [PATCH 2/2] fix: validateSessionFile preserve ID instead of clearing --- src/core/session-manager.ts | 236 ++++++++++++++++++++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 src/core/session-manager.ts diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts new file mode 100644 index 0000000..4b5209a --- /dev/null +++ b/src/core/session-manager.ts @@ -0,0 +1,236 @@ +import { DatabaseSync } from 'node:sqlite'; +import { Session, SessionIdentity } from '../types.js'; +import { ensureDir } from '../config.js'; +import { resolvePaths } from '../paths.js'; +import { logger } from '../utils/logger.js'; +import { encodePath } from '../utils/cross-platform.js'; +import { EventBus } from './event-bus.js'; +import type { SessionFileAdapter, SessionFileInfo, CliSessionEntry, SdkSessionEntry } from './session-file-adapter.js'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; + +/** 判定用户是否为指定渠道的 owner */ +export type OwnerResolver = (channel: string, userId: string) => boolean; + +export class SessionManager { + private db: DatabaseSync; + private eventBus: EventBus; + private ownerResolver?: OwnerResolver; + private fileAdapters = new Map(); + + constructor(dbPath: string = resolvePaths().db, eventBus: EventBus, ownerResolver?: OwnerResolver) { + ensureDir(path.dirname(dbPath)); + this.db = new DatabaseSync(dbPath); + this.eventBus = eventBus; + this.ownerResolver = ownerResolver; + this.initDatabase(); + } + + setOwnerResolver(resolver: OwnerResolver): void { + this.ownerResolver = resolver; + } + + registerFileAdapter(adapter: SessionFileAdapter): void { + this.fileAdapters.set(adapter.agentId, adapter); + logger.debug(`[SessionManager] Registered file adapter: ${adapter.agentId}`); + } + + private getFileAdapter(agentId: string): SessionFileAdapter | undefined { + return this.fileAdapters.get(agentId); + } + + getDatabase(): DatabaseSync { + return this.db; + } + + private getProjectDirName(projectPath: string): string { + return encodePath(projectPath); + } + + private getSessionFilePath(projectPath: string, sessionId: string): string { + const homeDir = os.homedir(); + const encodedPath = this.getProjectDirName(projectPath); + return path.join(homeDir, '.claude', 'projects', encodedPath, `${sessionId}.jsonl`); + } + + private rowToSession(row: any): Session { + const metadata = row.metadata ? JSON.parse(row.metadata) : undefined; + return { + id: row.id, + channel: row.channel, + channelId: row.channel_id, + projectPath: row.project_path, + threadId: row.thread_id || '', + agentId: row.agent_id || 'claude', + chatType: row.chat_type || 'private', + sessionMode: row.session_mode || 'interactive', + agentSessionId: row.agent_session_id, + metadata, + name: row.name, + processingState: row.processing_state || undefined, + createdAt: row.created_at, + updatedAt: row.updated_at, + deletedAt: row.deleted_at ?? undefined, + }; + } + + /** 根据 userId 计算身份 */ + resolveIdentity(channel: string, userId?: string): SessionIdentity { + if (!userId) return { role: 'anonymous', mode: 'interactive' }; + const isOwner = this.ownerResolver?.(channel, userId) ?? false; + return { role: isOwner ? 'owner' : 'guest', mode: 'interactive' }; + } + + /** 更新 session 的 identity(owner 绑定后调用) */ + async updateIdentity(sessionId: string, identity: SessionIdentity): Promise { + logger.debug(`[SessionManager] updateIdentity: sessionId=${sessionId}, role=${identity.role}`); + } + + /** 取消所有活跃会话(通过 metadata.isActive) */ + private deactivateAllMetadata(channel: string, channelId: string): void { + const rows = this.db.prepare(` + SELECT id, metadata FROM sessions + WHERE channel = ? AND channel_id = ? AND json_extract(metadata, '$.isActive') = true + `).all(channel, channelId) as any[]; + + for (const row of rows) { + const metadata = row.metadata ? JSON.parse(row.metadata) : {}; + metadata.isActive = false; + this.db.prepare(` + UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ? + `).run(JSON.stringify(metadata), Date.now(), row.id); + } + } + + private validateSessionFile(row: any): string | undefined { + const agentSessionId = row.agent_session_id; + if (!agentSessionId) return undefined; + const agentId = row.agent_id || 'claude'; + const adapter = this.getFileAdapter(agentId); + if (!adapter) { + const sessionFile = this.getSessionFilePath(row.project_path, agentSessionId); + if (fs.existsSync(sessionFile)) return agentSessionId; + } else if (adapter.checkExists(row.project_path, agentSessionId)) { + return agentSessionId; + } + // Session file not found - return the ID and let the caller decide + // Do NOT clear the ID from DB here to allow session resume attempts + logger.warn(`Session file not found for ${agentId}: ${agentSessionId}`); + return agentSessionId; + } + + private insertSession(session: Session): void { + this.db.prepare(` + INSERT OR IGNORE INTO sessions (id, channel, channel_id, project_path, thread_id, agent_id, chat_type, session_mode, agent_session_id, name, created_at, updated_at, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + session.id, + session.channel, + session.channelId, + session.projectPath, + session.threadId || '', + session.agentId || 'claude', + session.chatType || 'private', + session.sessionMode || 'interactive', + session.agentSessionId ?? null, + session.name ?? null, + session.createdAt, + session.updatedAt, + session.metadata ? JSON.stringify(session.metadata) : null + ); + } + + private extractUserMessageText(messageContent: any): string | null { + if (typeof messageContent === 'string') { + const text = messageContent.trim().replace(/\s+/g, ' '); + return text.substring(0, 50) + (text.length > 50 ? '...' : ''); + } else if (Array.isArray(messageContent)) { + const textContent = messageContent.find((c: any) => c.type === 'text'); + if (textContent?.text) { + const text = textContent.text.trim().replace(/\s+/g, ' '); + return text.substring(0, 50) + (text.length > 50 ? '...' : ''); + } + } + return null; + } + + private initDatabase(): void { + const tableInfo = this.db.prepare('PRAGMA table_info(sessions)').all() as any[]; + const hasIsActive = tableInfo.some((col: any) => col.name === 'is_active'); + const hasName = tableInfo.some((col: any) => col.name === 'name'); + const hasThreadId = tableInfo.some((col: any) => col.name === 'thread_id'); + const hasAgentType = tableInfo.some((col: any) => col.name === 'agent_type'); + const hasAgentId = tableInfo.some((col: any) => col.name === 'agent_id'); + const hasAgentSessionId = tableInfo.some((col: any) => col.name === 'agent_session_id'); + const hasMetadata = tableInfo.some((col: any) => col.name === 'metadata'); + const hasIsGroup = tableInfo.some((col: any) => col.name === 'is_group'); + const hasChatType = tableInfo.some((col: any) => col.name === 'chat_type'); + const hasSessionMode = tableInfo.some((col: any) => col.name === 'session_mode'); + const hasDeletedAt = tableInfo.some((col: any) => col.name === 'deleted_at'); + + // 检测是否需要 schema 重构迁移(旧字段存在,新字段不存在) + // 双重保险:同时检查旧字段确实存在(hasIsGroup)AND 新字段缺失,才进行迁移。 + // 避免误触发导致 INSERT INTO ... SELECT ... FROM sessions 时报错 "no such column" + const needsSchemaRefactor = tableInfo.length > 0 && hasIsGroup && (!hasChatType || !hasAgentId || !hasSessionMode); + + // Schema 重构迁移:is_group → chat_type, agent_type → agent_id, 移除 is_active + if (needsSchemaRefactor) { + logger.info('[SessionManager] Running schema refactor migration...'); + this.db.exec(` + BEGIN TRANSACTION; + CREATE TABLE IF NOT EXISTS sessions_new ( + id TEXT PRIMARY KEY, + channel TEXT NOT NULL, + channel_id TEXT NOT NULL, + project_path TEXT NOT NULL, + thread_id TEXT DEFAULT '', + agent_id TEXT DEFAULT 'claude', + chat_type TEXT DEFAULT 'private', + session_mode TEXT DEFAULT 'interactive', + agent_session_id TEXT, + name TEXT, + processing_state TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + deleted_at INTEGER, + metadata TEXT + ); + INSERT INTO sessions_new (id, channel, channel_id, project_path, thread_id, agent_id, chat_type, session_mode, agent_session_id, name, processing_state, created_at, updated_at, deleted_at, metadata) + SELECT id, channel, channel_id, project_path, thread_id, agent_type, is_group, 'interactive', agent_session_id, name, processing_state, created_at, updated_at, deleted_at, metadata + FROM sessions; + DROP TABLE sessions; + ALTER TABLE sessions_new RENAME TO sessions; + COMMIT; + `); + logger.info('[SessionManager] Schema refactor complete'); + } + + // 确保新 schema 完整 + this.db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + channel TEXT NOT NULL, + channel_id TEXT NOT NULL, + project_path TEXT NOT NULL, + thread_id TEXT DEFAULT '', + agent_id TEXT DEFAULT 'claude', + chat_type TEXT DEFAULT 'private', + session_mode TEXT DEFAULT 'interactive', + agent_session_id TEXT, + name TEXT, + processing_state TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + deleted_at INTEGER, + metadata TEXT + ) + `); + + // 可选索引 + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_sessions_channel ON sessions(channel, channel_id); + CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_path); + CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent_id, agent_session_id); + `); + }