mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #55 from TeigenZhang/fix/auto-attach-on-restart
fix: auto-attach PTY on server restart
This commit is contained in:
Vendored
+822
@@ -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
|
||||
Executable
+33
@@ -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"
|
||||
+2
-2
@@ -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();
|
||||
|
||||
@@ -2105,8 +2105,9 @@ class CodemanApp {
|
||||
|
||||
// Track working directory for path normalization in Project Insights
|
||||
this.currentSessionWorkingDir = session?.workingDir || null;
|
||||
if (session && session.pid === null && session.status === 'idle') {
|
||||
// This is a restored session - attach to the existing screen/shell
|
||||
if (session && session.pid === null && !session._ended) {
|
||||
// Session has no PTY attached — either restored after server restart
|
||||
// or detached for some other reason. Re-attach regardless of status.
|
||||
try {
|
||||
const endpoint = session.mode === 'shell'
|
||||
? `/api/sessions/${sessionId}/shell`
|
||||
|
||||
@@ -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 = {
|
||||
<path d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="init" title="/init">/init</button>
|
||||
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
|
||||
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="arrow-left" title="Arrow left">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="arrow-right" title="Arrow right">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
|
||||
<button class="accessory-btn" data-action="shift-tab" title="Shift+Tab">⇧Tab</button>
|
||||
<button class="accessory-btn" data-action="ctrl-o" title="Ctrl+O">⌃O</button>
|
||||
<button class="accessory-btn" data-action="opt-enter" title="Option+Enter (newline)">⌥Enter</button>
|
||||
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
|
||||
<button class="accessory-btn" data-action="init" title="/init">/init</button>
|
||||
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
|
||||
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
|
||||
<button class="accessory-btn accessory-btn-arrow" data-action="dismiss" title="Dismiss keyboard">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
@@ -80,7 +95,7 @@ const KeyboardAccessoryBar = {
|
||||
this.handleAction(action, btn);
|
||||
|
||||
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
|
||||
if ((action === 'scroll-up' || action === 'scroll-down') ||
|
||||
if ((action === 'scroll-up' || action === 'scroll-down' || action === 'arrow-left' || action === 'arrow-right' || action === 'tab' || action === 'shift-tab' || action === 'ctrl-o' || action === 'opt-enter' || action === 'esc') ||
|
||||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
||||
if (typeof app !== 'undefined' && app.terminal) {
|
||||
app.terminal.focus();
|
||||
@@ -109,6 +124,27 @@ const KeyboardAccessoryBar = {
|
||||
case 'scroll-down':
|
||||
this.sendKey('\x1b[B');
|
||||
break;
|
||||
case 'arrow-left':
|
||||
this.sendKey('\x1b[D');
|
||||
break;
|
||||
case 'arrow-right':
|
||||
this.sendKey('\x1b[C');
|
||||
break;
|
||||
case 'esc':
|
||||
this.sendKey('\x1b');
|
||||
break;
|
||||
case 'opt-enter':
|
||||
this.sendKey('\x1b\r');
|
||||
break;
|
||||
case 'tab':
|
||||
this.sendKey('\t');
|
||||
break;
|
||||
case 'shift-tab':
|
||||
this.sendKey('\x1b[Z');
|
||||
break;
|
||||
case 'ctrl-o':
|
||||
this.sendKey('\x0f');
|
||||
break;
|
||||
case 'init':
|
||||
this.sendCommand('/init');
|
||||
break;
|
||||
|
||||
+15
-38
@@ -886,7 +886,8 @@ html.mobile-init .file-browser-panel {
|
||||
padding-right: calc(8px + var(--safe-area-right));
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
z-index: 51;
|
||||
transition: transform 0.15s ease-out;
|
||||
will-change: transform;
|
||||
@@ -896,10 +897,16 @@ html.mobile-init .file-browser-panel {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* 隐藏滚动条但保留滑动能力 */
|
||||
.keyboard-accessory-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.accessory-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
background: #2a2a2a;
|
||||
@@ -939,24 +946,6 @@ html.mobile-init .file-browser-panel {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss {
|
||||
padding: 8px 14px;
|
||||
background: #2a2a2a;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 6px;
|
||||
color: #e5e5e5;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss:active {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
/* Voice preview — positioned above accessory bar on mobile */
|
||||
.voice-preview {
|
||||
bottom: calc(var(--safe-area-bottom) + 94px);
|
||||
@@ -2068,7 +2057,8 @@ html.mobile-init .file-browser-panel {
|
||||
padding: 6px 8px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
z-index: 51;
|
||||
}
|
||||
|
||||
@@ -2076,10 +2066,15 @@ html.mobile-init .file-browser-panel {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.keyboard-accessory-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.accessory-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
background: #2a2a2a;
|
||||
@@ -2118,24 +2113,6 @@ html.mobile-init .file-browser-panel {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss {
|
||||
padding: 8px 14px;
|
||||
background: #2a2a2a;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 6px;
|
||||
color: #e5e5e5;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.accessory-btn-dismiss:active {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
iOS Safari Specific Fixes
|
||||
============================================================================ */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+21
-8
@@ -1767,15 +1767,28 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
this.sessions.set(session.id, session);
|
||||
await this.setupSessionListeners(session);
|
||||
this.persistSessionState(session);
|
||||
|
||||
// Mark it as restored (not started yet - user needs to attach)
|
||||
getLifecycleLog().log({
|
||||
event: 'recovered',
|
||||
sessionId: session.id,
|
||||
name: session.name,
|
||||
});
|
||||
console.log(`[Server] Restored session ${session.id} from mux ${muxSession.muxName}`);
|
||||
// Auto-attach PTY to the surviving tmux session immediately.
|
||||
// This ensures ALL sessions resume capturing output right away,
|
||||
// not just the one the client happens to select first.
|
||||
try {
|
||||
await session.startInteractive();
|
||||
getLifecycleLog().log({
|
||||
event: 'recovered',
|
||||
sessionId: session.id,
|
||||
name: session.name,
|
||||
});
|
||||
console.log(`[Server] Restored and attached session ${session.id} from mux ${muxSession.muxName}`);
|
||||
} catch (attachErr) {
|
||||
console.error(`[Server] Failed to attach session ${session.id}, keeping as detached:`, attachErr);
|
||||
getLifecycleLog().log({
|
||||
event: 'recovered',
|
||||
sessionId: session.id,
|
||||
name: session.name,
|
||||
});
|
||||
}
|
||||
|
||||
this.persistSessionState(session);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user