diff --git a/dist/state-store.js b/dist/state-store.js new file mode 100644 index 00000000..60f2fb04 --- /dev/null +++ b/dist/state-store.js @@ -0,0 +1,822 @@ +/** + * @fileoverview Persistent JSON state storage for Codeman. + * + * Persists application state with debounced writes (500ms) to prevent excessive disk I/O. + * State is split into two files: + * - `~/.codeman/state.json` — main app state (sessions, tasks, config, global stats) + * - `~/.codeman/state-inner.json` — Ralph loop state per session (changes rapidly) + * + * Key exports: + * - `StateStore` class — singleton store with circuit breaker for save failures + * - `getStore(filePath?)` — factory/singleton accessor + * + * Key methods: `getState()`, `getSessions()`, `setSession()`, `getConfig()`, + * `setConfig()`, `getGlobalStats()`, `getAggregateStats()`, `getTokenStats()`, + * `getDailyStats()`, `getRalphState()`, `setRalphState()`, `save()`, `saveNow()` + * + * Auto-migrates legacy `~/.claudeman/` → `~/.codeman/` on first load. + * + * @dependencies types (AppState, RalphSessionState, GlobalStats, TokenStats), + * utils (Debouncer, MAX_SESSION_TOKENS) + * @consumedby session-manager, ralph-loop, web/server, respawn-controller, + * hooks-config, and most subsystems + * + * @module state-store + */ +import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync, copyFileSync } from 'node:fs'; +import { writeFile, rename, unlink, copyFile, access } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { createInitialState, createInitialRalphSessionState, createInitialGlobalStats, } from './types.js'; +import { Debouncer, MAX_SESSION_TOKENS } from './utils/index.js'; +/** Debounce delay for batching state writes (ms) */ +const SAVE_DEBOUNCE_MS = 500; +/** + * Persistent JSON state storage with debounced writes. + * + * State is automatically loaded on construction and saved with 500ms + * debouncing to batch rapid updates into single disk writes. + * + * @example + * ```typescript + * const store = new StateStore(); + * + * // Read state + * const sessions = store.getState().sessions; + * + * // Modify and save + * store.getState().sessions[id] = sessionState; + * store.save(); // Debounced - won't write immediately + * + * // Force immediate write + * store.saveNow(); + * ``` + */ +/** Maximum consecutive save failures before circuit breaker opens */ +const MAX_CONSECUTIVE_FAILURES = 3; +export class StateStore { + state; + filePath; + saveDeb = new Debouncer(SAVE_DEBOUNCE_MS); + dirty = false; + dirtySessions = new Set(); + cachedSessionJsons = new Map(); + // Inner state storage (separate from main state to reduce write frequency) + ralphStates = new Map(); + ralphStatePath; + ralphStateSaveDeb = new Debouncer(SAVE_DEBOUNCE_MS); + ralphStateDirty = false; + // Circuit breaker for save failures (prevents hammering disk on persistent errors) + consecutiveSaveFailures = 0; + circuitBreakerOpen = false; + // Guard against concurrent saveNowAsync() calls (debounce can race with in-flight write) + _saveInFlight = null; + constructor(filePath) { + // Migrate legacy data directory (~/.claudeman → ~/.codeman) + if (!filePath) { + const legacyDir = join(homedir(), '.claudeman'); + const newDir = join(homedir(), '.codeman'); + if (existsSync(legacyDir) && !existsSync(newDir)) { + console.log(`[state-store] Migrating data directory: ${legacyDir} → ${newDir}`); + renameSync(legacyDir, newDir); + } + const legacyCasesDir = join(homedir(), 'claudeman-cases'); + const newCasesDir = join(homedir(), 'codeman-cases'); + if (existsSync(legacyCasesDir) && !existsSync(newCasesDir)) { + console.log(`[state-store] Migrating cases directory: ${legacyCasesDir} → ${newCasesDir}`); + renameSync(legacyCasesDir, newCasesDir); + } + } + this.filePath = filePath || join(homedir(), '.codeman', 'state.json'); + this.ralphStatePath = this.filePath.replace('.json', '-inner.json'); + this.state = this.load(); + this.state.config.stateFilePath = this.filePath; + // Pre-populate session cache for loaded state + for (const [id, session] of Object.entries(this.state.sessions)) { + this.cachedSessionJsons.set(id, JSON.stringify(session)); + } + this.loadRalphStates(); + } + _mergeWithInitialState(parsed) { + const initial = createInitialState(); + return { + ...initial, + ...parsed, + sessions: { ...parsed.sessions }, + tasks: { ...parsed.tasks }, + ralphLoop: { ...initial.ralphLoop, ...parsed.ralphLoop }, + config: { ...initial.config, ...parsed.config }, + }; + } + _resetCircuitBreaker() { + this.consecutiveSaveFailures = 0; + if (this.circuitBreakerOpen) { + console.log('[StateStore] Circuit breaker CLOSED - save succeeded'); + this.circuitBreakerOpen = false; + } + } + ensureDir() { + const dir = dirname(this.filePath); + if (!existsSync(dir)) { + // Use restrictive permissions (0o700) - owner only can read/write/traverse + // State files may contain sensitive session data + mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + } + load() { + // Try main file first, then .bak fallback + for (const path of [this.filePath, this.filePath + '.bak']) { + try { + if (existsSync(path)) { + const data = readFileSync(path, 'utf-8'); + const parsed = JSON.parse(data); + const result = this._mergeWithInitialState(parsed); + if (path !== this.filePath) { + console.warn(`[StateStore] Recovered state from backup: ${path}`); + } + return result; + } + } + catch (err) { + console.error(`Failed to load state from ${path}:`, err); + } + } + return createInitialState(); + } + /** + * Schedules a debounced save. + * Multiple calls within 500ms are batched into a single disk write. + * Uses async I/O to avoid blocking the event loop. + */ + save() { + this.dirty = true; + if (this.saveDeb.isPending) + return; // Already scheduled + this.saveDeb.schedule(() => { + this.saveNowAsync().catch((err) => { + console.error('[StateStore] Async save failed:', err); + }); + }); + } + /** + * Async version of saveNow — used by the debounced save() path. + * Uses non-blocking fs.promises to avoid blocking the event loop during + * the debounced write cycle. For synchronous shutdown flush, use saveNow(). + * + * Guards against concurrent execution: if a save is already in flight, + * waits for it to complete then re-checks dirty flag before starting another. + */ + async saveNowAsync() { + if (this._saveInFlight) { + await this._saveInFlight; + // After waiting, re-check if still dirty (the previous save may have handled it) + if (!this.dirty) + return; + } + this._saveInFlight = this._doSaveAsync(); + try { + await this._saveInFlight; + } + finally { + this._saveInFlight = null; + } + } + /** + * Assemble JSON string with incremental per-session caching. + * Only dirty sessions are re-serialized; clean sessions use cached JSON fragments. + */ + assembleStateJson() { + this.updateDirtySessionCache(); + // Build sessions object from cached fragments + const sessionParts = []; + for (const [id, session] of Object.entries(this.state.sessions)) { + let json = this.cachedSessionJsons.get(id); + if (!json) { + // Session not in cache (loaded from disk or set via direct state mutation) + json = JSON.stringify(session); + this.cachedSessionJsons.set(id, json); + } + sessionParts.push(`${JSON.stringify(id)}:${json}`); + } + this.pruneStaleCacheEntries(); + return this.buildPartialJson(sessionParts); + } + updateDirtySessionCache() { + // Re-serialize dirty sessions and update cache + for (const id of this.dirtySessions) { + const session = this.state.sessions[id]; + if (session) { + this.cachedSessionJsons.set(id, JSON.stringify(session)); + } + else { + this.cachedSessionJsons.delete(id); + } + } + this.dirtySessions.clear(); + } + pruneStaleCacheEntries() { + // Prune stale cache entries (sessions removed via direct state mutation) + if (this.cachedSessionJsons.size > Object.keys(this.state.sessions).length) { + for (const cachedId of this.cachedSessionJsons.keys()) { + if (!(cachedId in this.state.sessions)) { + this.cachedSessionJsons.delete(cachedId); + } + } + } + } + buildPartialJson(sessionParts) { + // Build final JSON: sessions from cache, everything else re-serialized (tiny) + const sessionsJson = `{${sessionParts.join(',')}}`; + // Serialize non-session fields individually (they're small) + const parts = [ + `"sessions":${sessionsJson}`, + `"tasks":${JSON.stringify(this.state.tasks)}`, + `"ralphLoop":${JSON.stringify(this.state.ralphLoop)}`, + `"config":${JSON.stringify(this.state.config)}`, + ]; + // Optional fields + if (this.state.globalStats) { + parts.push(`"globalStats":${JSON.stringify(this.state.globalStats)}`); + } + if (this.state.tokenStats) { + parts.push(`"tokenStats":${JSON.stringify(this.state.tokenStats)}`); + } + return `{${parts.join(',')}}`; + } + serializeState() { + try { + return this.assembleStateJson(); + } + catch (assembleErr) { + // Fallback to full serialization if incremental assembly fails + console.warn('[StateStore] assembleStateJson failed, falling back to full serialize:', assembleErr); + this.cachedSessionJsons.clear(); + this.dirtySessions.clear(); + try { + return JSON.stringify(this.state); + } + catch (err) { + console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err); + this.consecutiveSaveFailures++; + if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) { + console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly'); + this.circuitBreakerOpen = true; + } + return null; + } + } + } + async _doSaveAsync() { + this.saveDeb.cancel(); + if (!this.dirty) { + return; + } + // Circuit breaker: stop attempting writes after too many failures + if (this.circuitBreakerOpen) { + console.warn('[StateStore] Circuit breaker open - skipping save (too many consecutive failures)'); + return; + } + this.ensureDir(); + const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`; + const backupPath = this.filePath + '.bak'; + // Step 1: Serialize state (validates it's JSON-safe) + const json = this.serializeState(); + if (json === null) + return; + // Clear dirty flag BEFORE async I/O so mutations during write re-set it. + // The state snapshot is already captured in `json` above. + this.dirty = false; + // Step 2: Create backup via file copy (async, no read+parse+write) + try { + await access(this.filePath); + await copyFile(this.filePath, backupPath); + } + catch { + // Backup failed or file doesn't exist yet - continue with write + } + // Step 3: Atomic write: write to temp file, then rename (async) + try { + await writeFile(tempPath, json, 'utf-8'); + await rename(tempPath, this.filePath); + this._resetCircuitBreaker(); + } + catch (err) { + console.error('[StateStore] Failed to write state file:', err); + // Re-mark dirty so the data is retried on the next save cycle + this.dirty = true; + this.consecutiveSaveFailures++; + // Try to clean up temp file on error + try { + await unlink(tempPath); + } + catch { + // Temp file may not exist + } + // Check circuit breaker threshold + if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) { + console.error('[StateStore] Circuit breaker OPEN - writes failing repeatedly'); + this.circuitBreakerOpen = true; + } + } + } + /** + * Synchronous immediate write to disk using atomic write pattern. + * Used by flushAll() during shutdown when async is not appropriate. + * Prefer saveNowAsync() for normal operation. + */ + saveNow() { + this.saveDeb.cancel(); + if (!this.dirty) { + return; + } + if (this.circuitBreakerOpen) { + console.warn('[StateStore] Circuit breaker open - skipping save (too many consecutive failures)'); + return; + } + this.ensureDir(); + const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`; + const backupPath = this.filePath + '.bak'; + const json = this.serializeState(); + if (json === null) + return; + // Backup via atomic copy (avoids reading entire file into memory) + try { + if (existsSync(this.filePath)) { + copyFileSync(this.filePath, backupPath); + } + } + catch { + // Backup failed - continue with write + } + try { + writeFileSync(tempPath, json, 'utf-8'); + renameSync(tempPath, this.filePath); + // Clear dirty flag only AFTER successful write + this.dirty = false; + this._resetCircuitBreaker(); + } + catch (err) { + console.error('[StateStore] Failed to write state file:', err); + this.consecutiveSaveFailures++; + try { + if (existsSync(tempPath)) + unlinkSync(tempPath); + } + catch { + /* ignore */ + } + if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) { + console.error('[StateStore] Circuit breaker OPEN - writes failing repeatedly'); + this.circuitBreakerOpen = true; + } + } + } + /** + * Attempt to recover state from backup file. + * Call this if main state file is corrupt. + */ + recoverFromBackup() { + const backupPath = this.filePath + '.bak'; + try { + if (existsSync(backupPath)) { + const backupContent = readFileSync(backupPath, 'utf-8'); + const parsed = JSON.parse(backupContent); + this.state = this._mergeWithInitialState(parsed); + console.log('[StateStore] Successfully recovered state from backup'); + // Reset circuit breaker after successful recovery + this.circuitBreakerOpen = false; + this.consecutiveSaveFailures = 0; + return true; + } + } + catch (err) { + console.error('[StateStore] Failed to recover from backup:', err); + } + return false; + } + /** + * Reset the circuit breaker (for manual intervention). + */ + resetCircuitBreaker() { + this.circuitBreakerOpen = false; + this.consecutiveSaveFailures = 0; + console.log('[StateStore] Circuit breaker manually reset'); + } + /** Flushes any pending main state save. Call before shutdown. */ + flush() { + this.saveNow(); + } + /** Returns the full application state object. */ + getState() { + return this.state; + } + /** Returns all session states keyed by session ID. */ + getSessions() { + return this.state.sessions; + } + /** Returns a session state by ID, or null if not found. */ + getSession(id) { + return this.state.sessions[id] ?? null; + } + /** Sets a session state and triggers a debounced save. */ + setSession(id, session) { + this.state.sessions[id] = session; + this.dirtySessions.add(id); + this.save(); + } + /** Removes a session state and triggers a debounced save. */ + removeSession(id) { + delete this.state.sessions[id]; + this.cachedSessionJsons.delete(id); + this.dirtySessions.delete(id); + this.save(); + } + /** + * Cleans up stale sessions from state that don't have corresponding active sessions. + * @param activeSessionIds - Set of currently active session IDs + * @returns Number of sessions cleaned up + */ + cleanupStaleSessions(activeSessionIds) { + const allSessionIds = Object.keys(this.state.sessions); + const cleaned = []; + for (const sessionId of allSessionIds) { + if (!activeSessionIds.has(sessionId)) { + const name = this.state.sessions[sessionId]?.name; + cleaned.push({ id: sessionId, name }); + delete this.state.sessions[sessionId]; + this.cachedSessionJsons.delete(sessionId); + this.dirtySessions.delete(sessionId); + // Also clean up Ralph state for this session + this.ralphStates.delete(sessionId); + } + } + if (cleaned.length > 0) { + console.log(`[StateStore] Cleaned up ${cleaned.length} stale session(s) from state`); + this.save(); + } + return { count: cleaned.length, cleaned }; + } + /** Returns all task states keyed by task ID. */ + getTasks() { + return this.state.tasks; + } + /** Returns a task state by ID, or null if not found. */ + getTask(id) { + return this.state.tasks[id] ?? null; + } + /** Sets a task state and triggers a debounced save. */ + setTask(id, task) { + this.state.tasks[id] = task; + this.save(); + } + /** Removes a task state and triggers a debounced save. */ + removeTask(id) { + delete this.state.tasks[id]; + this.save(); + } + /** Returns the Ralph Loop state. */ + getRalphLoopState() { + return this.state.ralphLoop; + } + /** Updates Ralph Loop state (partial merge) and triggers a debounced save. */ + setRalphLoopState(ralphLoop) { + this.state.ralphLoop = { ...this.state.ralphLoop, ...ralphLoop }; + this.save(); + } + // ========== Orchestrator Loop State Methods ========== + /** Returns the orchestrator loop state, or null if never initialized. */ + getOrchestratorState() { + return this.state.orchestrator ?? null; + } + /** Updates orchestrator loop state (partial merge) and triggers a debounced save. */ + setOrchestratorState(orchestrator) { + if (this.state.orchestrator) { + this.state.orchestrator = { ...this.state.orchestrator, ...orchestrator }; + } + else { + // First initialization — caller must provide full state + this.state.orchestrator = orchestrator; + } + this.save(); + } + /** Clears orchestrator state and triggers a debounced save. */ + clearOrchestratorState() { + this.state.orchestrator = undefined; + this.save(); + } + /** Returns the application configuration. */ + getConfig() { + return this.state.config; + } + /** Updates configuration (partial merge) and triggers a debounced save. */ + setConfig(config) { + this.state.config = { ...this.state.config, ...config }; + this.save(); + } + /** Resets all state to initial values and saves immediately. */ + reset() { + this.state = createInitialState(); + this.state.config.stateFilePath = this.filePath; + this.ralphStates.clear(); + this.cachedSessionJsons.clear(); + this.dirtySessions.clear(); + this.saveNow(); // Immediate save for reset operations + this.saveRalphStatesNow(); + } + // ========== Global Stats Methods ========== + /** Returns global stats, creating initial stats if needed. */ + getGlobalStats() { + if (!this.state.globalStats) { + this.state.globalStats = createInitialGlobalStats(); + } + return this.state.globalStats; + } + /** + * Adds tokens and cost to global stats. + * Call when a session is deleted to preserve its usage in lifetime stats. + */ + addToGlobalStats(inputTokens, outputTokens, cost) { + // Sanity check: reject absurdly large values + if (inputTokens > MAX_SESSION_TOKENS || outputTokens > MAX_SESSION_TOKENS) { + console.warn(`[StateStore] Rejected absurd global stats: input=${inputTokens}, output=${outputTokens}`); + return; + } + // Reject negative values + if (inputTokens < 0 || outputTokens < 0 || cost < 0) { + console.warn(`[StateStore] Rejected negative global stats: input=${inputTokens}, output=${outputTokens}, cost=${cost}`); + return; + } + const stats = this.getGlobalStats(); + stats.totalInputTokens += inputTokens; + stats.totalOutputTokens += outputTokens; + stats.totalCost += cost; + stats.lastUpdatedAt = Date.now(); + this.save(); + } + /** Increments the total sessions created counter. */ + incrementSessionsCreated() { + const stats = this.getGlobalStats(); + stats.totalSessionsCreated += 1; + stats.lastUpdatedAt = Date.now(); + this.save(); + } + /** + * Returns aggregate stats combining global (deleted sessions) + active sessions. + * @param activeSessions Map of active session states + */ + getAggregateStats(activeSessions) { + const global = this.getGlobalStats(); + let activeInput = 0; + let activeOutput = 0; + let activeCost = 0; + let activeCount = 0; + for (const session of Object.values(activeSessions)) { + activeInput += session.inputTokens ?? 0; + activeOutput += session.outputTokens ?? 0; + activeCost += session.totalCost ?? 0; + activeCount++; + } + return { + totalInputTokens: global.totalInputTokens + activeInput, + totalOutputTokens: global.totalOutputTokens + activeOutput, + totalCost: global.totalCost + activeCost, + totalSessionsCreated: global.totalSessionsCreated, + activeSessionsCount: activeCount, + }; + } + // ========== Token Stats Methods (Daily Tracking) ========== + /** Maximum days to keep in daily history */ + static MAX_DAILY_HISTORY = 30; + /** + * Get or initialize token stats from state. + */ + getTokenStats() { + if (!this.state.tokenStats) { + this.state.tokenStats = { + daily: [], + lastUpdated: Date.now(), + }; + } + return this.state.tokenStats; + } + /** + * Get today's date string in YYYY-MM-DD format. + */ + getTodayDateString() { + const now = new Date(); + return now.toISOString().split('T')[0]; + } + /** + * Calculate estimated cost from tokens using Claude Opus pricing. + * Input: $15/M tokens, Output: $75/M tokens + */ + calculateEstimatedCost(inputTokens, outputTokens) { + const inputCost = (inputTokens / 1000000) * 15; + const outputCost = (outputTokens / 1000000) * 75; + return inputCost + outputCost; + } + // Track unique sessions per day for accurate session count + dailySessionIds = new Set(); + dailySessionDate = ''; + /** + * Record token usage for today. + * Accumulates tokens to today's entry, creating it if needed. + * @param inputTokens Input tokens to add + * @param outputTokens Output tokens to add + * @param sessionId Optional session ID for unique session counting + */ + recordDailyUsage(inputTokens, outputTokens, sessionId) { + if (inputTokens <= 0 && outputTokens <= 0) + return; + // Sanity check: reject absurdly large values (max 1M tokens per recording) + // Claude's context window is ~200k, so 1M per recording is already very generous + const MAX_TOKENS_PER_RECORDING = 1_000_000; + if (inputTokens > MAX_TOKENS_PER_RECORDING || outputTokens > MAX_TOKENS_PER_RECORDING) { + console.warn(`[StateStore] Rejected absurd token values: input=${inputTokens}, output=${outputTokens}`); + return; + } + const stats = this.getTokenStats(); + const today = this.getTodayDateString(); + // Reset daily session tracking on date change + if (this.dailySessionDate !== today) { + this.dailySessionIds.clear(); + this.dailySessionDate = today; + } + // Find or create today's entry + let todayEntry = stats.daily.find((e) => e.date === today); + if (!todayEntry) { + todayEntry = { + date: today, + inputTokens: 0, + outputTokens: 0, + estimatedCost: 0, + sessions: 0, + }; + stats.daily.unshift(todayEntry); // Add to front (most recent first) + } + // Accumulate tokens + todayEntry.inputTokens += inputTokens; + todayEntry.outputTokens += outputTokens; + todayEntry.estimatedCost = this.calculateEstimatedCost(todayEntry.inputTokens, todayEntry.outputTokens); + // Only increment session count for unique sessions + if (sessionId && !this.dailySessionIds.has(sessionId)) { + this.dailySessionIds.add(sessionId); + todayEntry.sessions = this.dailySessionIds.size; + } + // Prune old entries (keep last 30 days) + if (stats.daily.length > StateStore.MAX_DAILY_HISTORY) { + stats.daily = stats.daily.slice(0, StateStore.MAX_DAILY_HISTORY); + } + stats.lastUpdated = Date.now(); + this.save(); + } + /** + * Get daily stats for display. + * @param days Number of days to return (default: 30) + * @returns Array of daily entries, most recent first + */ + getDailyStats(days = 30) { + const stats = this.getTokenStats(); + return stats.daily.slice(0, days); + } + // ========== Inner State Methods (Ralph Loop tracking) ========== + loadRalphStates() { + try { + if (existsSync(this.ralphStatePath)) { + const data = readFileSync(this.ralphStatePath, 'utf-8'); + const parsed = JSON.parse(data); + for (const [sessionId, state] of Object.entries(parsed)) { + this.ralphStates.set(sessionId, state); + } + } + } + catch (err) { + console.error('Failed to load inner states:', err); + } + } + // Debounced save for inner states + saveRalphStates() { + this.ralphStateDirty = true; + if (this.ralphStateSaveDeb.isPending) + return; // Already scheduled + this.ralphStateSaveDeb.schedule(() => { + this.saveRalphStatesNow(); + }); + } + /** + * Immediate save for inner states using atomic write pattern. + * Writes to temp file first, then renames to prevent corruption on crash. + */ + saveRalphStatesNow() { + this.ralphStateSaveDeb.cancel(); + if (!this.ralphStateDirty) { + return; + } + // Clear dirty flag only on success to enable retry on failure + this.ensureDir(); + const data = Object.fromEntries(this.ralphStates); + // Atomic write: write to temp file, then rename (atomic on POSIX) + const tempPath = this.ralphStatePath + '.tmp'; + let json; + try { + json = JSON.stringify(data); + } + catch (err) { + console.error('[StateStore] Failed to serialize Ralph state (circular reference or invalid data):', err); + // Keep dirty flag true for retry - don't throw, let caller continue + return; + } + try { + writeFileSync(tempPath, json, 'utf-8'); + renameSync(tempPath, this.ralphStatePath); + // Success - clear dirty flag + this.ralphStateDirty = false; + } + catch (err) { + console.error('[StateStore] Failed to write Ralph state file:', err); + // Keep dirty flag true for retry on next save + // Try to clean up temp file on error + try { + if (existsSync(tempPath)) { + unlinkSync(tempPath); + } + } + catch (cleanupErr) { + console.warn('[StateStore] Failed to cleanup temp file during Ralph state save error:', cleanupErr); + } + // Don't throw - let caller continue, retry on next save + } + } + /** Returns inner state for a session, or null if not found. */ + getRalphState(sessionId) { + return this.ralphStates.get(sessionId) ?? null; + } + /** Sets inner state for a session and triggers a debounced save. */ + setRalphState(sessionId, state) { + this.ralphStates.set(sessionId, state); + this.saveRalphStates(); + } + /** + * Updates inner state for a session (partial merge). + * Creates initial state if none exists. + * @returns The updated inner state. + */ + updateRalphState(sessionId, updates) { + let state = this.ralphStates.get(sessionId); + if (!state) { + state = createInitialRalphSessionState(sessionId); + } + state = { ...state, ...updates, lastUpdated: Date.now() }; + this.ralphStates.set(sessionId, state); + this.saveRalphStates(); + return state; + } + /** Removes inner state for a session and triggers a debounced save. */ + removeRalphState(sessionId) { + if (this.ralphStates.has(sessionId)) { + this.ralphStates.delete(sessionId); + this.saveRalphStates(); + } + } + /** Returns a copy of all inner states as a Map. */ + getAllRalphStates() { + return new Map(this.ralphStates); + } + /** Flushes all pending saves (main and inner state). Call before shutdown. */ + flushAll() { + // Save both states, catching errors to ensure both are attempted + let mainError = null; + let ralphError = null; + try { + this.saveNow(); + } + catch (err) { + mainError = err; + console.error('[StateStore] Error flushing main state:', err); + } + try { + this.saveRalphStatesNow(); + } + catch (err) { + ralphError = err; + console.error('[StateStore] Error flushing Ralph state:', err); + } + // Log summary if any errors occurred + if (mainError || ralphError) { + console.warn('[StateStore] flushAll completed with errors - some state may not be persisted'); + } + } +} +// Singleton instance +let storeInstance = null; +/** + * Gets or creates the singleton StateStore instance. + * @param filePath Optional custom file path (only used on first call). + */ +export function getStore(filePath) { + if (!storeInstance) { + storeInstance = new StateStore(filePath); + } + return storeInstance; +} +//# sourceMappingURL=state-store.js.map \ No newline at end of file diff --git a/scripts/claudeman-launchd-wrapper.sh b/scripts/claudeman-launchd-wrapper.sh new file mode 100755 index 00000000..3c07a3bb --- /dev/null +++ b/scripts/claudeman-launchd-wrapper.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# Claudeman launchd wrapper +# +# 根因: Node 25 被 launchd 直接拉起时 V8 bootstrapper 概率性死锁 +# (进程存在、端口不监听、日志空白、sample 显示卡在 LoadEnvironment) +# 手动 nohup 同样环境则正常。通过 bash wrapper + exec 绕过此问题。 +# +# 额外加固: +# - 启动前清理占 3000 端口的野进程 +# - 写启动日志到 stderr(被 launchd 重定向到 StandardErrorPath) + +set -euo pipefail + +PORT=3000 +CLAUDEMAN_DIR="/Users/teigen/Documents/Workspace/AI_project/Claudeman" + +export HOME=/Users/teigen +export PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin + +echo "[wrapper] $(date '+%Y-%m-%d %H:%M:%S') starting claudeman web" >&2 + +# 清理占端口的野进程(非本进程树的残留 node) +STALE_PIDS=$(/usr/sbin/lsof -nP -iTCP:${PORT} -sTCP:LISTEN -t 2>/dev/null || true) +if [[ -n "$STALE_PIDS" ]]; then + echo "[wrapper] clearing stale processes on port ${PORT}: ${STALE_PIDS}" >&2 + for pid in $STALE_PIDS; do + kill "$pid" 2>/dev/null || true + done + sleep 2 +fi + +cd "$CLAUDEMAN_DIR" +exec /opt/homebrew/bin/node dist/index.js web --https -p "$PORT" diff --git a/src/state-store.ts b/src/state-store.ts index ec94d48b..0280d030 100644 --- a/src/state-store.ts +++ b/src/state-store.ts @@ -309,7 +309,7 @@ export class StateStore { this.ensureDir(); - const tempPath = this.filePath + '.tmp'; + const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`; const backupPath = this.filePath + '.bak'; // Step 1: Serialize state (validates it's JSON-safe) @@ -373,7 +373,7 @@ export class StateStore { this.ensureDir(); - const tempPath = this.filePath + '.tmp'; + const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`; const backupPath = this.filePath + '.bak'; const json = this.serializeState(); diff --git a/src/web/public/constants.js b/src/web/public/constants.js index ef31a045..dd954e1d 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -291,6 +291,12 @@ const SSE_EVENTS = { ORCHESTRATOR_TASK_FAILED: 'orchestrator:taskFailed', ORCHESTRATOR_COMPLETED: 'orchestrator:completed', ORCHESTRATOR_ERROR: 'orchestrator:error', + + // Cases + CASE_CREATED: 'case:created', + CASE_LINKED: 'case:linked', + CASE_DELETED: 'case:deleted', + CASE_ORDER_CHANGED: 'case:order-changed', }; // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/public/index.html b/src/web/public/index.html index 250be5aa..b8599ba1 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1360,6 +1360,7 @@

Add Case

+ +
diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index 77df2a2b..9ae2bee7 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -4,7 +4,7 @@ * Defines two exports: * * - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual - * keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, and dismiss. + * keyboard on mobile: Esc, arrow up/down, Tab, Shift+Tab, Ctrl+O, /init, /clear, /compact, paste, and dismiss. * Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state). * Commands are sent as text + Enter separately for Ink compatibility. * Only initializes on touch devices (MobileDetection.isTouchDevice guard). @@ -53,17 +53,32 @@ const KeyboardAccessoryBar = { - - - + + - + + +
+ + `; + }); + container.innerHTML = html; + }, + + async moveCaseUp(name) { + const cases = this.cases || []; + const idx = cases.findIndex(c => c.name === name); + if (idx <= 0) return; + // Swap positions (immutable) + const reordered = [...cases]; + [reordered[idx - 1], reordered[idx]] = [reordered[idx], reordered[idx - 1]]; + this.cases = reordered; + this.renderCaseManageList(); + await this.saveCaseOrder(reordered.map(c => c.name)); + }, + + async moveCaseDown(name) { + const cases = this.cases || []; + const idx = cases.findIndex(c => c.name === name); + if (idx < 0 || idx >= cases.length - 1) return; + const reordered = [...cases]; + [reordered[idx], reordered[idx + 1]] = [reordered[idx + 1], reordered[idx]]; + this.cases = reordered; + this.renderCaseManageList(); + await this.saveCaseOrder(reordered.map(c => c.name)); + }, + + async deleteCase(name) { + if (!confirm(`Delete case "${name}"? Linked cases will only be unlinked (folder preserved). Created cases will be permanently deleted.`)) { + return; + } + + try { + const res = await fetch(`/api/cases/${encodeURIComponent(name)}`, { method: 'DELETE' }); + const data = await res.json(); + if (data.success) { + this.showToast(`Case "${name}" ${data.data?.type === 'unlinked' ? 'unlinked' : 'deleted'}`, 'success'); + // Remove from current list and refresh + this.cases = (this.cases || []).filter(c => c.name !== name); + this.renderCaseManageList(); + // Refresh the dropdown + const select = document.getElementById('quickStartCase'); + const currentCase = select.value; + await this.loadQuickStartCases(currentCase === name ? null : currentCase); + } else { + this.showToast(data.error || 'Failed to delete case', 'error'); + } + } catch (err) { + this.showToast('Failed to delete case: ' + err.message, 'error'); + } + }, + + async saveCaseOrder(order) { + try { + await fetch('/api/cases/order', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ order }) + }); + // Refresh dropdown to reflect new order + const select = document.getElementById('quickStartCase'); + const currentCase = select.value; + await this.loadQuickStartCases(currentCase); + } catch (err) { + this.showToast('Failed to save case order: ' + err.message, 'error'); + } + }, + // ═══════════════════════════════════════════════════════════════ // Mobile Case Picker // ═══════════════════════════════════════════════════════════════ @@ -1181,6 +1288,11 @@ Object.assign(CodemanApp.prototype, { ${escapeHtml(c.name)} + + + + + @@ -1226,6 +1338,25 @@ Object.assign(CodemanApp.prototype, { } }, + async deleteCaseMobile(name) { + if (!confirm(`Delete case "${name}"?`)) return; + try { + const res = await fetch(`/api/cases/${encodeURIComponent(name)}`, { method: 'DELETE' }); + const data = await res.json(); + if (data.success) { + this.showToast(`Case "${name}" ${data.data?.type === 'unlinked' ? 'unlinked' : 'deleted'}`, 'success'); + this.cases = (this.cases || []).filter(c => c.name !== name); + // Refresh mobile picker and dropdown + this.closeMobileCasePicker(); + await this.loadQuickStartCases(); + } else { + this.showToast(data.error || 'Failed to delete case', 'error'); + } + } catch (err) { + this.showToast('Failed to delete case: ' + err.message, 'error'); + } + }, + showCreateCaseFromMobile() { // Close mobile picker first this.closeMobileCasePicker(); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 270868fd..7b4b9ef2 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2964,6 +2964,93 @@ body { font-size: 0.6rem; } +/* Case Management List (Manage tab) */ +.case-manage-list { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 320px; + overflow-y: auto; +} + +.case-manage-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 6px; + transition: background var(--transition-smooth); +} + +.case-manage-item:hover { + background: rgba(255, 255, 255, 0.06); +} + +.case-manage-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.case-manage-name { + font-size: 0.8rem; + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.case-manage-path { + font-size: 0.65rem; + color: var(--text-dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.case-manage-actions { + display: flex; + gap: 4px; + margin-left: 10px; + flex-shrink: 0; +} + +.case-manage-btn { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 4px; + color: var(--text-dim); + font-size: 0.65rem; + cursor: pointer; + transition: all var(--transition-smooth); +} + +.case-manage-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.1); + color: var(--text); +} + +.case-manage-btn:disabled { + opacity: 0.25; + cursor: not-allowed; +} + +.case-manage-btn-delete:hover:not(:disabled) { + background: rgba(239, 68, 68, 0.15); + border-color: rgba(239, 68, 68, 0.3); + color: #ef4444; +} + .toolbar-input { padding: 0.4rem 0.5rem; background: var(--bg-input); @@ -3692,6 +3779,25 @@ body { opacity: 1; } +.mobile-case-item-delete { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-dim); + opacity: 0.4; + flex-shrink: 0; + border-radius: 4px; + transition: all var(--transition-smooth); +} + +.mobile-case-item-delete:active { + opacity: 1; + color: #ef4444; + background: rgba(239, 68, 68, 0.15); +} + .mobile-case-picker-footer { padding: 12px 20px 16px; border-top: 1px solid rgba(255, 255, 255, 0.1); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 767c19b7..20b3f3fc 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -179,6 +179,7 @@ Object.assign(CodemanApp.prototype, { // Accumulate sub-line pixel deltas so slow swipes still scroll let pixelAccum = 0; + let didScroll = false; // track whether touchmove fired (tap vs scroll) container.addEventListener( 'touchstart', (ev) => { @@ -187,6 +188,7 @@ Object.assign(CodemanApp.prototype, { velocity = 0; pixelAccum = 0; isTouching = true; + didScroll = false; lastTime = 0; if (scrollFrame) { cancelAnimationFrame(scrollFrame); @@ -201,6 +203,7 @@ Object.assign(CodemanApp.prototype, { 'touchmove', (ev) => { if (ev.touches.length === 1 && isTouching) { + didScroll = true; const touchY = ev.touches[0].clientY; const delta = touchLastY - touchY; // positive = scroll down pixelAccum += delta; @@ -225,6 +228,12 @@ Object.assign(CodemanApp.prototype, { if (!scrollFrame && Math.abs(velocity) > 0.3) { scrollFrame = requestAnimationFrame(scrollLoop); } + // Tap (no scroll): refocus xterm's hidden textarea so keyboard input + // routes back to the terminal. Without this, a tap on the terminal area + // consumes the touch event but xterm's textarea never regains focus. + if (!didScroll && this.terminal) { + this.terminal.focus(); + } }, { passive: true } ); @@ -284,22 +293,6 @@ Object.assign(CodemanApp.prototype, { } this.flushFlickerBuffer(); } - // Clear viewport + scrollback for Ink-based sessions before sending SIGWINCH. - // fitAddon.fit() reflows content: lines at old width may wrap to more rows, - // pushing overflow into scrollback. Ink's cursor-up count is based on the - // pre-reflow line count, so ghost renders accumulate in scrollback. - // Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris, - // then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw. - const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null; - if ( - activeResizeSession && - activeResizeSession.mode !== 'shell' && - !activeResizeSession._ended && - this.terminal && - this.isTerminalAtBottom() - ) { - this.terminal.write('\x1b[3J\x1b[H\x1b[2J'); - } // Skip server resize while mobile keyboard is visible — sending SIGWINCH // causes Ink to re-render at the new row count, garbling terminal output. // Local fit() still runs so xterm knows the viewport size for scrolling. @@ -311,6 +304,24 @@ Object.assign(CodemanApp.prototype, { const rows = dims ? Math.max(dims.rows, MIN_ROWS) : MIN_ROWS; // Only send resize if dimensions actually changed if (!this._lastResizeDims || cols !== this._lastResizeDims.cols || rows !== this._lastResizeDims.rows) { + // Clear viewport + scrollback ONLY when dimensions actually change. + // fitAddon.fit() reflows content: lines at old width may wrap to more rows, + // pushing overflow into scrollback. Ink's cursor-up count is based on the + // pre-reflow line count, so ghost renders accumulate in scrollback. + // Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris, + // then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw. + // IMPORTANT: Only clear when we're actually sending SIGWINCH (dims changed). + // Clearing without a subsequent Ink redraw leaves the terminal blank. + const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null; + if ( + activeResizeSession && + activeResizeSession.mode !== 'shell' && + !activeResizeSession._ended && + this.terminal && + this.isTerminalAtBottom() + ) { + this.terminal.write('\x1b[3J\x1b[H\x1b[2J'); + } this._lastResizeDims = { cols, rows }; fetch(`/api/sessions/${this.activeSessionId}/resize`, { method: 'POST', @@ -1348,6 +1359,10 @@ Object.assign(CodemanApp.prototype, { if (this.fitAddon) this.fitAddon.fit(); const dims = this.getTerminalDimensions(); if (!dims) return; + // Update _lastResizeDims so the throttledResize handler won't redundantly + // clear the terminal for the same dimensions (which would blank the screen + // without a subsequent Ink redraw to repaint it). + this._lastResizeDims = { cols: dims.cols, rows: dims.rows }; // Fast path: WebSocket resize if (this._wsReady && this._wsSessionId === sessionId) { try { diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index efe0d9f8..6b825164 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -11,10 +11,10 @@ import { join, resolve } from 'node:path'; import { homedir } from 'node:os'; import type { ApiResponse, CaseInfo } from '../../types.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; -import { CreateCaseSchema, LinkCaseSchema } from '../schemas.js'; +import { CreateCaseSchema, LinkCaseSchema, CaseOrderSchema } from '../schemas.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { writeHooksConfig } from '../../hooks-config.js'; -import { CASES_DIR, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js'; +import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js'; import { SseEvent } from '../sse-events.js'; import type { EventPort, ConfigPort } from '../ports/index.js'; @@ -71,6 +71,18 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config } } + // Sort by persisted caseOrder from settings.json + const settings = await readJsonConfig>(SETTINGS_PATH, 'settings', {}); + const caseOrder = Array.isArray(settings.caseOrder) ? (settings.caseOrder as string[]) : []; + if (caseOrder.length > 0) { + const orderMap = new Map(caseOrder.map((name, idx) => [name, idx])); + cases.sort((a, b) => { + const ai = orderMap.get(a.name) ?? Number.MAX_SAFE_INTEGER; + const bi = orderMap.get(b.name) ?? Number.MAX_SAFE_INTEGER; + return ai - bi; + }); + } + return cases; }); @@ -150,6 +162,68 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config } }); + // ========== Delete / Unlink Case ========== + + app.delete('/api/cases/:name', async (req): Promise> => { + const { name } = req.params as { name: string }; + + if (!validatePathWithinBase(name, CASES_DIR)) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); + } + + // Check linked cases first — unlink only, don't delete the actual directory + const linkedCases = await readLinkedCases(); + if (linkedCases[name]) { + delete linkedCases[name]; + try { + await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2)); + ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'unlinked' }); + return { success: true, data: { name } }; + } catch (err) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + } + } + + // Case in CASES_DIR — delete the entire directory + const casePath = join(CASES_DIR, name); + if (!existsSync(casePath)) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${name}" not found`); + } + + try { + await fs.rm(casePath, { recursive: true, force: true }); + ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'deleted' }); + return { success: true, data: { name } }; + } catch (err) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + } + }); + + // ========== Reorder Cases ========== + + app.put('/api/cases/order', async (req): Promise> => { + const { order } = parseBody(CaseOrderSchema, req.body, 'Invalid order data'); + + try { + const dir = join(homedir(), '.codeman'); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + let existing: Record = {}; + try { + existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8')); + } catch { + /* ignore */ + } + const merged = { ...existing, caseOrder: order }; + await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2)); + ctx.broadcast(SseEvent.CaseOrderChanged, { order }); + return { success: true, data: { order } }; + } catch (err) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + } + }); + app.get('/api/cases/:name', async (req) => { const { name } = req.params as { name: string }; diff --git a/src/web/schemas.ts b/src/web/schemas.ts index d142e167..a5569952 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -431,6 +431,11 @@ export const LinkCaseSchema = z.object({ path: safePathSchema, }); +/** PUT /api/cases/order */ +export const CaseOrderSchema = z.object({ + order: z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')), +}); + /** POST /api/auth/revoke */ export const RevokeSessionSchema = z.object({ sessionToken: z.string().min(1).max(200).optional(), diff --git a/src/web/sse-events.ts b/src/web/sse-events.ts index 5c93e7be..d83893fa 100644 --- a/src/web/sse-events.ts +++ b/src/web/sse-events.ts @@ -325,6 +325,10 @@ export const OrchestratorError = 'orchestrator:error' as const; export const CaseCreated = 'case:created' as const; /** Existing directory linked as a case. */ export const CaseLinked = 'case:linked' as const; +/** Case deleted or unlinked. */ +export const CaseDeleted = 'case:deleted' as const; +/** Case ordering changed. */ +export const CaseOrderChanged = 'case:order-changed' as const; // ─── Namespace Re-export ───────────────────────────────────────────────────── @@ -485,4 +489,6 @@ export const SseEvent = { // Cases CaseCreated, CaseLinked, + CaseDeleted, + CaseOrderChanged, } as const;