(m + 1);
+
+ // Initialize first row
+ for (let i = 0; i <= m; i++) {
+ prev[i] = i;
+ }
+
+ // Fill the matrix row by row
+ for (let j = 1; j <= n; j++) {
+ curr[0] = j;
+
+ for (let i = 1; i <= m; i++) {
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
+ curr[i] = Math.min(
+ prev[i] + 1, // deletion
+ curr[i - 1] + 1, // insertion
+ prev[i - 1] + cost // substitution
+ );
+ }
+
+ // Swap rows
+ [prev, curr] = [curr, prev];
+ }
+
+ return prev[m];
+}
+
+/**
+ * Calculate similarity ratio between two strings (0 to 1).
+ * Uses Levenshtein distance normalized by the longer string's length.
+ *
+ * @param a - First string
+ * @param b - Second string
+ * @returns Similarity ratio (1.0 = identical, 0.0 = completely different)
+ *
+ * @example
+ * stringSimilarity('hello', 'hello') // 1.0
+ * stringSimilarity('hello', 'helo') // 0.8 (4/5 similar)
+ * stringSimilarity('abc', 'xyz') // 0.0 (3 edits, length 3)
+ */
+export function stringSimilarity(a: string, b: string): number {
+ if (a === b) return 1.0;
+ if (a.length === 0 && b.length === 0) return 1.0;
+ if (a.length === 0 || b.length === 0) return 0.0;
+
+ const distance = levenshteinDistance(a, b);
+ const maxLength = Math.max(a.length, b.length);
+ return 1 - distance / maxLength;
+}
+
+/**
+ * Check if two strings are similar within a given threshold.
+ *
+ * @param a - First string
+ * @param b - Second string
+ * @param threshold - Minimum similarity ratio (default: 0.85 = 85% similar)
+ * @returns True if similarity >= threshold
+ *
+ * @example
+ * isSimilar('COMPLETE', 'COMPLET', 0.85) // true (87.5% similar)
+ * isSimilar('COMPLETE', 'DONE', 0.85) // false (0% similar)
+ */
+export function isSimilar(a: string, b: string, threshold = 0.85): boolean {
+ return stringSimilarity(a, b) >= threshold;
+}
+
+/**
+ * Check if two strings are similar with edit distance tolerance.
+ * More intuitive for short strings than percentage-based threshold.
+ *
+ * @param a - First string
+ * @param b - Second string
+ * @param maxDistance - Maximum allowed edit distance (default: 2)
+ * @returns True if edit distance <= maxDistance
+ *
+ * @example
+ * isSimilarByDistance('COMPLETE', 'COMPLET', 2) // true (distance 1)
+ * isSimilarByDistance('COMPLETE', 'COMP', 2) // false (distance 4)
+ */
+export function isSimilarByDistance(a: string, b: string, maxDistance = 2): boolean {
+ return levenshteinDistance(a, b) <= maxDistance;
+}
+
+/**
+ * Normalize a completion phrase for comparison.
+ * Handles variations in case, whitespace, and separators.
+ *
+ * @param phrase - Raw completion phrase
+ * @returns Normalized phrase (uppercase, no separators)
+ *
+ * @example
+ * normalizePhrase('task_done') // 'TASKDONE'
+ * normalizePhrase('TASK-DONE') // 'TASKDONE'
+ * normalizePhrase('Task Done') // 'TASKDONE'
+ */
+export function normalizePhrase(phrase: string): string {
+ return phrase
+ .toUpperCase()
+ .replace(/[\s_\-\.]+/g, '') // Remove whitespace, underscores, hyphens, dots
+ .trim();
+}
+
+/**
+ * Check if two completion phrases match with fuzzy tolerance.
+ *
+ * First normalizes both phrases, then checks:
+ * 1. Exact match after normalization
+ * 2. Edit distance <= maxDistance for typo tolerance
+ *
+ * @param phrase1 - First phrase to compare
+ * @param phrase2 - Second phrase to compare
+ * @param maxDistance - Maximum edit distance for fuzzy match (default: 2)
+ * @returns True if phrases match (exact or fuzzy)
+ *
+ * @example
+ * fuzzyPhraseMatch('COMPLETE', 'COMPLETE') // true (exact)
+ * fuzzyPhraseMatch('COMPLETE', 'COMPLET') // true (typo)
+ * fuzzyPhraseMatch('TASK_DONE', 'TASKDONE') // true (separator)
+ * fuzzyPhraseMatch('COMPLETE', 'FINISHED') // false (different word)
+ */
+export function fuzzyPhraseMatch(
+ phrase1: string,
+ phrase2: string,
+ maxDistance = 2
+): boolean {
+ const norm1 = normalizePhrase(phrase1);
+ const norm2 = normalizePhrase(phrase2);
+
+ // Exact match after normalization
+ if (norm1 === norm2) return true;
+
+ // For short phrases (< 6 chars), require exact match to avoid false positives
+ // e.g., "DONE" shouldn't match "DENY"
+ if (norm1.length < 6 || norm2.length < 6) {
+ return false;
+ }
+
+ // Fuzzy match with edit distance
+ return isSimilarByDistance(norm1, norm2, maxDistance);
+}
+
+/**
+ * Generate a content hash for todo deduplication.
+ * Normalizes content and generates a simple hash.
+ *
+ * @param content - Todo item content
+ * @returns Normalized hash string for comparison
+ */
+export function todoContentHash(content: string): string {
+ // Normalize: lowercase, collapse whitespace, remove punctuation
+ const normalized = content
+ .toLowerCase()
+ .replace(/\s+/g, ' ')
+ .replace(/[^\w\s]/g, '')
+ .trim();
+
+ // Simple hash using reduce (fast, good enough for deduplication)
+ let hash = 0;
+ for (let i = 0; i < normalized.length; i++) {
+ const char = normalized.charCodeAt(i);
+ hash = ((hash << 5) - hash) + char;
+ hash = hash & hash; // Convert to 32-bit integer
+ }
+ return hash.toString(36);
+}
diff --git a/src/web/public/app.js b/src/web/public/app.js
index 56275474..317a75eb 100644
--- a/src/web/public/app.js
+++ b/src/web/public/app.js
@@ -6504,8 +6504,14 @@ class ClaudemanApp {
if (total > 0) {
tokensEl.style.display = '';
const tokenStr = this.formatTokens(total);
- const estimatedCost = this.estimateCost(input, output);
- tokensEl.textContent = `${tokenStr} tokens · $${estimatedCost.toFixed(2)}`;
+ const settings = this.loadAppSettingsFromStorage();
+ const showCost = settings.showCost ?? false;
+ if (showCost) {
+ const estimatedCost = this.estimateCost(input, output);
+ tokensEl.textContent = `${tokenStr} tokens · $${estimatedCost.toFixed(2)}`;
+ } else {
+ tokensEl.textContent = `${tokenStr} tokens`;
+ }
} else {
tokensEl.style.display = 'none';
}
@@ -6865,10 +6871,14 @@ class ClaudemanApp {
const estimatedCost = this.estimateCost(totalInput, totalOutput);
const tokenEl = this.$('headerTokens');
if (tokenEl) {
- tokenEl.textContent = total > 0 ? `${display} tokens · $${estimatedCost.toFixed(2)}` : '0 tokens';
+ const settings = this.loadAppSettingsFromStorage();
+ const showCost = settings.showCost ?? false;
+ tokenEl.textContent = total > 0
+ ? (showCost ? `${display} tokens · $${estimatedCost.toFixed(2)}` : `${display} tokens`)
+ : '0 tokens';
tokenEl.title = this.globalStats
- ? `Lifetime: ${this.globalStats.totalSessionsCreated} sessions created\nEstimated cost based on Claude Opus pricing`
- : 'Token usage across active sessions\nEstimated cost based on Claude Opus pricing';
+ ? `Lifetime: ${this.globalStats.totalSessionsCreated} sessions created${showCost ? '\nEstimated cost based on Claude Opus pricing' : ''}`
+ : `Token usage across active sessions${showCost ? '\nEstimated cost based on Claude Opus pricing' : ''}`;
}
}
@@ -7781,16 +7791,17 @@ class ClaudemanApp {
document.getElementById('appSettingsDefaultDir').value = settings.defaultWorkingDir || '';
document.getElementById('appSettingsRalphEnabled').checked = settings.ralphTrackerEnabled ?? false;
// Header visibility settings (default to true/enabled)
- document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? true;
+ document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? false;
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? true;
document.getElementById('appSettingsShowTokenCount').checked = settings.showTokenCount ?? true;
+ document.getElementById('appSettingsShowCost').checked = settings.showCost ?? false;
document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? true;
- document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? true;
+ document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? false;
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? false;
- document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? true;
+ document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? false;
document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? true;
- document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? false;
- document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? true;
+ document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? true;
+ document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? false;
// Claude CLI settings
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
const allowedToolsRow = document.getElementById('allowedToolsRow');
@@ -7906,6 +7917,7 @@ class ClaudemanApp {
showFontControls: document.getElementById('appSettingsShowFontControls').checked,
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
showTokenCount: document.getElementById('appSettingsShowTokenCount').checked,
+ showCost: document.getElementById('appSettingsShowCost').checked,
showMonitor: document.getElementById('appSettingsShowMonitor').checked,
showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked,
showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked,
@@ -8107,7 +8119,7 @@ class ClaudemanApp {
applyHeaderVisibilitySettings() {
const settings = this.loadAppSettingsFromStorage();
// Default all to true (enabled) if not set
- const showFontControls = settings.showFontControls ?? true;
+ const showFontControls = settings.showFontControls ?? false;
const showSystemStats = settings.showSystemStats ?? true;
const showTokenCount = settings.showTokenCount ?? true;
@@ -8141,7 +8153,7 @@ class ClaudemanApp {
applyMonitorVisibility() {
const settings = this.loadAppSettingsFromStorage();
const showMonitor = settings.showMonitor ?? true;
- const showSubagents = settings.showSubagents ?? true;
+ const showSubagents = settings.showSubagents ?? false;
const showFileBrowser = settings.showFileBrowser ?? false;
const monitorPanel = document.getElementById('monitorPanel');
@@ -10679,7 +10691,7 @@ class ClaudemanApp {
*/
updateSubagentWindowVisibility() {
const settings = this.loadAppSettingsFromStorage();
- const activeTabOnly = settings.subagentActiveTabOnly ?? false;
+ const activeTabOnly = settings.subagentActiveTabOnly ?? true;
for (const [agentId, windowInfo] of this.subagentWindows) {
// Get parent from PERSISTENT map (THE source of truth)
@@ -10722,7 +10734,7 @@ class ClaudemanApp {
const existing = this.subagentWindows.get(agentId);
const agent = this.subagents.get(agentId);
const settings = this.loadAppSettingsFromStorage();
- const activeTabOnly = settings.subagentActiveTabOnly ?? false;
+ const activeTabOnly = settings.subagentActiveTabOnly ?? true;
// If window is hidden (different tab) and activeTabOnly is enabled, switch to parent tab
if (existing.hidden && agent?.parentSessionId && activeTabOnly) {
@@ -10883,7 +10895,7 @@ class ClaudemanApp {
// Check if this window should be visible based on settings
// Use the PERSISTENT parent map for accurate tab-based visibility
const settings = this.loadAppSettingsFromStorage();
- const activeTabOnly = settings.subagentActiveTabOnly ?? false;
+ const activeTabOnly = settings.subagentActiveTabOnly ?? true;
let shouldHide = false;
if (activeTabOnly) {
const storedParent = this.subagentParentMap.get(agentId);
@@ -11132,7 +11144,7 @@ class ClaudemanApp {
if (windowData) {
const settings = this.loadAppSettingsFromStorage();
- const activeTabOnly = settings.subagentActiveTabOnly ?? false;
+ const activeTabOnly = settings.subagentActiveTabOnly ?? true;
// Get parent from PERSISTENT map (THE source of truth)
const storedParent = this.subagentParentMap.get(agentId);
@@ -11559,7 +11571,7 @@ class ClaudemanApp {
// Check if panel is enabled in settings
const settings = this.loadAppSettingsFromStorage();
- const showProjectInsights = settings.showProjectInsights ?? true;
+ const showProjectInsights = settings.showProjectInsights ?? false;
if (!showProjectInsights) {
panel.classList.remove('visible');
this.projectInsightsPanelVisible = false;
diff --git a/src/web/public/index.html b/src/web/public/index.html
index 6a48006a..b65f6896 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -731,6 +731,13 @@
+
+ Show Cost ($)
+
+
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index 9b9b695c..495affc3 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -5118,22 +5118,24 @@ kbd {
}
.connection-line {
- stroke: #00ff41;
- stroke-width: 2.5;
+ stroke: #3b82f6;
+ stroke-width: 3;
stroke-dasharray: 5 3;
fill: none;
- opacity: 0.75;
- /* Dark outline for contrast, subtle glow */
- filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.7))
- drop-shadow(0 0 3px rgba(0, 255, 65, 0.5));
+ opacity: 0.9;
+ /* Dark outline for contrast, vibrant blue glow */
+ filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8))
+ drop-shadow(0 0 4px rgba(59, 130, 246, 0.8))
+ drop-shadow(0 0 8px rgba(59, 130, 246, 0.5));
transition: opacity 0.2s, stroke-width 0.2s, filter 0.2s;
}
.connection-line:hover {
- opacity: 0.9;
- stroke-width: 3;
- filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8))
- drop-shadow(0 0 5px rgba(0, 255, 65, 0.7));
+ opacity: 1;
+ stroke-width: 3.5;
+ filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9))
+ drop-shadow(0 0 6px rgba(59, 130, 246, 1))
+ drop-shadow(0 0 12px rgba(59, 130, 246, 0.7));
}
.connection-line.spawning-line {
@@ -5160,24 +5162,26 @@ kbd {
/* Plan subagent to regular subagent connection lines (Opus → Haiku) */
.connection-line.plan-to-subagent-line {
- stroke: #00ff41;
- stroke-width: 2.5;
- /* Dark outline for contrast, subtle glow */
- filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.7))
- drop-shadow(0 0 3px rgba(0, 255, 65, 0.5));
+ stroke: #3b82f6;
+ stroke-width: 3;
+ /* Dark outline for contrast, vibrant blue glow */
+ filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8))
+ drop-shadow(0 0 4px rgba(59, 130, 246, 0.8))
+ drop-shadow(0 0 8px rgba(59, 130, 246, 0.5));
stroke-dasharray: 5 3;
animation: plan-subagent-pulse 1.2s ease-in-out infinite;
}
.connection-line.plan-to-subagent-line:hover {
- stroke-width: 3;
- filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8))
- drop-shadow(0 0 5px rgba(0, 255, 65, 0.7));
+ stroke-width: 3.5;
+ filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9))
+ drop-shadow(0 0 6px rgba(59, 130, 246, 1))
+ drop-shadow(0 0 12px rgba(59, 130, 246, 0.7));
}
@keyframes plan-subagent-pulse {
- 0%, 100% { opacity: 0.65; }
- 50% { opacity: 0.85; }
+ 0%, 100% { opacity: 0.8; }
+ 50% { opacity: 1; }
}
/* ========== Project Insights Panel (Bash File Viewers) ========== */
diff --git a/src/web/server.ts b/src/web/server.ts
index 125b142e..2ccc9642 100644
--- a/src/web/server.ts
+++ b/src/web/server.ts
@@ -1121,8 +1121,12 @@ export class WebServer extends EventEmitter {
if (enabled !== undefined) {
if (enabled) {
session.ralphTracker.enable();
+ // Allow re-enabling on restart if user explicitly enabled
+ session.ralphTracker.enableAutoEnable();
} else {
session.ralphTracker.disable();
+ // Prevent re-enabling on restart when user explicitly disabled
+ session.ralphTracker.disableAutoEnable();
}
// Persist Ralph enabled state
this.screenManager.updateRalphEnabled(id, enabled);
@@ -1369,8 +1373,8 @@ export class WebServer extends EventEmitter {
}
try {
- // Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled)
- if (this.store.getConfig().ralphEnabled) {
+ // Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user)
+ if (this.store.getConfig().ralphEnabled && !session.ralphTracker.autoEnableDisabled) {
autoConfigureRalph(session, session.workingDir, () => {});
if (!session.ralphTracker.enabled) {
session.ralphTracker.enable();
@@ -1688,8 +1692,8 @@ export class WebServer extends EventEmitter {
}
try {
- // Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled)
- if (this.store.getConfig().ralphEnabled) {
+ // Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user)
+ if (this.store.getConfig().ralphEnabled && !session.ralphTracker.autoEnableDisabled) {
autoConfigureRalph(session, session.workingDir, () => {});
if (!session.ralphTracker.enabled) {
session.ralphTracker.enable();
@@ -2288,6 +2292,7 @@ export class WebServer extends EventEmitter {
autoConfigureRalph(session, casePath, () => {}); // no broadcast yet
if (!session.ralphTracker.enabled) {
session.ralphTracker.enable();
+ session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart
}
}
@@ -4561,6 +4566,13 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}
}
// Ralph / Todo tracker
+ if (savedState.ralphAutoEnableDisabled) {
+ session.ralphTracker.disableAutoEnable();
+ console.log(`[Server] Restored Ralph auto-enable disabled for session ${session.id}`);
+ } else if (savedState.ralphEnabled) {
+ // If Ralph was enabled and not explicitly disabled, allow re-enabling on restart
+ session.ralphTracker.enableAutoEnable();
+ }
if (savedState.ralphEnabled) {
session.ralphTracker.enable();
if (savedState.ralphCompletionPhrase) {
@@ -4594,8 +4606,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}
}
- // Fallback: restore Ralph state from state-inner.json if not already set
- if (!session.ralphTracker.enabled) {
+ // Fallback: restore Ralph state from state-inner.json if not already set and not explicitly disabled
+ if (!session.ralphTracker.enabled && !session.ralphTracker.autoEnableDisabled) {
const ralphState = this.store.getRalphState(screen.sessionId);
if (ralphState?.loop?.enabled) {
session.ralphTracker.restoreState(ralphState.loop, ralphState.todos);
diff --git a/test/ai-idle-checker.test.ts b/test/ai-idle-checker.test.ts
index 53f20389..e71c5631 100644
--- a/test/ai-idle-checker.test.ts
+++ b/test/ai-idle-checker.test.ts
@@ -295,6 +295,12 @@ describe('AiIdleChecker', () => {
});
it('should disable after maxConsecutiveErrors', async () => {
+ // With P1-005 exponential backoff, cooldowns increase:
+ // Error 1: 1000ms * 2^0 = 1000ms
+ // Error 2: 1000ms * 2^1 = 2000ms
+ // Error 3: disabled (no cooldown)
+ const cooldowns = [1100, 2100]; // Wait slightly longer than each cooldown
+
for (let i = 0; i < 3; i++) {
mockedReadFileSync.mockReturnValueOnce('')
.mockReturnValueOnce('garbage\n__AICHECK_DONE__');
@@ -305,7 +311,7 @@ describe('AiIdleChecker', () => {
// Clear cooldown for next check (except after the last one which disables)
if (i < 2) {
- await vi.advanceTimersByTimeAsync(1100);
+ await vi.advanceTimersByTimeAsync(cooldowns[i]);
}
}
@@ -464,13 +470,19 @@ describe('AiIdleChecker', () => {
const handler = vi.fn();
checker.on('disabled', handler);
+ // With exponential backoff (P1-005), cooldown increases:
+ // Error 1: 1000ms * 2^0 = 1000ms
+ // Error 2: 1000ms * 2^1 = 2000ms
+ // Error 3: disabled (no cooldown needed)
+ const cooldowns = [1100, 2100]; // Wait longer than exponential backoff
+
for (let i = 0; i < 3; i++) {
mockedReadFileSync.mockReturnValueOnce('')
.mockReturnValueOnce('garbage\n__AICHECK_DONE__');
const checkPromise = checker.check('output');
await vi.advanceTimersByTimeAsync(1000);
await checkPromise;
- if (i < 2) await vi.advanceTimersByTimeAsync(1100);
+ if (i < 2) await vi.advanceTimersByTimeAsync(cooldowns[i]);
}
expect(handler).toHaveBeenCalledWith(expect.stringContaining('consecutive errors'));
diff --git a/test/respawn-controller.test.ts b/test/respawn-controller.test.ts
index 5dfa62d0..abee16ca 100644
--- a/test/respawn-controller.test.ts
+++ b/test/respawn-controller.test.ts
@@ -15,6 +15,8 @@ class MockSession extends EventEmitter {
id = 'mock-session-id';
workingDir = '/tmp';
status = 'idle';
+ pid = 12345; // Mock PID for P1-006 health check
+ isWorking = false; // P0-006 Session.isWorking integration
writeBuffer: string[] = [];
write(data: string): void {