Compare commits

...

11 Commits

Author SHA1 Message Date
AndyMik90 a0c775f690 Merge branch 'auto-claude/028-fix-kanban-view-task-display-styling' 2025-12-17 22:19:09 +01:00
AndyMik90 9babdc23c1 fix to spec runner paths 2025-12-17 22:18:11 +01:00
AndyMik90 dc886dce44 auto-claude: subtask-1-1 - Restructure SortableFeatureCard badge layout
- Reorganize layout for better visual hierarchy: title first, then description, then metadata
- Make badges more compact with smaller text (text-[10px]) and reduced padding (px-1.5 py-0)
- Separate priority badge in header from complexity/impact badges in footer
- Replace competitor insight text with icon-only badge (with tooltip)
- Reduce card padding from p-4 to p-3
- Change title from truncate to line-clamp-2 for better readability
- Move complexity and impact badges to dedicated metadata row at bottom
- Remove " impact" text suffix for cleaner look
- Shorten "Go to Task" button text to "Task"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 22:04:47 +01:00
AndyMik90 32760348ae Fix to linux path issue 2025-12-17 21:42:53 +01:00
AndyMik90 395ba60098 Improvement/Terminal session persistence over app restart 2025-12-17 18:23:47 +01:00
AndyMik90 d85a1b4f63 V2.3.0 release for terminals 2025-12-17 17:28:02 +01:00
AndyMik90 43a593c1e1 Bug fix: dont let tasks slip by human review if the task was set as done. 2025-12-17 17:21:55 +01:00
AndyMik90 5078191d74 Added a release title for the github format - changelog functionality 2025-12-17 17:15:28 +01:00
AndyMik90 99850512e6 Implement PTY Daemon and Buffer Management
- Introduced a new PTY Daemon to handle terminal processes, ensuring session continuity and efficient resource management.
- Added session persistence for terminal states, allowing recovery across app restarts.
- Implemented buffer management to optimize terminal output handling, reducing React re-renders.
- Enhanced flow control and scroll management for better user experience during high-velocity terminal output.
- Refactored terminal store to utilize the new buffer manager, improving performance and memory management.
- Added WebGL context management for enhanced rendering capabilities in terminals.

This update significantly improves terminal performance and reliability, especially during intensive operations.
2025-12-17 17:05:37 +01:00
AndyMik90 f92fcfa075 Version 2.2.0 2025-12-17 16:23:00 +01:00
AndyMik90 f201f7e3a8 hotfix/spec-runner path location 2025-12-17 16:19:49 +01:00
35 changed files with 2722 additions and 148 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
uses: SethCohen/github-releases-to-discord@v1.19.0
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
color: "5865F2"
color: "5793266"
username: "Auto Claude Releases"
avatar_url: "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png"
footer_title: "Auto Claude Changelog"
+92
View File
@@ -1,3 +1,95 @@
## 2.3.1 - Linux Compatibility Fix
### 🐛 Bug Fixes
- Resolved path handling issues on Linux systems for improved cross-platform compatibility
---
## What's Changed
- fix: Fix to linux path issue by @AndyMik90 in 3276034
## 2.2.0 - 2025-12-17
### ✨ New Features
- Add usage monitoring with profile swap detection to prevent cascading resource issues
- Option to stash changes before merge operations for safer branch integration
- Add hideCloseButton prop to DialogContent component for improved UI flexibility
### 🛠️ Improvements
- Enhance AgentManager to manage task context cleanup and preserve swapCount on restarts
- Improve changelog feature with version tracking, markdown/preview, and persistent styling options
- Refactor merge conflict handling to use branch names instead of commit hashes for better clarity
- Streamline usage monitoring logic by removing unnecessary dynamic imports
- Better handling of lock files during merge conflicts
- Refactor code for improved readability and maintainability
- Refactor IdeationHeader and update handleDeleteSelected logic
### 🐛 Bug Fixes
- Fix worktree merge logic to correctly handle branch operations
- Fix spec_runner.py path resolution after move to runners/ directory
- Fix Discord release webhook failing on large changelogs
- Fix branch logic for merge AI operations
- Hotfix for spec-runner path location
---
## What's Changed
- fix: hotfix/spec-runner path location by @AndyMik90 in f201f7e
- refactor: Remove unnecessary dynamic imports of getUsageMonitor in terminal-handlers.ts to streamline usage monitoring logic by @AndyMik90 in 0da4bc4
- feat: Improve changelog feature, version tracking, markdown/preview, persistent styling options by @AndyMik90 in a0d142b
- refactor: Refactor code for improved readability and maintainability by @AndyMik90 in 473b045
- feat: Enhance AgentManager to manage task context cleanup and preserve swapCount on restarts. Update UsageMonitor to delay profile usage checks to prevent cascading swaps by @AndyMik90 in e5b9488
- feat: Usage-monitoring by @AndyMik90 in de33b2c
- feat: option to stash changes before merge by @AndyMik90 in 7e09739
- refactor: Refactor merge conflict check to use branch names instead of commit hashes by @AndyMik90 in e6d6cea
- fix: worktree merge logic by @AndyMik90 in dfb5cf9
- test: Sign off - all verification passed by @AndyMik90 in 34631c3
- feat: Pass hideCloseButton={showFileExplorer} to DialogContent by @AndyMik90 in 7c327ed
- feat: Add hideCloseButton prop to DialogContent component by @AndyMik90 in 5f9653a
- fix: branch logic for merge AI by @AndyMik90 in 2d2a813
- fix: spec_runner.py path resolution after move to runners/ directory by @AndyMik90 in ce9c2cd
- refactor: Better handling of lock files during merge conflicts by @AndyMik90 in 460c76d
- fix: Discord release webhook failing on large changelogs by @AndyMik90 in 4eb66f5
- chore: Update CHANGELOG with new features, improvements, bug fixes, and other changes by @AndyMik90 in 788b8d0
- refactor: Enhance merge conflict handling by excluding lock files by @AndyMik90 in 957746e
- refactor: Refactor IdeationHeader and update handleDeleteSelected logic by @AndyMik90 in 36338f3
## What's New
### ✨ New Features
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "auto-claude-ui",
"version": "2.0.1",
"version": "2.3.0",
"description": "Desktop UI for Auto Claude autonomous coding framework",
"main": "./out/main/index.js",
"author": "Auto Claude Team",
@@ -48,7 +48,9 @@
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-virtual": "^3.13.13",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-serialize": "^0.13.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"chokidar": "^5.0.0",
"class-variance-authority": "^0.7.1",
+24
View File
@@ -75,9 +75,15 @@ importers:
'@xterm/addon-fit':
specifier: ^0.10.0
version: 0.10.0(@xterm/xterm@5.5.0)
'@xterm/addon-serialize':
specifier: ^0.13.0
version: 0.13.0(@xterm/xterm@5.5.0)
'@xterm/addon-web-links':
specifier: ^0.11.0
version: 0.11.0(@xterm/xterm@5.5.0)
'@xterm/addon-webgl':
specifier: ^0.18.0
version: 0.18.0(@xterm/xterm@5.5.0)
'@xterm/xterm':
specifier: ^5.5.0
version: 5.5.0
@@ -1662,11 +1668,21 @@ packages:
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/addon-serialize@0.13.0':
resolution: {integrity: sha512-kGs8o6LWAmN1l2NpMp01/YkpxbmO4UrfWybeGu79Khw5K9+Krp7XhXbBTOTc3GJRRhd6EmILjpR8k5+odY39YQ==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/addon-web-links@0.11.0':
resolution: {integrity: sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/addon-webgl@0.18.0':
resolution: {integrity: sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/xterm@5.5.0':
resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
@@ -5927,10 +5943,18 @@ snapshots:
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/addon-serialize@0.13.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/addon-web-links@0.11.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/addon-webgl@0.18.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/xterm@5.5.0': {}
abbrev@1.1.1: {}
@@ -42,9 +42,12 @@ function setupTestDirs(): void {
// Create VERSION file (required by getAutoBuildSourcePath)
writeFileSync(path.join(AUTO_CLAUDE_SOURCE, 'VERSION'), '1.0.0');
// Create mock spec_runner.py
// Create runners subdirectory (where spec_runner.py lives after restructure)
mkdirSync(path.join(AUTO_CLAUDE_SOURCE, 'runners'), { recursive: true });
// Create mock spec_runner.py in runners/ subdirectory
writeFileSync(
path.join(AUTO_CLAUDE_SOURCE, 'spec_runner.py'),
path.join(AUTO_CLAUDE_SOURCE, 'runners', 'spec_runner.py'),
'# Mock spec runner\nprint("Starting spec creation")'
);
// Create mock run.py
@@ -102,7 +102,7 @@ export class AgentManager extends EventEmitter {
return;
}
const specRunnerPath = path.join(autoBuildSource, 'spec_runner.py');
const specRunnerPath = path.join(autoBuildSource, 'runners', 'spec_runner.py');
if (!existsSync(specRunnerPath)) {
this.emit('error', taskId, `Spec runner not found at: ${specRunnerPath}`);
+36 -1
View File
@@ -160,11 +160,32 @@ export function buildChangelogPrompt(
return parts.join('');
}).join('\n');
// Format-specific instructions for tasks mode
let formatSpecificInstructions = '';
if (request.format === 'github-release') {
formatSpecificInstructions = `
For GitHub Release format:
RELEASE TITLE (CRITICAL):
- First, analyze all completed tasks to identify the main theme or focus of this release
- Create a concise, descriptive title (2-5 words) that captures what this release is about
- Examples of good titles:
* "Improved Terminal Experience" (for terminal-related improvements)
* "Enhanced Security Features" (for security updates)
* "UI/UX Refinements" (for interface changes)
* "Agent Performance Boost" (for performance improvements)
- The version header MUST be: "## ${request.version} - [Your Thematic Title]"
- Focus on the USER BENEFIT or FUNCTIONAL AREA, not technical implementation details
- The title should be what the release is "about" in layman's terms
`;
}
return `${audienceInstruction}
Format:
${formatInstruction}
${emojiInstruction ? `\nEmoji Usage:\n${emojiInstruction}` : ''}
${formatSpecificInstructions}
Completed tasks:
${taskSummaries}
@@ -223,7 +244,21 @@ export function buildGitPrompt(
let formatSpecificInstructions = '';
if (request.format === 'github-release') {
formatSpecificInstructions = `
For GitHub Release format, create TWO parts after the version header:
For GitHub Release format, you MUST follow this structure:
RELEASE TITLE (CRITICAL):
- First, analyze all commits to identify the main theme or focus of this release
- Create a concise, descriptive title (2-5 words) that captures what this release is about
- Examples of good titles:
* "Improved Terminal Experience" (for terminal-related improvements)
* "Enhanced Security Features" (for security updates)
* "Performance Optimizations" (for speed improvements)
* "UI/UX Refinements" (for interface changes)
* "Agent System Overhaul" (for major architectural changes)
* "Build Pipeline Enhancements" (for CI/CD improvements)
- The version header MUST be: "## ${request.version} - [Your Thematic Title]"
- Focus on the USER BENEFIT or FUNCTIONAL AREA, not technical implementation details
- The title should be what the release is "about" in layman's terms
PART 1 - Categorized changes (summarized):
- Use category sections: New Features, Improvements, Bug Fixes, Documentation, Other Changes
@@ -98,7 +98,7 @@ export class VersionSuggester {
*/
private buildPrompt(commits: GitCommit[], currentVersion: string): string {
const commitSummary = commits
.map((c, i) => `${i + 1}. ${c.shortHash} - ${c.message}`)
.map((c, i) => `${i + 1}. ${c.hash} - ${c.subject}`)
.join('\n');
return `You are a semantic versioning expert analyzing git commits to suggest the appropriate version bump.
+2 -1
View File
@@ -51,7 +51,8 @@ function createWindow(): void {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
nodeIntegration: false,
backgroundThrottling: false // Prevent terminal lag when window loses focus
}
});
@@ -1,6 +1,6 @@
import { ipcMain, BrowserWindow } from 'electron';
import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../../shared/constants';
import type { IPCResult, Task, TaskStartOptions, TaskStatus } from '../../../shared/types';
import type { IPCResult, TaskStartOptions, TaskStatus } from '../../../shared/types';
import path from 'path';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { AgentManager } from '../../agent';
@@ -19,7 +19,7 @@ export function registerTaskExecutionHandlers(
*/
ipcMain.on(
IPC_CHANNELS.TASK_START,
(_, taskId: string, options?: TaskStartOptions) => {
(_, taskId: string, _options?: TaskStartOptions) => {
console.log('[TASK_START] Received request for taskId:', taskId);
const mainWindow = getMainWindow();
if (!mainWindow) {
@@ -76,9 +76,9 @@ export function registerTaskExecutionHandlers(
} else if (needsImplementation) {
// Spec exists but no subtasks - run run.py to create implementation plan and execute
// Read the spec.md to get the task description
let taskDescription = task.description || task.title;
const _taskDescription = task.description || task.title;
try {
taskDescription = readFileSync(specFilePath, 'utf-8');
readFileSync(specFilePath, 'utf-8');
} catch {
// Use default description
}
@@ -216,6 +216,16 @@ export function registerTaskExecutionHandlers(
taskId: string,
status: TaskStatus
): Promise<IPCResult> => {
// Validate status transition - 'done' can only be set through merge handler
// This prevents AI agents from bypassing the human review workflow
if (status === 'done') {
console.warn(`[TASK_UPDATE_STATUS] Blocked attempt to set status 'done' directly for task ${taskId}. Use merge workflow instead.`);
return {
success: false,
error: "Cannot set status to 'done' directly. Complete the human review and merge the worktree changes instead."
};
}
// Find task and project
const { task, project } = findTaskAndProject(taskId);
@@ -242,8 +252,8 @@ export function registerTaskExecutionHandlers(
// Store the exact UI status - project-store.ts will map it back
plan.status = status;
// Also store mapped version for Python compatibility
plan.planStatus = status === 'done' ? 'completed'
: status === 'in_progress' ? 'in_progress'
// Note: 'done' is blocked at the start of this handler - only set via merge workflow
plan.planStatus = status === 'in_progress' ? 'in_progress'
: status === 'ai_review' ? 'review'
: status === 'human_review' ? 'review'
: 'pending';
@@ -252,14 +262,14 @@ export function registerTaskExecutionHandlers(
writeFileSync(planPath, JSON.stringify(plan, null, 2));
} else {
// If no implementation plan exists yet, create a basic one
// Note: 'done' status is blocked at the start of this handler
const plan = {
feature: task.title,
description: task.description || '',
created_at: task.createdAt.toISOString(),
updated_at: new Date().toISOString(),
status: status, // Store exact UI status for persistence
planStatus: status === 'done' ? 'completed'
: status === 'in_progress' ? 'in_progress'
planStatus: status === 'in_progress' ? 'in_progress'
: status === 'ai_review' ? 'review'
: status === 'human_review' ? 'review'
: 'pending',
@@ -505,16 +515,29 @@ export function registerTaskExecutionHandlers(
const specDirForWatcher = path.join(project.path, specsBaseDir, task.specId);
fileWatcher.watch(taskId, specDirForWatcher);
// Note: Parallel execution is handled internally by the agent
agentManager.startTaskExecution(
taskId,
project.path,
task.specId,
{
parallel: false,
workers: 1
}
);
// Check if spec.md exists to determine whether to run spec creation or task execution
const specFilePath = path.join(specDirForWatcher, AUTO_BUILD_PATHS.SPEC_FILE);
const hasSpec = existsSync(specFilePath);
const needsSpecCreation = !hasSpec;
if (needsSpecCreation) {
// No spec file - need to run spec_runner.py to create the spec
const taskDescription = task.description || task.title;
console.log(`[Recovery] Starting spec creation for: ${task.specId}`);
agentManager.startSpecCreation(task.specId, project.path, taskDescription, specDirForWatcher, task.metadata);
} else {
// Spec exists - run task execution
console.log(`[Recovery] Starting task execution for: ${task.specId}`);
agentManager.startTaskExecution(
taskId,
project.path,
task.specId,
{
parallel: false,
workers: 1
}
);
}
autoRestarted = true;
console.log(`[Recovery] Auto-restarted task ${taskId}`);
@@ -0,0 +1,399 @@
/**
* PTY Daemon Client
*
* Communicates with the PTY daemon process via Unix socket/named pipe.
* Handles connection management, automatic reconnection, and message routing.
*/
import * as net from 'net';
import * as path from 'path';
import { spawn, ChildProcess } from 'child_process';
import { app } from 'electron';
const SOCKET_PATH =
process.platform === 'win32'
? `\\\\.\\pipe\\auto-claude-pty-${process.getuid?.() || 'default'}`
: `/tmp/auto-claude-pty-${process.getuid?.() || 'default'}.sock`;
type ResponseHandler = (response: any) => void;
interface PtyConfig {
shell: string;
shellArgs: string[];
cwd: string;
env: Record<string, string>;
rows: number;
cols: number;
}
interface PtyInfo {
id: string;
config: PtyConfig;
createdAt: number;
lastDataAt: number;
isDead: boolean;
bufferSize: number;
}
class PtyDaemonClient {
private socket: net.Socket | null = null;
private daemonProcess: ChildProcess | null = null;
private pendingRequests = new Map<string, ResponseHandler>();
private dataHandlers = new Map<string, (data: string) => void>();
private exitHandlers = new Map<string, (exitCode: number, signal?: number) => void>();
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private isConnecting = false;
private buffer = '';
private isShuttingDown = false;
/**
* Connect to daemon, spawning if necessary
*/
async connect(): Promise<void> {
if (this.isShuttingDown) {
throw new Error('Client is shutting down');
}
if (this.socket || this.isConnecting) return;
this.isConnecting = true;
try {
// Try to connect to existing daemon
await this.tryConnect();
console.log('[PtyDaemonClient] Connected to existing daemon');
} catch {
// Spawn daemon and connect
console.log('[PtyDaemonClient] Spawning new daemon...');
await this.spawnDaemon();
await this.tryConnect();
console.log('[PtyDaemonClient] Connected to new daemon');
} finally {
this.isConnecting = false;
this.reconnectAttempts = 0;
}
}
/**
* Try to connect to existing daemon
*/
private tryConnect(): Promise<void> {
return new Promise((resolve, reject) => {
const socket = net.connect(SOCKET_PATH);
const timeout = setTimeout(() => {
socket.destroy();
reject(new Error('Connection timeout'));
}, 3000);
socket.on('connect', () => {
clearTimeout(timeout);
this.socket = socket;
this.setupSocketHandlers();
resolve();
});
socket.on('error', (err) => {
clearTimeout(timeout);
reject(err);
});
});
}
/**
* Spawn a new daemon process
*/
private async spawnDaemon(): Promise<void> {
// In production, the daemon file is in the same directory
const daemonPath = path.join(__dirname, 'pty-daemon.js');
try {
// Spawn detached process that survives parent
this.daemonProcess = spawn(process.execPath, [daemonPath], {
detached: true,
stdio: 'ignore', // Don't pipe stdout/stderr
env: { ...process.env },
});
// Unref so parent can exit independently
this.daemonProcess.unref();
console.log(`[PtyDaemonClient] Spawned daemon process (PID: ${this.daemonProcess.pid})`);
// Wait for daemon to start listening
await new Promise((resolve) => setTimeout(resolve, 1000));
} catch (error) {
console.error('[PtyDaemonClient] Failed to spawn daemon:', error);
throw error;
}
}
/**
* Setup socket event handlers
*/
private setupSocketHandlers(): void {
if (!this.socket) return;
this.socket.on('data', (chunk) => {
this.buffer += chunk.toString();
// Handle newline-delimited JSON
const lines = this.buffer.split('\n');
this.buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const response = JSON.parse(line);
this.handleResponse(response);
} catch (e) {
console.error('[PtyDaemonClient] Invalid response:', e);
}
}
});
this.socket.on('close', () => {
console.log('[PtyDaemonClient] Disconnected from daemon');
this.socket = null;
if (!this.isShuttingDown) {
this.attemptReconnect();
}
});
this.socket.on('error', (err) => {
console.error('[PtyDaemonClient] Socket error:', err);
});
}
/**
* Handle response from daemon
*/
private handleResponse(response: any): void {
// Handle request-response pattern
if (response.requestId) {
const handler = this.pendingRequests.get(response.requestId);
if (handler) {
this.pendingRequests.delete(response.requestId);
handler(response);
}
return;
}
// Handle streaming data
if (response.type === 'data' && response.id) {
const handler = this.dataHandlers.get(response.id);
if (handler) {
handler(response.data);
}
return;
}
// Handle exit events
if (response.type === 'exit' && response.id) {
const handler = this.exitHandlers.get(response.id);
if (handler) {
handler(response.data.exitCode, response.data.signal);
}
return;
}
}
/**
* Attempt to reconnect with exponential backoff
*/
private attemptReconnect(): void {
if (this.isShuttingDown) return;
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('[PtyDaemonClient] Max reconnect attempts reached');
return;
}
this.reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 10000);
console.log(
`[PtyDaemonClient] Reconnect attempt ${this.reconnectAttempts} in ${delay}ms...`
);
setTimeout(() => {
this.connect().catch((error) => {
console.error('[PtyDaemonClient] Reconnect failed:', error);
});
}, delay);
}
/**
* Send a request and wait for response
*/
private async request<T>(msg: any): Promise<T> {
await this.connect();
if (!this.socket) {
throw new Error('Not connected to daemon');
}
const requestId = `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRequests.delete(requestId);
reject(new Error('Request timeout'));
}, 10000);
this.pendingRequests.set(requestId, (response) => {
clearTimeout(timeout);
if (response.type === 'error') {
reject(new Error(response.error || 'Unknown error'));
} else {
resolve(response as T);
}
});
this.socket!.write(JSON.stringify({ ...msg, requestId }) + '\n');
});
}
/**
* Send a message without expecting a response
*/
private send(msg: any): void {
if (!this.socket) {
console.warn('[PtyDaemonClient] Cannot send - not connected');
return;
}
this.socket.write(JSON.stringify(msg) + '\n');
}
// ===== Public API =====
/**
* Create a new PTY in the daemon
*/
async createPty(config: PtyConfig): Promise<string> {
const response = await this.request<{ type: 'created'; id: string }>({
type: 'create',
data: config,
});
return response.id;
}
/**
* Write data to a PTY
*/
write(id: string, data: string): void {
this.send({ type: 'write', id, data });
}
/**
* Resize a PTY
*/
resize(id: string, cols: number, rows: number): void {
this.send({ type: 'resize', id, data: { cols, rows } });
}
/**
* Kill a PTY
*/
kill(id: string): void {
this.send({ type: 'kill', id });
this.dataHandlers.delete(id);
this.exitHandlers.delete(id);
}
/**
* List all PTYs in the daemon
*/
async list(): Promise<PtyInfo[]> {
const response = await this.request<{ type: 'list'; data: PtyInfo[] }>({
type: 'list',
});
return response.data;
}
/**
* Subscribe to PTY output and exit events
*/
subscribe(
id: string,
onData: (data: string) => void,
onExit: (code: number, signal?: number) => void
): void {
this.dataHandlers.set(id, onData);
this.exitHandlers.set(id, onExit);
this.send({ type: 'subscribe', id });
}
/**
* Unsubscribe from PTY events
*/
unsubscribe(id: string): void {
this.dataHandlers.delete(id);
this.exitHandlers.delete(id);
this.send({ type: 'unsubscribe', id });
}
/**
* Get buffered output from a PTY
*/
async getBuffer(id: string): Promise<{ buffer: string; isDead: boolean }> {
const response = await this.request<{
type: 'buffer';
data: { buffer: string; isDead: boolean };
}>({
type: 'get-buffer',
id,
});
return response.data;
}
/**
* Check if daemon is alive
*/
async ping(): Promise<boolean> {
try {
await this.request<{ type: 'pong' }>({ type: 'ping' });
return true;
} catch {
return false;
}
}
/**
* Check if connected
*/
isConnected(): boolean {
return this.socket !== null;
}
/**
* Disconnect from daemon (does not kill daemon)
*/
disconnect(): void {
if (this.socket) {
this.isShuttingDown = true;
this.socket.end();
this.socket = null;
}
}
/**
* Cleanup on app shutdown
*/
shutdown(): void {
this.isShuttingDown = true;
this.disconnect();
this.pendingRequests.clear();
this.dataHandlers.clear();
this.exitHandlers.clear();
}
}
// Singleton instance
export const ptyDaemonClient = new PtyDaemonClient();
// Cleanup on app quit
app.on('before-quit', () => {
ptyDaemonClient.shutdown();
});
@@ -0,0 +1,497 @@
#!/usr/bin/env node
/**
* PTY Daemon Process
*
* Runs as a separate detached process that owns all PTY instances.
* Survives main Electron process restarts, providing session continuity.
*
* Communication: Unix socket (Linux/macOS) or Named Pipe (Windows)
*/
import * as net from 'net';
import * as fs from 'fs';
import * as pty from 'node-pty';
const SOCKET_PATH =
process.platform === 'win32'
? `\\\\.\\pipe\\auto-claude-pty-${process.getuid?.() || 'default'}`
: `/tmp/auto-claude-pty-${process.getuid?.() || 'default'}.sock`;
// Maximum buffer size per PTY (100KB)
const MAX_BUFFER_SIZE = 100_000;
// Ring buffer to prevent memory growth
const RING_BUFFER_MAX_CHUNKS = 1000;
interface ManagedPty {
id: string;
process: pty.IPty;
config: PtyConfig;
buffer: string[];
bufferSize: number;
clients: Set<net.Socket>;
createdAt: number;
lastDataAt: number;
isDead: boolean;
}
interface PtyConfig {
shell: string;
shellArgs: string[];
cwd: string;
env: Record<string, string>;
rows: number;
cols: number;
}
interface DaemonMessage {
type:
| 'create'
| 'write'
| 'resize'
| 'kill'
| 'list'
| 'subscribe'
| 'unsubscribe'
| 'get-buffer'
| 'ping';
id?: string;
data?: any;
requestId?: string;
}
interface DaemonResponse {
type: 'created' | 'list' | 'buffer' | 'data' | 'exit' | 'error' | 'pong';
id?: string;
data?: any;
requestId?: string;
error?: string;
}
class PtyDaemon {
private ptys = new Map<string, ManagedPty>();
private server: net.Server | null = null;
constructor() {
console.error('[PTY Daemon] Starting...');
this.cleanup();
this.startServer();
this.setupSignalHandlers();
}
/**
* Remove stale socket/pipe
*/
private cleanup(): void {
if (process.platform !== 'win32' && fs.existsSync(SOCKET_PATH)) {
try {
fs.unlinkSync(SOCKET_PATH);
console.error('[PTY Daemon] Cleaned up stale socket');
} catch (error) {
console.error('[PTY Daemon] Failed to clean up socket:', error);
}
}
}
/**
* Start the IPC server
*/
private startServer(): void {
this.server = net.createServer((socket) => {
console.error('[PTY Daemon] Client connected');
this.handleConnection(socket);
});
this.server.on('error', (err) => {
console.error('[PTY Daemon] Server error:', err);
if ((err as any).code === 'EADDRINUSE') {
console.error('[PTY Daemon] Address in use - another daemon may be running');
process.exit(1);
}
});
this.server.listen(SOCKET_PATH, () => {
console.error(`[PTY Daemon] Listening on ${SOCKET_PATH}`);
// Set permissions on Unix
if (process.platform !== 'win32') {
try {
fs.chmodSync(SOCKET_PATH, 0o600);
} catch (error) {
console.error('[PTY Daemon] Failed to set socket permissions:', error);
}
}
});
}
/**
* Handle a client connection
*/
private handleConnection(socket: net.Socket): void {
let buffer = '';
socket.on('data', (chunk) => {
buffer += chunk.toString();
// Handle newline-delimited JSON messages
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const msg: DaemonMessage = JSON.parse(line);
this.handleMessage(socket, msg);
} catch (e) {
console.error('[PTY Daemon] Invalid message:', e);
this.sendError(socket, 'Invalid JSON message');
}
}
});
socket.on('close', () => {
console.error('[PTY Daemon] Client disconnected');
// Unsubscribe from all PTYs
this.ptys.forEach((pty) => {
pty.clients.delete(socket);
});
});
socket.on('error', (err) => {
console.error('[PTY Daemon] Socket error:', err);
});
}
/**
* Handle incoming message from client
*/
private handleMessage(socket: net.Socket, msg: DaemonMessage): void {
try {
switch (msg.type) {
case 'ping':
this.send(socket, { type: 'pong', requestId: msg.requestId });
break;
case 'create': {
const id = this.createPty(msg.data);
this.send(socket, { type: 'created', id, requestId: msg.requestId });
break;
}
case 'write':
if (!msg.id) throw new Error('Missing PTY id');
this.writeToPty(msg.id, msg.data);
break;
case 'resize':
if (!msg.id) throw new Error('Missing PTY id');
this.resizePty(msg.id, msg.data.cols, msg.data.rows);
break;
case 'kill':
if (!msg.id) throw new Error('Missing PTY id');
this.killPty(msg.id);
break;
case 'list': {
const list = this.listPtys();
this.send(socket, { type: 'list', data: list, requestId: msg.requestId });
break;
}
case 'subscribe':
if (!msg.id) throw new Error('Missing PTY id');
this.subscribeToPty(socket, msg.id);
break;
case 'unsubscribe':
if (!msg.id) throw new Error('Missing PTY id');
this.unsubscribeFromPty(socket, msg.id);
break;
case 'get-buffer': {
if (!msg.id) throw new Error('Missing PTY id');
const bufferData = this.getBuffer(msg.id);
this.send(socket, {
type: 'buffer',
id: msg.id,
data: bufferData,
requestId: msg.requestId,
});
break;
}
default:
throw new Error(`Unknown message type: ${(msg as any).type}`);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error('[PTY Daemon] Error handling message:', errorMsg);
this.sendError(socket, errorMsg, msg.requestId);
}
}
/**
* Create a new PTY
*/
private createPty(config: PtyConfig): string {
const id = `pty-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
try {
const ptyProcess = pty.spawn(config.shell, config.shellArgs, {
name: 'xterm-256color',
cols: config.cols,
rows: config.rows,
cwd: config.cwd,
env: config.env,
});
const managed: ManagedPty = {
id,
process: ptyProcess,
config,
buffer: [],
bufferSize: 0,
clients: new Set(),
createdAt: Date.now(),
lastDataAt: Date.now(),
isDead: false,
};
// Capture all output
ptyProcess.onData((data) => {
managed.lastDataAt = Date.now();
// Add to ring buffer
managed.buffer.push(data);
managed.bufferSize += data.length;
// Enforce buffer size limit
while (managed.bufferSize > MAX_BUFFER_SIZE && managed.buffer.length > 1) {
const removed = managed.buffer.shift();
if (removed) {
managed.bufferSize -= removed.length;
}
}
// Also enforce chunk count limit (ring buffer behavior)
while (managed.buffer.length > RING_BUFFER_MAX_CHUNKS) {
const removed = managed.buffer.shift();
if (removed) {
managed.bufferSize -= removed.length;
}
}
// Broadcast to all subscribers
managed.clients.forEach((client) => {
this.send(client, { type: 'data', id, data });
});
});
ptyProcess.onExit(({ exitCode, signal }) => {
console.error(`[PTY Daemon] PTY ${id} exited: code=${exitCode}, signal=${signal}`);
managed.isDead = true;
// Notify all subscribers
managed.clients.forEach((client) => {
this.send(client, { type: 'exit', id, data: { exitCode, signal } });
});
// Keep in map for buffer retrieval, will be cleaned up on explicit kill
});
this.ptys.set(id, managed);
console.error(`[PTY Daemon] Created PTY ${id} (${config.shell})`);
return id;
} catch (error) {
console.error('[PTY Daemon] Failed to create PTY:', error);
throw error;
}
}
/**
* Write data to a PTY
*/
private writeToPty(id: string, data: string): void {
const managed = this.ptys.get(id);
if (!managed) {
throw new Error(`PTY ${id} not found`);
}
if (managed.isDead) {
throw new Error(`PTY ${id} is dead`);
}
managed.process.write(data);
}
/**
* Resize a PTY
*/
private resizePty(id: string, cols: number, rows: number): void {
const managed = this.ptys.get(id);
if (!managed) {
throw new Error(`PTY ${id} not found`);
}
if (managed.isDead) {
console.warn(`[PTY Daemon] Cannot resize dead PTY ${id}`);
return;
}
managed.process.resize(cols, rows);
managed.config.cols = cols;
managed.config.rows = rows;
}
/**
* Kill a PTY and remove it
*/
private killPty(id: string): void {
const managed = this.ptys.get(id);
if (!managed) {
console.warn(`[PTY Daemon] PTY ${id} not found for kill`);
return;
}
if (!managed.isDead) {
try {
managed.process.kill();
} catch (error) {
console.error(`[PTY Daemon] Error killing PTY ${id}:`, error);
}
}
this.ptys.delete(id);
console.error(`[PTY Daemon] Removed PTY ${id}`);
}
/**
* List all PTYs
*/
private listPtys(): Array<{
id: string;
config: PtyConfig;
createdAt: number;
lastDataAt: number;
isDead: boolean;
bufferSize: number;
}> {
return Array.from(this.ptys.values()).map((m) => ({
id: m.id,
config: m.config,
createdAt: m.createdAt,
lastDataAt: m.lastDataAt,
isDead: m.isDead,
bufferSize: m.bufferSize,
}));
}
/**
* Subscribe a client to PTY output
*/
private subscribeToPty(socket: net.Socket, id: string): void {
const managed = this.ptys.get(id);
if (!managed) {
throw new Error(`PTY ${id} not found`);
}
managed.clients.add(socket);
console.error(`[PTY Daemon] Client subscribed to PTY ${id}`);
}
/**
* Unsubscribe a client from PTY output
*/
private unsubscribeFromPty(socket: net.Socket, id: string): void {
const managed = this.ptys.get(id);
if (managed) {
managed.clients.delete(socket);
console.error(`[PTY Daemon] Client unsubscribed from PTY ${id}`);
}
}
/**
* Get the buffered output for a PTY
*/
private getBuffer(id: string): { buffer: string; isDead: boolean } {
const managed = this.ptys.get(id);
if (!managed) {
throw new Error(`PTY ${id} not found`);
}
return {
buffer: managed.buffer.join(''),
isDead: managed.isDead,
};
}
/**
* Send a response to a client
*/
private send(socket: net.Socket, response: DaemonResponse): void {
try {
socket.write(JSON.stringify(response) + '\n');
} catch {
// Socket may be closed, ignore
console.debug('[PTY Daemon] Failed to send response (socket closed?)');
}
}
/**
* Send an error response
*/
private sendError(socket: net.Socket, error: string, requestId?: string): void {
this.send(socket, { type: 'error', error, requestId });
}
/**
* Setup signal handlers for graceful shutdown
*/
private setupSignalHandlers(): void {
const shutdown = (signal: string) => {
console.error(`[PTY Daemon] Received ${signal}, shutting down...`);
// Kill all PTYs
this.ptys.forEach((managed) => {
if (!managed.isDead) {
try {
managed.process.kill();
} catch (error) {
console.error(`[PTY Daemon] Error killing PTY ${managed.id}:`, error);
}
}
});
// Close server
this.server?.close();
// Remove socket
this.cleanup();
console.error('[PTY Daemon] Shutdown complete');
process.exit(0);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// Handle uncaught errors
process.on('uncaughtException', (error) => {
console.error('[PTY Daemon] Uncaught exception:', error);
// Don't exit - daemon should be resilient
});
process.on('unhandledRejection', (reason) => {
console.error('[PTY Daemon] Unhandled rejection:', reason);
// Don't exit - daemon should be resilient
});
}
}
// Start daemon if this file is run directly
if (require.main === module) {
try {
new PtyDaemon();
console.error('[PTY Daemon] Running - PID:', process.pid);
} catch (error) {
console.error('[PTY Daemon] Fatal error:', error);
process.exit(1);
}
}
export { PtyDaemon };
@@ -9,6 +9,7 @@ import * as os from 'os';
import type { TerminalProcess, WindowGetter, SessionCaptureResult } from './types';
import { getTerminalSessionStore, type TerminalSession } from '../terminal-session-store';
import { IPC_CHANNELS } from '../../shared/constants';
import { debugLog, debugError } from '../../shared/utils/debug-logger';
/**
* Get the Claude project slug from a project path.
@@ -27,7 +28,7 @@ export function findMostRecentClaudeSession(projectPath: string): string | null
try {
if (!fs.existsSync(claudeProjectDir)) {
console.log('[SessionHandler] Claude project directory not found:', claudeProjectDir);
debugLog('[SessionHandler] Claude project directory not found:', claudeProjectDir);
return null;
}
@@ -41,15 +42,15 @@ export function findMostRecentClaudeSession(projectPath: string): string | null
.sort((a, b) => b.mtime - a.mtime);
if (files.length === 0) {
console.log('[SessionHandler] No Claude session files found in:', claudeProjectDir);
debugLog('[SessionHandler] No Claude session files found in:', claudeProjectDir);
return null;
}
const sessionId = files[0].name.replace('.jsonl', '');
console.log('[SessionHandler] Found most recent Claude session:', sessionId);
debugLog('[SessionHandler] Found most recent Claude session:', sessionId);
return sessionId;
} catch (error) {
console.error('[SessionHandler] Error finding Claude session:', error);
debugError('[SessionHandler] Error finding Claude session:', error);
return null;
}
}
@@ -82,7 +83,7 @@ export function findClaudeSessionAfter(projectPath: string, afterTimestamp: numb
return files[0].name.replace('.jsonl', '');
} catch (error) {
console.error('[SessionHandler] Error finding Claude session:', error);
debugError('[SessionHandler] Error finding Claude session:', error);
return null;
}
}
@@ -210,7 +211,7 @@ export function captureClaudeSessionId(
if (sessionId) {
terminal.claudeSessionId = sessionId;
console.log('[SessionHandler] Captured Claude session ID from directory:', sessionId);
debugLog('[SessionHandler] Captured Claude session ID from directory:', sessionId);
if (terminal.projectPath) {
updateClaudeSessionId(terminal.projectPath, terminalId, sessionId);
@@ -223,7 +224,7 @@ export function captureClaudeSessionId(
} else if (attempts < maxAttempts) {
setTimeout(checkForSession, 1000);
} else {
console.log('[SessionHandler] Could not capture Claude session ID after', maxAttempts, 'attempts');
debugLog('[SessionHandler] Could not capture Claude session ID after', maxAttempts, 'attempts');
}
};
@@ -0,0 +1,325 @@
/**
* Terminal Session Persistence
*
* Handles saving and loading terminal session state to disk.
* This is the fallback recovery layer when the PTY daemon is not available.
*/
import { app } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import type {
TerminalSessionState,
TerminalSessionsFile,
TerminalRecoveryInfo,
} from '../../shared/types/terminal-session';
const SESSIONS_FILE = path.join(app.getPath('userData'), 'terminal-sessions.json');
const BUFFERS_DIR = path.join(app.getPath('userData'), 'terminal-buffers');
// Session age limit: 7 days
const MAX_SESSION_AGE_MS = 7 * 24 * 60 * 60 * 1000;
class SessionPersistence {
private sessions: Map<string, TerminalSessionState> = new Map();
private saveTimeout: NodeJS.Timeout | null = null;
private isInitialized = false;
constructor() {
this.ensureDirectories();
}
/**
* Ensure required directories exist
*/
private ensureDirectories(): void {
if (!fs.existsSync(BUFFERS_DIR)) {
fs.mkdirSync(BUFFERS_DIR, { recursive: true });
}
}
/**
* Initialize and load sessions on app start
*/
initialize(): TerminalRecoveryInfo {
if (this.isInitialized) {
return this.getRecoveryInfo();
}
const sessions = this.loadSessions();
this.isInitialized = true;
console.log(`[SessionPersistence] Initialized with ${sessions.length} sessions`);
return this.getRecoveryInfo();
}
/**
* Load sessions from disk
*/
loadSessions(): TerminalSessionState[] {
if (!fs.existsSync(SESSIONS_FILE)) {
return [];
}
try {
const data: TerminalSessionsFile = JSON.parse(
fs.readFileSync(SESSIONS_FILE, 'utf8')
);
// Validate version
if (data.version !== 2) {
console.warn('[SessionPersistence] Incompatible version, starting fresh');
return [];
}
// Filter out stale sessions (older than 7 days)
const now = Date.now();
const validSessions = data.sessions.filter(
(s) => now - s.lastActiveAt < MAX_SESSION_AGE_MS
);
// Clean up buffers for stale sessions
const staleSessions = data.sessions.filter(
(s) => now - s.lastActiveAt >= MAX_SESSION_AGE_MS
);
staleSessions.forEach((s) => {
if (s.bufferFile) {
this.deleteBufferFile(s.bufferFile);
}
});
validSessions.forEach((s) => this.sessions.set(s.id, s));
console.log(
`[SessionPersistence] Loaded ${validSessions.length} valid sessions, cleaned ${staleSessions.length} stale sessions`
);
return validSessions;
} catch (error) {
console.error('[SessionPersistence] Failed to load sessions:', error);
return [];
}
}
/**
* Get recovery information for UI
*/
getRecoveryInfo(): TerminalRecoveryInfo {
const sessions = Array.from(this.sessions.values());
return {
totalSessions: sessions.length,
recoverableSessions: sessions.filter((s) => s.bufferFile || s.daemonPtyId).length,
recoveryMethod: sessions.some((s) => s.daemonPtyId) ? 'daemon' : 'state',
sessions: sessions.map((s) => ({
id: s.id,
title: s.title,
isClaudeMode: s.isClaudeMode,
lastActiveAt: s.lastActiveAt,
hasBuffer: !!s.bufferFile,
hasDaemonPty: !!s.daemonPtyId,
})),
};
}
/**
* Save a session (debounced)
*/
saveSession(session: TerminalSessionState): void {
session.lastActiveAt = Date.now();
this.sessions.set(session.id, session);
this.scheduleSave();
}
/**
* Update session metadata without triggering full save
*/
updateSessionMetadata(
id: string,
updates: Partial<Pick<TerminalSessionState, 'title' | 'isClaudeMode' | 'claudeSessionId' | 'daemonPtyId'>>
): void {
const session = this.sessions.get(id);
if (!session) return;
Object.assign(session, updates);
session.lastActiveAt = Date.now();
this.scheduleSave();
}
/**
* Get a session by ID
*/
getSession(id: string): TerminalSessionState | undefined {
return this.sessions.get(id);
}
/**
* Get all sessions
*/
getAllSessions(): TerminalSessionState[] {
return Array.from(this.sessions.values());
}
/**
* Remove a session
*/
removeSession(id: string): void {
const session = this.sessions.get(id);
if (session?.bufferFile) {
this.deleteBufferFile(session.bufferFile);
}
this.sessions.delete(id);
this.scheduleSave();
}
/**
* Save buffer content to disk
*/
saveBuffer(sessionId: string, serializedBuffer: string): void {
const session = this.sessions.get(sessionId);
if (!session) {
console.warn(`[SessionPersistence] Cannot save buffer - session ${sessionId} not found`);
return;
}
const bufferFile = `buffer-${sessionId}.txt`;
const bufferPath = path.join(BUFFERS_DIR, bufferFile);
try {
fs.writeFileSync(bufferPath, serializedBuffer, 'utf8');
session.bufferFile = bufferFile;
this.saveSession(session);
console.debug(`[SessionPersistence] Saved buffer for session ${sessionId} (${serializedBuffer.length} bytes)`);
} catch (error) {
console.error(`[SessionPersistence] Failed to save buffer for ${sessionId}:`, error);
}
}
/**
* Load buffer content from disk
*/
loadBuffer(sessionId: string): string | null {
const session = this.sessions.get(sessionId);
if (!session?.bufferFile) return null;
const bufferPath = path.join(BUFFERS_DIR, session.bufferFile);
if (!fs.existsSync(bufferPath)) {
console.warn(`[SessionPersistence] Buffer file missing: ${session.bufferFile}`);
return null;
}
try {
return fs.readFileSync(bufferPath, 'utf8');
} catch (error) {
console.error(`[SessionPersistence] Failed to load buffer for ${sessionId}:`, error);
return null;
}
}
/**
* Delete a buffer file
*/
private deleteBufferFile(bufferFile: string): void {
const bufferPath = path.join(BUFFERS_DIR, bufferFile);
if (fs.existsSync(bufferPath)) {
try {
fs.unlinkSync(bufferPath);
console.debug(`[SessionPersistence] Deleted buffer file: ${bufferFile}`);
} catch (error) {
console.error(`[SessionPersistence] Failed to delete buffer file ${bufferFile}:`, error);
}
}
}
/**
* Debounced save to prevent excessive disk I/O
*/
private scheduleSave(): void {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
this.saveTimeout = setTimeout(() => {
this.saveToDisk();
}, 1000); // 1 second debounce
}
/**
* Immediate save (call before app quit)
*/
saveNow(): void {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
this.saveTimeout = null;
}
this.saveToDisk();
}
/**
* Perform the actual disk write
*/
private saveToDisk(): void {
const data: TerminalSessionsFile = {
version: 2,
savedAt: Date.now(),
sessions: Array.from(this.sessions.values()),
};
try {
fs.writeFileSync(SESSIONS_FILE, JSON.stringify(data, null, 2), 'utf8');
console.debug(`[SessionPersistence] Saved ${data.sessions.length} sessions to disk`);
} catch (error) {
console.error('[SessionPersistence] Failed to save sessions:', error);
}
}
/**
* Clean up old buffer files not referenced by any session
*/
cleanupOrphanedBuffers(): void {
if (!fs.existsSync(BUFFERS_DIR)) return;
try {
const bufferFiles = fs.readdirSync(BUFFERS_DIR);
const referencedBuffers = new Set(
Array.from(this.sessions.values())
.map((s) => s.bufferFile)
.filter((f): f is string => !!f)
);
let cleanedCount = 0;
for (const file of bufferFiles) {
if (!referencedBuffers.has(file)) {
const filePath = path.join(BUFFERS_DIR, file);
fs.unlinkSync(filePath);
cleanedCount++;
}
}
if (cleanedCount > 0) {
console.log(`[SessionPersistence] Cleaned up ${cleanedCount} orphaned buffer files`);
}
} catch (error) {
console.error('[SessionPersistence] Failed to cleanup orphaned buffers:', error);
}
}
}
// Singleton instance
export const sessionPersistence = new SessionPersistence();
// Hook into app lifecycle
app.on('before-quit', () => {
console.log('[SessionPersistence] App quitting, saving sessions...');
sessionPersistence.saveNow();
});
app.on('will-quit', () => {
sessionPersistence.saveNow();
});
// Cleanup orphaned buffers on startup (after initial load)
app.whenReady().then(() => {
setTimeout(() => {
sessionPersistence.cleanupOrphanedBuffers();
}, 5000); // Wait 5 seconds after app ready
});
@@ -15,6 +15,7 @@ import type {
WindowGetter,
TerminalOperationResult
} from './types';
import { debugLog, debugError } from '../../shared/utils/debug-logger';
/**
* Options for terminal restoration
@@ -40,10 +41,10 @@ export async function createTerminal(
): Promise<TerminalOperationResult> {
const { id, cwd, cols = 80, rows = 24, projectPath } = options;
console.log('[TerminalLifecycle] Creating terminal:', { id, cwd, cols, rows, projectPath });
debugLog('[TerminalLifecycle] Creating terminal:', { id, cwd, cols, rows, projectPath });
if (terminals.has(id)) {
console.log('[TerminalLifecycle] Terminal already exists, returning success:', id);
debugLog('[TerminalLifecycle] Terminal already exists, returning success:', id);
return { success: true };
}
@@ -51,7 +52,7 @@ export async function createTerminal(
const profileEnv = PtyManager.getActiveProfileEnv();
if (profileEnv.CLAUDE_CODE_OAUTH_TOKEN) {
console.log('[TerminalLifecycle] Injecting OAuth token from active profile');
debugLog('[TerminalLifecycle] Injecting OAuth token from active profile');
}
const ptyProcess = PtyManager.spawnPtyProcess(
@@ -61,7 +62,7 @@ export async function createTerminal(
profileEnv
);
console.log('[TerminalLifecycle] PTY process spawned, pid:', ptyProcess.pid);
debugLog('[TerminalLifecycle] PTY process spawned, pid:', ptyProcess.pid);
const terminalCwd = cwd || os.homedir();
const terminal: TerminalProcess = {
@@ -88,10 +89,10 @@ export async function createTerminal(
SessionHandler.persistSession(terminal);
}
console.log('[TerminalLifecycle] Terminal created successfully:', id);
debugLog('[TerminalLifecycle] Terminal created successfully:', id);
return { success: true };
} catch (error) {
console.error('[TerminalLifecycle] Error creating terminal:', error);
debugError('[TerminalLifecycle] Error creating terminal:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to create terminal',
@@ -111,7 +112,7 @@ export async function restoreTerminal(
cols = 80,
rows = 24
): Promise<TerminalOperationResult> {
console.log('[TerminalLifecycle] Restoring terminal session:', session.id, 'Claude mode:', session.isClaudeMode);
debugLog('[TerminalLifecycle] Restoring terminal session:', session.id, 'Claude mode:', session.isClaudeMode);
const result = await createTerminal(
{
@@ -137,34 +138,17 @@ export async function restoreTerminal(
terminal.title = session.title;
if (session.isClaudeMode && options.resumeClaudeSession) {
await new Promise(resolve => setTimeout(resolve, 1000));
// Restore Claude mode state without sending resume commands
// The PTY daemon keeps processes alive, so we just need to reconnect to the existing session
if (session.isClaudeMode) {
terminal.isClaudeMode = true;
terminal.claudeSessionId = session.claudeSessionId;
const projectDir = session.cwd || session.projectPath;
const startTime = Date.now();
const clearCmd = process.platform === 'win32' ? 'cls' : 'clear';
let resumeCommand: string;
if (session.claudeSessionId) {
resumeCommand = `${clearCmd} && cd "${projectDir}" && claude --resume "${session.claudeSessionId}"`;
console.log('[TerminalLifecycle] Resuming Claude with session ID:', session.claudeSessionId, 'in', projectDir);
} else {
resumeCommand = `${clearCmd} && cd "${projectDir}" && claude --resume`;
console.log('[TerminalLifecycle] Opening Claude session picker in', projectDir);
}
terminal.pty.write(`${resumeCommand}\r`);
debugLog('[TerminalLifecycle] Restored Claude mode state for session:', session.id, 'sessionId:', session.claudeSessionId);
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, session.id, 'Claude');
}
if (!session.claudeSessionId && projectDir) {
options.captureSessionId(session.id, projectDir, startTime);
win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, session.id, session.title);
}
}
@@ -238,12 +222,14 @@ export async function destroyAllTerminals(
/**
* Handle terminal exit event
* Note: We don't remove sessions here because terminal exit might be due to app shutdown.
* Sessions are only removed when explicitly destroyed by user action via destroyTerminal().
*/
function handleTerminalExit(
terminal: TerminalProcess,
terminals: Map<string, TerminalProcess>
): void {
SessionHandler.removePersistedSession(terminal);
// Don't remove session - let it persist for restoration
}
/**
@@ -64,39 +64,27 @@ export function SortableFeatureCard({
{...listeners}
>
<Card
className="p-4 hover:bg-muted/50 cursor-pointer transition-colors"
className="p-3 hover:bg-muted/50 cursor-pointer transition-colors"
onClick={onClick}
>
<div className="flex items-start justify-between">
{/* Header - Title with priority badge and action button */}
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<div className="flex items-center gap-1.5 mb-1">
<Badge
variant="outline"
className={ROADMAP_PRIORITY_COLORS[feature.priority]}
className={cn('text-[10px] px-1.5 py-0', ROADMAP_PRIORITY_COLORS[feature.priority])}
>
{ROADMAP_PRIORITY_LABELS[feature.priority]}
</Badge>
<Badge
variant="outline"
className={`text-xs ${ROADMAP_COMPLEXITY_COLORS[feature.complexity]}`}
>
{feature.complexity}
</Badge>
<Badge
variant="outline"
className={`text-xs ${ROADMAP_IMPACT_COLORS[feature.impact]}`}
>
{feature.impact} impact
</Badge>
{hasCompetitorInsight && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="text-xs text-primary border-primary/50"
className="text-[10px] px-1.5 py-0 text-primary border-primary/50"
>
<TrendingUp className="h-3 w-3 mr-1" />
Competitor Insight
<TrendingUp className="h-2.5 w-2.5" />
</Badge>
</TooltipTrigger>
<TooltipContent>
@@ -105,39 +93,61 @@ export function SortableFeatureCard({
</Tooltip>
)}
</div>
<h3 className="font-medium truncate">{feature.title}</h3>
<p className="text-sm text-muted-foreground line-clamp-2">
{feature.description}
</p>
<h3 className="font-medium text-sm leading-snug line-clamp-2">{feature.title}</h3>
</div>
{feature.linkedSpecId ? (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
onGoToTask?.(feature.linkedSpecId!);
}}
>
<ExternalLink className="h-3 w-3 mr-1" />
Go to Task
</Button>
) : (
feature.status !== 'done' &&
onConvertToSpec && (
<div className="shrink-0">
{feature.linkedSpecId ? (
<Button
variant="outline"
size="sm"
className="h-7 px-2"
onClick={(e) => {
e.stopPropagation();
onConvertToSpec(feature);
onGoToTask?.(feature.linkedSpecId!);
}}
>
<Play className="h-3 w-3 mr-1" />
Build
<ExternalLink className="h-3 w-3 mr-1" />
Task
</Button>
)
)}
) : (
feature.status !== 'done' &&
onConvertToSpec && (
<Button
variant="outline"
size="sm"
className="h-7 px-2"
onClick={(e) => {
e.stopPropagation();
onConvertToSpec(feature);
}}
>
<Play className="h-3 w-3 mr-1" />
Build
</Button>
)
)}
</div>
</div>
{/* Description */}
<p className="mt-1.5 text-xs text-muted-foreground line-clamp-2">
{feature.description}
</p>
{/* Metadata badges - compact row */}
<div className="mt-2 flex items-center gap-1.5">
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0', ROADMAP_COMPLEXITY_COLORS[feature.complexity])}
>
{feature.complexity}
</Badge>
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0', ROADMAP_IMPACT_COLORS[feature.impact])}
>
{feature.impact}
</Badge>
</div>
</Card>
</div>
@@ -18,7 +18,7 @@ import {
EXECUTION_PHASE_BADGE_COLORS
} from '../../shared/constants';
import { startTask, stopTask, checkTaskRunning, recoverStuckTask, isIncompleteHumanReview, archiveTasks } from '../stores/task-store';
import type { Task, TaskCategory, ExecutionPhase, ReviewReason } from '../../shared/types';
import type { Task, TaskCategory, ReviewReason } from '../../shared/types';
// Category icon mapping
const CategoryIcon: Record<TaskCategory, typeof Zap> = {
@@ -51,16 +51,26 @@ export function TaskCard({ task, onClick }: TaskCardProps) {
const isIncomplete = isIncompleteHumanReview(task);
// Check if task is stuck (status says in_progress but no actual process)
// Add a grace period to avoid false positives during process spawn
useEffect(() => {
let timeoutId: NodeJS.Timeout | undefined;
if (isRunning && !hasCheckedRunning) {
checkTaskRunning(task.id).then((actuallyRunning) => {
setIsStuck(!actuallyRunning);
setHasCheckedRunning(true);
});
// Wait 2 seconds before checking - gives process time to spawn and register
timeoutId = setTimeout(() => {
checkTaskRunning(task.id).then((actuallyRunning) => {
setIsStuck(!actuallyRunning);
setHasCheckedRunning(true);
});
}, 2000);
} else if (!isRunning) {
setIsStuck(false);
setHasCheckedRunning(false);
}
return () => {
if (timeoutId) clearTimeout(timeoutId);
};
}, [task.id, isRunning, hasCheckedRunning]);
const handleStartStop = (e: React.MouseEvent) => {
@@ -55,16 +55,26 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
const taskProgress = getTaskProgress(task);
// Check if task is stuck (status says in_progress but no actual process)
// Add a grace period to avoid false positives during process spawn
useEffect(() => {
let timeoutId: NodeJS.Timeout | undefined;
if (isRunning && !hasCheckedRunning) {
checkTaskRunning(task.id).then((actuallyRunning) => {
setIsStuck(!actuallyRunning);
setHasCheckedRunning(true);
});
// Wait 2 seconds before checking - gives process time to spawn and register
timeoutId = setTimeout(() => {
checkTaskRunning(task.id).then((actuallyRunning) => {
setIsStuck(!actuallyRunning);
setHasCheckedRunning(true);
});
}, 2000);
} else if (!isRunning) {
setIsStuck(false);
setHasCheckedRunning(false);
}
return () => {
if (timeoutId) clearTimeout(timeoutId);
};
}, [task.id, isRunning, hasCheckedRunning]);
// Handle scroll events in logs to detect if user scrolled up
@@ -192,7 +202,7 @@ export function useTaskDetail({ task }: UseTaskDetailOptions) {
console.log('%c[useTaskDetail] Restored merge preview from sessionStorage:', 'color: magenta;', previewData);
setMergePreview(previewData);
// Don't auto-popup - restored data stays silent
} catch (e) {
} catch {
console.warn('[useTaskDetail] Failed to parse stored merge preview');
sessionStorage.removeItem(storageKey);
}
@@ -3,6 +3,7 @@ import { Terminal as XTerm } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { useTerminalStore } from '../../stores/terminal-store';
import { terminalBufferManager } from '../../lib/terminal-buffer-manager';
interface UseXtermOptions {
terminalId: string;
@@ -70,13 +71,12 @@ export function useXterm({ terminalId, onCommandEnter, onResize }: UseXtermOptio
xtermRef.current = xterm;
fitAddonRef.current = fitAddon;
// Replay buffered output if this is a remount
const terminalState = useTerminalStore.getState().terminals.find((t) => t.id === terminalId);
if (terminalState?.outputBuffer && !terminalState.isClaudeMode) {
xterm.write(terminalState.outputBuffer);
useTerminalStore.getState().clearOutputBuffer(terminalId);
} else if (terminalState?.isClaudeMode) {
useTerminalStore.getState().clearOutputBuffer(terminalId);
// Replay buffered output if this is a remount or restored session
const bufferedOutput = terminalBufferManager.get(terminalId);
if (bufferedOutput && bufferedOutput.length > 0) {
xterm.write(bufferedOutput);
// Clear buffer after replay to avoid duplicate output
terminalBufferManager.clear(terminalId);
}
// Handle terminal input
@@ -0,0 +1,219 @@
/**
* Terminal Buffer Persistence (Renderer)
*
* Uses xterm's SerializeAddon to periodically save terminal buffers
* to disk via IPC. This provides fallback recovery when the PTY daemon
* is not available.
*/
import { SerializeAddon } from '@xterm/addon-serialize';
import type { Terminal } from '@xterm/xterm';
// Save interval: 30 seconds during active use
const SAVE_INTERVAL_MS = 30_000;
// Save threshold: when buffer grows by 50KB
const SAVE_THRESHOLD_BYTES = 50_000;
interface ManagedTerminal {
terminalId: string;
xterm: Terminal;
serializeAddon: SerializeAddon;
saveInterval: NodeJS.Timeout;
lastSavedSize: number;
}
class BufferPersistence {
private terminals = new Map<string, ManagedTerminal>();
private isSaving = new Map<string, boolean>();
/**
* Register a terminal for buffer persistence
*/
register(terminalId: string, xterm: Terminal): SerializeAddon {
// Clean up if already registered
if (this.terminals.has(terminalId)) {
this.unregister(terminalId);
}
// Create and load serialize addon
const serializeAddon = new SerializeAddon();
xterm.loadAddon(serializeAddon);
// Start periodic saves
const saveInterval = setInterval(() => {
this.saveBuffer(terminalId).catch((error) => {
console.debug(`[BufferPersistence] Auto-save failed for ${terminalId}:`, error);
});
}, SAVE_INTERVAL_MS);
// Store managed terminal
const managed: ManagedTerminal = {
terminalId,
xterm,
serializeAddon,
saveInterval,
lastSavedSize: 0,
};
this.terminals.set(terminalId, managed);
console.debug(`[BufferPersistence] Registered terminal ${terminalId}`);
return serializeAddon;
}
/**
* Unregister a terminal and cleanup
*/
unregister(terminalId: string): void {
const managed = this.terminals.get(terminalId);
if (!managed) return;
// Stop interval
clearInterval(managed.saveInterval);
// Remove from map
this.terminals.delete(terminalId);
this.isSaving.delete(terminalId);
console.debug(`[BufferPersistence] Unregistered terminal ${terminalId}`);
}
/**
* Save buffer if changed significantly
*/
async saveBuffer(terminalId: string): Promise<void> {
const managed = this.terminals.get(terminalId);
if (!managed) {
console.warn(`[BufferPersistence] Terminal ${terminalId} not registered`);
return;
}
// Skip if already saving
if (this.isSaving.get(terminalId)) {
return;
}
try {
this.isSaving.set(terminalId, true);
// Serialize the buffer
const serialized = managed.serializeAddon.serialize();
const currentSize = serialized.length;
const lastSize = managed.lastSavedSize;
// Only save if buffer grew significantly or was cleared
const shouldSave =
currentSize - lastSize > SAVE_THRESHOLD_BYTES || // Grew significantly
currentSize < lastSize; // Buffer was cleared
if (!shouldSave) {
return;
}
// Save via IPC
await window.electronAPI.saveTerminalBuffer(terminalId, serialized);
// Update last saved size
managed.lastSavedSize = currentSize;
console.debug(
`[BufferPersistence] Saved buffer for ${terminalId} (${currentSize} bytes)`
);
} catch (error) {
console.error(`[BufferPersistence] Failed to save ${terminalId}:`, error);
throw error;
} finally {
this.isSaving.set(terminalId, false);
}
}
/**
* Force immediate save (call before close)
*/
async saveNow(terminalId: string): Promise<void> {
const managed = this.terminals.get(terminalId);
if (!managed) return;
try {
const serialized = managed.serializeAddon.serialize();
await window.electronAPI.saveTerminalBuffer(terminalId, serialized);
managed.lastSavedSize = serialized.length;
console.debug(`[BufferPersistence] Immediate save for ${terminalId} complete`);
} catch (error) {
console.error(`[BufferPersistence] Failed to immediately save ${terminalId}:`, error);
throw error;
}
}
/**
* Save all registered terminals
*/
async saveAll(): Promise<void> {
console.log(`[BufferPersistence] Saving all buffers (${this.terminals.size} terminals)`);
const saves = Array.from(this.terminals.keys()).map((id) =>
this.saveNow(id).catch((error) => {
console.error(`[BufferPersistence] Failed to save ${id}:`, error);
})
);
await Promise.all(saves);
console.log('[BufferPersistence] All buffers saved');
}
/**
* Get current buffer size for a terminal
*/
getBufferSize(terminalId: string): number | null {
const managed = this.terminals.get(terminalId);
if (!managed) return null;
try {
const serialized = managed.serializeAddon.serialize();
return serialized.length;
} catch {
return null;
}
}
/**
* Check if a terminal is registered
*/
isRegistered(terminalId: string): boolean {
return this.terminals.has(terminalId);
}
/**
* Get all registered terminal IDs
*/
getRegisteredTerminals(): string[] {
return Array.from(this.terminals.keys());
}
/**
* Cleanup all terminals
*/
cleanup(): void {
console.log('[BufferPersistence] Cleaning up all terminals');
Array.from(this.terminals.keys()).forEach((id) => this.unregister(id));
}
}
// Singleton instance
export const bufferPersistence = new BufferPersistence();
// Save all buffers before page unload
window.addEventListener('beforeunload', () => {
console.log('[BufferPersistence] Page unloading, saving all buffers...');
// Use synchronous save via IPC if available, otherwise fire and forget
bufferPersistence.saveAll().catch((error) => {
console.error('[BufferPersistence] Failed to save all buffers on unload:', error);
});
});
// Cleanup on page hide (browser background/minimize)
window.addEventListener('pagehide', () => {
bufferPersistence.saveAll().catch(console.error);
});
@@ -0,0 +1,143 @@
/**
* Flow Controller
*
* Implements high/low watermark flow control to prevent terminal overwhelm
* during high-velocity streaming (e.g., Claude Code output).
*
* Inspired by:
* - Tabby's 128KB threshold approach
* - xterm.js official flow control recommendations (< 500KB for responsiveness)
* - Production-proven backpressure handling
*/
import type { Terminal } from '@xterm/xterm';
export class FlowController {
// Thresholds based on xterm.js recommendations and Tabby's implementation
private static readonly BYTE_THRESHOLD = 100_000; // 100KB - trigger callback tracking
private static readonly HIGH_WATERMARK = 5; // Pause at 5 pending callbacks
private static readonly LOW_WATERMARK = 2; // Resume at 2 pending callbacks
private pendingCallbacks = 0;
private bytesWritten = 0;
private blocked = false;
private resolveBlock: (() => void) | null = null;
// Statistics for debugging/monitoring
private stats = {
totalWrites: 0,
totalBytes: 0,
blockedCount: 0,
maxPendingCallbacks: 0,
};
/**
* Write data to terminal with backpressure handling
*
* For small chunks (< 100KB accumulated): fast path, no callback overhead
* For large chunks: use callbacks to track completion and apply backpressure
*/
async write(terminal: Terminal, data: string): Promise<void> {
// Wait if we're blocked (too many pending writes)
if (this.blocked) {
await new Promise<void>((resolve) => {
this.resolveBlock = resolve;
});
}
this.bytesWritten += data.length;
this.stats.totalWrites++;
this.stats.totalBytes += data.length;
// Use callbacks for large accumulated chunks to track completion
if (this.bytesWritten >= FlowController.BYTE_THRESHOLD) {
terminal.write(data, () => {
// Callback fires when xterm finishes processing this chunk
this.pendingCallbacks = Math.max(0, this.pendingCallbacks - 1);
// Unblock if we've drained below low watermark
if (this.pendingCallbacks < FlowController.LOW_WATERMARK && this.blocked) {
this.blocked = false;
this.resolveBlock?.();
this.resolveBlock = null;
}
});
this.pendingCallbacks++;
this.stats.maxPendingCallbacks = Math.max(
this.stats.maxPendingCallbacks,
this.pendingCallbacks
);
this.bytesWritten = 0;
// Block if we've exceeded high watermark
if (this.pendingCallbacks > FlowController.HIGH_WATERMARK) {
if (!this.blocked) {
this.blocked = true;
this.stats.blockedCount++;
}
}
} else {
// Fast path - no callback overhead for small chunks
terminal.write(data);
}
}
/**
* Check if currently blocked (for debugging/metrics)
*/
isBlocked(): boolean {
return this.blocked;
}
/**
* Get pending callbacks count
*/
getPendingCallbacks(): number {
return this.pendingCallbacks;
}
/**
* Get statistics for monitoring
*/
getStats(): {
totalWrites: number;
totalBytes: number;
blockedCount: number;
maxPendingCallbacks: number;
currentPending: number;
isBlocked: boolean;
} {
return {
...this.stats,
currentPending: this.pendingCallbacks,
isBlocked: this.blocked,
};
}
/**
* Reset statistics
*/
resetStats(): void {
this.stats = {
totalWrites: 0,
totalBytes: 0,
blockedCount: 0,
maxPendingCallbacks: 0,
};
}
/**
* Force unblock (emergency recovery)
*/
forceUnblock(): void {
if (this.blocked) {
console.warn('[FlowController] Force unblock - resetting state');
this.blocked = false;
this.pendingCallbacks = 0;
this.bytesWritten = 0;
this.resolveBlock?.();
this.resolveBlock = null;
}
}
}
@@ -61,6 +61,14 @@ export const changelogMock = {
}
}),
suggestChangelogVersionFromCommits: async () => ({
success: true,
data: {
version: '1.0.0',
reason: 'Based on commit analysis'
}
}),
getChangelogBranches: async () => ({
success: true,
data: []
@@ -69,6 +69,8 @@ export const terminalMock = {
}
}),
saveTerminalBuffer: async () => {},
// Terminal Event Listeners (no-op in browser)
onTerminalOutput: () => () => {},
onTerminalExit: () => () => {},
@@ -0,0 +1,116 @@
/**
* Scroll Controller
*
* Prevents automatic scroll-to-bottom during high-velocity terminal output
* when the user has manually scrolled up.
*
* Inspired by Tabby's scroll interception pattern - production-proven approach
* that intercepts xterm's native scrollToBottom method.
*/
import type { Terminal } from '@xterm/xterm';
export class ScrollController {
private userScrolledUp = false;
private originalScrollToBottom: (() => void) | null = null;
private xterm: Terminal | null = null;
private scrollListener: (() => void) | null = null;
/**
* Attach to an xterm instance
*/
attach(xterm: Terminal): void {
this.xterm = xterm;
// Access internal core (Tabby does this too - it's safe)
const core = (xterm as any)._core;
if (!core) {
console.warn('[ScrollController] Cannot access xterm core - scroll locking disabled');
return;
}
// Store original scrollToBottom method
this.originalScrollToBottom = core.scrollToBottom.bind(core);
// Intercept scrollToBottom - block when user has scrolled up
core.scrollToBottom = () => {
if (this.userScrolledUp) {
// No-op when user has scrolled up - this is the key fix!
return;
}
this.originalScrollToBottom?.();
};
// Track user scroll position
this.scrollListener = () => {
const buffer = xterm.buffer.active;
const atBottom = buffer.baseY + xterm.rows >= buffer.length - 1;
this.userScrolledUp = !atBottom;
};
xterm.onScroll(this.scrollListener);
console.debug('[ScrollController] Attached and intercepted scrollToBottom');
}
/**
* Detach from xterm and restore original behavior
*/
detach(): void {
if (this.xterm && this.originalScrollToBottom) {
const core = (this.xterm as any)._core;
if (core && this.originalScrollToBottom) {
core.scrollToBottom = this.originalScrollToBottom;
}
}
this.xterm = null;
this.originalScrollToBottom = null;
this.scrollListener = null;
this.userScrolledUp = false;
console.debug('[ScrollController] Detached');
}
/**
* Force scroll to bottom (e.g., user clicks "scroll to bottom" button)
*/
forceScrollToBottom(): void {
this.userScrolledUp = false;
this.originalScrollToBottom?.();
}
/**
* Check if user has scrolled up (for UI indicators)
*/
isScrolledUp(): boolean {
return this.userScrolledUp;
}
/**
* Manually reset scroll state (e.g., after clearing terminal)
*/
reset(): void {
this.userScrolledUp = false;
}
/**
* Get current scroll position info for debugging
*/
getScrollInfo(): {
userScrolledUp: boolean;
baseY?: number;
rows?: number;
bufferLength?: number;
} | null {
if (!this.xterm) return null;
const buffer = this.xterm.buffer.active;
return {
userScrolledUp: this.userScrolledUp,
baseY: buffer.baseY,
rows: this.xterm.rows,
bufferLength: buffer.length,
};
}
}
@@ -0,0 +1,165 @@
/**
* Terminal Buffer Manager
*
* Singleton that manages terminal output buffers outside of React state.
* This prevents React re-renders on every terminal output chunk.
*
* Inspired by VS Code's DisposableStore pattern.
*/
interface Disposable {
dispose(): void;
}
class TerminalBufferManager {
private static instance: TerminalBufferManager;
private buffers = new Map<string, string>();
private disposables = new Map<string, Disposable[]>();
private readonly MAX_BUFFER_SIZE = 100_000; // 100KB per terminal
private constructor() {
// Private constructor for singleton
}
static getInstance(): TerminalBufferManager {
if (!TerminalBufferManager.instance) {
TerminalBufferManager.instance = new TerminalBufferManager();
}
return TerminalBufferManager.instance;
}
/**
* Append data to a terminal's buffer
* Automatically truncates to MAX_BUFFER_SIZE
*/
append(id: string, data: string): void {
const current = this.buffers.get(id) || '';
const combined = current + data;
// Keep only the last MAX_BUFFER_SIZE characters
const truncated = combined.length > this.MAX_BUFFER_SIZE
? combined.slice(-this.MAX_BUFFER_SIZE)
: combined;
this.buffers.set(id, truncated);
}
/**
* Get the buffer for a terminal
*/
get(id: string): string {
return this.buffers.get(id) || '';
}
/**
* Set the entire buffer (for restoration)
*/
set(id: string, buffer: string): void {
this.buffers.set(id, buffer.slice(-this.MAX_BUFFER_SIZE));
}
/**
* Clear a terminal's buffer
*/
clear(id: string): void {
this.buffers.delete(id);
}
/**
* Check if a terminal has a buffer
*/
has(id: string): boolean {
return this.buffers.has(id);
}
/**
* Get buffer size in bytes
*/
getSize(id: string): number {
return this.buffers.get(id)?.length || 0;
}
/**
* Register disposables for proper cleanup (VS Code pattern)
*/
registerDisposable(id: string, ...disposables: Disposable[]): void {
const existing = this.disposables.get(id) || [];
this.disposables.set(id, [...existing, ...disposables]);
}
/**
* Full cleanup when terminal is destroyed
*/
dispose(id: string): void {
// Dispose all registered resources
const disposables = this.disposables.get(id);
if (disposables) {
for (const disposable of disposables) {
try {
disposable.dispose();
} catch (e) {
console.warn(`[TerminalBufferManager] Error disposing resource for ${id}:`, e);
}
}
this.disposables.delete(id);
}
// Remove buffer
this.buffers.delete(id);
}
/**
* For session persistence - get all buffers
*/
getAll(): Map<string, string> {
return new Map(this.buffers);
}
/**
* Get all terminal IDs with buffers
*/
getAllIds(): string[] {
return Array.from(this.buffers.keys());
}
/**
* Get total memory usage across all buffers
*/
getTotalSize(): number {
let total = 0;
for (const buffer of this.buffers.values()) {
total += buffer.length;
}
return total;
}
/**
* Get statistics for debugging
*/
getStats(): {
terminalCount: number;
totalSizeBytes: number;
maxBufferSize: number;
buffers: Array<{ id: string; sizeBytes: number }>;
} {
const buffers = Array.from(this.buffers.entries()).map(([id, buffer]) => ({
id,
sizeBytes: buffer.length,
}));
return {
terminalCount: this.buffers.size,
totalSizeBytes: this.getTotalSize(),
maxBufferSize: this.MAX_BUFFER_SIZE,
buffers,
};
}
}
// Export singleton instance
export const terminalBufferManager = TerminalBufferManager.getInstance();
// For debugging in browser console
if (typeof window !== 'undefined') {
(window as any).__terminalBufferManager = terminalBufferManager;
}
@@ -0,0 +1,185 @@
/**
* WebGL Context Manager
*
* Manages WebGL context lifecycle with LRU eviction to prevent
* browser WebGL context exhaustion (typically 8-16 limit).
*
* Inspired by VS Code's conditional WebGL loading and Hyper's context management.
*/
import { WebglAddon } from '@xterm/addon-webgl';
import type { Terminal } from '@xterm/xterm';
import { supportsWebGL2, getMaxWebGLContexts } from './webgl-utils';
class WebGLContextManager {
private static instance: WebGLContextManager;
private readonly MAX_CONTEXTS: number;
private activeContexts = new Map<string, WebglAddon>();
private terminals = new Map<string, Terminal>();
private contextQueue: string[] = []; // LRU tracking
readonly isSupported: boolean;
private constructor() {
// Check WebGL support once at startup
this.isSupported = supportsWebGL2();
// Use conservative max based on browser detection
this.MAX_CONTEXTS = Math.min(getMaxWebGLContexts(), 8);
console.log(
`[WebGLContextManager] Initialized - Supported: ${this.isSupported}, Max contexts: ${this.MAX_CONTEXTS}`
);
}
static getInstance(): WebGLContextManager {
if (!WebGLContextManager.instance) {
WebGLContextManager.instance = new WebGLContextManager();
}
return WebGLContextManager.instance;
}
/**
* Register a terminal for WebGL management
*/
register(terminalId: string, xterm: Terminal): void {
this.terminals.set(terminalId, xterm);
console.debug(`[WebGLContextManager] Registered terminal ${terminalId}`);
}
/**
* Unregister a terminal (called on terminal close)
*/
unregister(terminalId: string): void {
this.release(terminalId);
this.terminals.delete(terminalId);
// Remove from LRU queue
this.contextQueue = this.contextQueue.filter((id) => id !== terminalId);
console.debug(`[WebGLContextManager] Unregistered terminal ${terminalId}`);
}
/**
* Acquire a WebGL context for a terminal (called when visible)
*/
acquire(terminalId: string): boolean {
if (!this.isSupported) {
return false;
}
const xterm = this.terminals.get(terminalId);
if (!xterm) {
console.warn(`[WebGLContextManager] Terminal ${terminalId} not registered`);
return false;
}
// Already has a context
if (this.activeContexts.has(terminalId)) {
// Move to end of LRU queue (mark as recently used)
this.contextQueue = this.contextQueue.filter((id) => id !== terminalId);
this.contextQueue.push(terminalId);
return true;
}
// LRU eviction: if at limit, release oldest context
if (this.activeContexts.size >= this.MAX_CONTEXTS) {
const oldest = this.contextQueue.shift();
if (oldest) {
console.log(
`[WebGLContextManager] Evicting oldest context: ${oldest} (at limit ${this.MAX_CONTEXTS})`
);
this.release(oldest);
}
}
try {
const addon = new WebglAddon();
// Handle context loss gracefully (VS Code pattern)
addon.onContextLoss(() => {
console.warn(`[WebGLContextManager] Context lost for terminal ${terminalId}`);
this.activeContexts.delete(terminalId);
this.contextQueue = this.contextQueue.filter((id) => id !== terminalId);
// Terminal will re-acquire on next visibility change
});
xterm.loadAddon(addon);
this.activeContexts.set(terminalId, addon);
this.contextQueue.push(terminalId);
console.log(
`[WebGLContextManager] Acquired context for ${terminalId} (active: ${this.activeContexts.size}/${this.MAX_CONTEXTS})`
);
return true;
} catch (error) {
console.warn(`[WebGLContextManager] Failed to acquire context for ${terminalId}:`, error);
return false; // Falls back to canvas renderer automatically
}
}
/**
* Release a WebGL context (called when terminal becomes hidden)
*/
release(terminalId: string): void {
const addon = this.activeContexts.get(terminalId);
if (!addon) {
return;
}
try {
addon.dispose();
console.log(
`[WebGLContextManager] Released context for ${terminalId} (active: ${this.activeContexts.size - 1}/${this.MAX_CONTEXTS})`
);
} catch (error) {
console.debug(`[WebGLContextManager] Error disposing context for ${terminalId}:`, error);
// Context may already be lost, continue cleanup
}
this.activeContexts.delete(terminalId);
// Remove from queue (will be re-added on next acquire)
this.contextQueue = this.contextQueue.filter((id) => id !== terminalId);
}
/**
* Check if a terminal has an active WebGL context
*/
hasContext(terminalId: string): boolean {
return this.activeContexts.has(terminalId);
}
/**
* Get statistics for debugging
*/
getStats(): {
isSupported: boolean;
maxContexts: number;
activeContexts: number;
registeredTerminals: number;
contextQueue: string[];
} {
return {
isSupported: this.isSupported,
maxContexts: this.MAX_CONTEXTS,
activeContexts: this.activeContexts.size,
registeredTerminals: this.terminals.size,
contextQueue: [...this.contextQueue],
};
}
/**
* Force release all contexts (for debugging or emergency cleanup)
*/
releaseAll(): void {
console.log('[WebGLContextManager] Releasing all contexts');
const terminalIds = Array.from(this.activeContexts.keys());
for (const id of terminalIds) {
this.release(id);
}
}
}
// Export singleton instance
export const webglContextManager = WebGLContextManager.getInstance();
// For debugging in browser console
if (typeof window !== 'undefined') {
(window as any).__webglContextManager = webglContextManager;
}
@@ -0,0 +1,154 @@
/**
* WebGL Utilities
*
* Feature detection and compatibility checks for WebGL rendering.
* Inspired by Hyper's WebGL2 detection patterns.
*/
/**
* Check if WebGL2 is supported in the current browser
*/
export function supportsWebGL2(): boolean {
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl2');
return gl !== null;
} catch {
return false;
}
}
/**
* Check if WebGL (version 1) is supported
*/
export function supportsWebGL(): boolean {
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
return gl !== null;
} catch {
return false;
}
}
/**
* Get the maximum number of WebGL contexts supported by the browser
* This is a conservative estimate - browsers typically support 8-16
*/
export function getMaxWebGLContexts(): number {
// Conservative default
let maxContexts = 8;
try {
// Try to detect browser
const userAgent = navigator.userAgent.toLowerCase();
if (userAgent.includes('chrome') || userAgent.includes('chromium')) {
// Chrome/Chromium typically supports 16
maxContexts = 16;
} else if (userAgent.includes('firefox')) {
// Firefox typically supports 32
maxContexts = 32;
} else if (userAgent.includes('safari')) {
// Safari is more conservative
maxContexts = 8;
}
// For Electron, we can be a bit more generous
if (userAgent.includes('electron')) {
maxContexts = Math.min(maxContexts, 12); // Use 12 for safety
}
} catch {
// Fallback to conservative default
}
return maxContexts;
}
/**
* Check if terminal configuration is compatible with WebGL
* Some features don't work well with WebGL rendering
*/
export function canUseWebGL(options: {
transparency?: boolean;
ligatures?: boolean;
}): boolean {
// WebGL doesn't work well with transparency (Hyper finding)
if (options.transparency) {
return false;
}
// Ligatures can cause issues with WebGL in some terminals
if (options.ligatures) {
return false;
}
return supportsWebGL2() || supportsWebGL();
}
/**
* Get WebGL info for debugging
*/
export function getWebGLInfo(): {
webgl1Supported: boolean;
webgl2Supported: boolean;
maxContexts: number;
renderer?: string;
vendor?: string;
} {
const info = {
webgl1Supported: supportsWebGL(),
webgl2Supported: supportsWebGL2(),
maxContexts: getMaxWebGLContexts(),
renderer: undefined as string | undefined,
vendor: undefined as string | undefined,
};
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
if (gl) {
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
if (debugInfo) {
info.renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL);
info.vendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL);
}
}
} catch {
// Info not available
}
return info;
}
/**
* Test WebGL context creation to verify it's working
*/
export function testWebGLContext(): {
success: boolean;
error?: string;
} {
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
if (!gl) {
return {
success: false,
error: 'Failed to create WebGL context',
};
}
// Try a simple render to verify it works
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
@@ -1,6 +1,8 @@
import { create } from 'zustand';
import { v4 as uuid } from 'uuid';
import type { TerminalSession } from '../../shared/types';
import { terminalBufferManager } from '../lib/terminal-buffer-manager';
import { debugLog, debugError } from '../../shared/utils/debug-logger';
export type TerminalStatus = 'idle' | 'running' | 'claude-active' | 'exited';
@@ -12,7 +14,7 @@ export interface Terminal {
createdAt: Date;
isClaudeMode: boolean;
claudeSessionId?: string; // Claude Code session ID for resume
outputBuffer: string; // Store terminal output for replay on remount
// outputBuffer removed - now managed by terminalBufferManager singleton
isRestored?: boolean; // Whether this terminal was restored from a saved session
associatedTaskId?: string; // ID of task associated with this terminal (for context loading)
}
@@ -73,7 +75,7 @@ export const useTerminalStore = create<TerminalState>((set, get) => ({
cwd: cwd || process.env.HOME || '~',
createdAt: new Date(),
isClaudeMode: false,
outputBuffer: '',
// outputBuffer removed - managed by terminalBufferManager
};
set((state) => ({
@@ -101,10 +103,15 @@ export const useTerminalStore = create<TerminalState>((set, get) => ({
createdAt: new Date(session.createdAt),
isClaudeMode: session.isClaudeMode,
claudeSessionId: session.claudeSessionId,
outputBuffer: session.outputBuffer,
// outputBuffer now stored in terminalBufferManager
isRestored: true,
};
// Restore buffer to buffer manager
if (session.outputBuffer) {
terminalBufferManager.set(session.id, session.outputBuffer);
}
set((state) => ({
terminals: [...state.terminals, restoredTerminal],
activeTerminalId: state.activeTerminalId || restoredTerminal.id,
@@ -114,6 +121,9 @@ export const useTerminalStore = create<TerminalState>((set, get) => ({
},
removeTerminal: (id: string) => {
// Clean up buffer manager
terminalBufferManager.dispose(id);
set((state) => {
const newTerminals = state.terminals.filter((t) => t.id !== id);
const newActiveId = state.activeTerminalId === id
@@ -173,26 +183,16 @@ export const useTerminalStore = create<TerminalState>((set, get) => ({
}));
},
// DEPRECATED: Use terminalBufferManager.append() directly
// Kept for backward compatibility, but does NOT trigger React re-renders
appendOutput: (id: string, data: string) => {
set((state) => ({
terminals: state.terminals.map((t) =>
t.id === id
? {
...t,
// Limit buffer size to prevent memory issues (keep last 100KB)
outputBuffer: (t.outputBuffer + data).slice(-100000)
}
: t
),
}));
terminalBufferManager.append(id, data);
// No React state update - this is the key performance improvement!
},
// DEPRECATED: Use terminalBufferManager.clear() directly
clearOutputBuffer: (id: string) => {
set((state) => ({
terminals: state.terminals.map((t) =>
t.id === id ? { ...t, outputBuffer: '' } : t
),
}));
terminalBufferManager.clear(id);
},
clearAllTerminals: () => {
@@ -226,7 +226,7 @@ export async function restoreTerminalSessions(projectPath: string): Promise<void
// Don't restore if we already have terminals (user might have opened some manually)
if (store.terminals.length > 0) {
console.log('[TerminalStore] Terminals already exist, skipping session restore');
debugLog('[TerminalStore] Terminals already exist, skipping session restore');
return;
}
@@ -243,6 +243,6 @@ export async function restoreTerminalSessions(projectPath: string): Promise<void
store.setHasRestoredSessions(true);
} catch (error) {
console.error('[TerminalStore] Error restoring sessions:', error);
debugError('[TerminalStore] Error restoring sessions:', error);
}
}
+5
View File
@@ -165,6 +165,7 @@ export interface ElectronAPI {
getTerminalSessionDates: (projectPath?: string) => Promise<IPCResult<SessionDateInfo[]>>;
getTerminalSessionsForDate: (date: string, projectPath: string) => Promise<IPCResult<TerminalSession[]>>;
restoreTerminalSessionsFromDate: (date: string, projectPath: string, cols?: number, rows?: number) => Promise<IPCResult<SessionDateRestoreResult>>;
saveTerminalBuffer: (terminalId: string, serialized: string) => Promise<void>;
// Terminal event listeners
onTerminalOutput: (callback: (id: string, data: string) => void) => () => void;
@@ -405,6 +406,10 @@ export interface ElectronAPI {
projectId: string,
taskIds: string[]
) => Promise<IPCResult<{ version: string; reason: string }>>;
suggestChangelogVersionFromCommits: (
projectId: string,
commits: import('./changelog').GitCommit[]
) => Promise<IPCResult<{ version: string; reason: string }>>;
// Changelog git operations (for git-based changelog generation)
getChangelogBranches: (projectId: string) => Promise<IPCResult<GitBranchInfo[]>>;
@@ -0,0 +1,74 @@
/**
* Terminal Session State for Persistence
*
* This provides the comprehensive state needed for session restoration
* across app restarts, crashes, and hot reloads.
*/
export interface TerminalSessionState {
// Identity
id: string;
title: string;
// Process configuration (for recreation)
shell: string;
shellArgs: string[];
cwd: string;
env: Record<string, string>;
// Display state
rows: number;
cols: number;
// Claude Code specific
isClaudeMode: boolean;
claudeSessionId?: string; // For potential /resume
// Timing
createdAt: number;
lastActiveAt: number;
// Persistence metadata
bufferFile?: string; // Reference to serialized buffer file
// Daemon reference
daemonPtyId?: string; // ID of PTY in daemon process
// Project context
projectPath?: string;
}
export interface TerminalSessionsFile {
version: 2;
savedAt: number;
sessions: TerminalSessionState[];
}
/**
* Recovery information for session restoration UI
*/
export interface TerminalRecoveryInfo {
totalSessions: number;
recoverableSessions: number;
recoveryMethod: 'daemon' | 'state' | 'none';
sessions: Array<{
id: string;
title: string;
isClaudeMode: boolean;
lastActiveAt: number;
hasBuffer: boolean;
hasDaemonPty: boolean;
}>;
}
/**
* Result of session recovery attempt
*/
export interface SessionRecoveryResult {
sessionId: string;
success: boolean;
method: 'daemon-reconnect' | 'state-restore' | 'failed';
error?: string;
restoredBuffer: boolean;
restoredProcess: boolean;
}
@@ -0,0 +1,29 @@
/**
* Debug Logger
* Only logs when DEBUG=true in environment
*/
const isDebugEnabled = (): boolean => {
if (typeof process !== 'undefined' && process.env) {
return process.env.DEBUG === 'true';
}
return false;
};
export const debugLog = (...args: unknown[]): void => {
if (isDebugEnabled()) {
console.log(...args);
}
};
export const debugWarn = (...args: unknown[]): void => {
if (isDebugEnabled()) {
console.warn(...args);
}
};
export const debugError = (...args: unknown[]): void => {
if (isDebugEnabled()) {
console.error(...args);
}
};
+10
View File
@@ -9,6 +9,16 @@ CLAUDE_CODE_OAUTH_TOKEN=your-oauth-token-here
# Default: claude-opus-4-5-20251101
# AUTO_BUILD_MODEL=claude-opus-4-5-20251101
# =============================================================================
# GIT/WORKTREE SETTINGS (OPTIONAL)
# =============================================================================
# Configure how Auto Claude handles git worktrees for isolated builds.
# Default base branch for worktree creation (OPTIONAL)
# If not set, Auto Claude will auto-detect main/master, or fall back to current branch.
# Common values: main, master, develop
# DEFAULT_BRANCH=main
# =============================================================================
# DEBUG MODE (OPTIONAL)
# =============================================================================
+47 -1
View File
@@ -15,6 +15,7 @@ This allows:
"""
import asyncio
import os
import re
import shutil
import subprocess
@@ -53,10 +54,55 @@ class WorktreeManager:
def __init__(self, project_dir: Path, base_branch: str | None = None):
self.project_dir = project_dir
self.base_branch = base_branch or self._get_current_branch()
self.base_branch = base_branch or self._detect_base_branch()
self.worktrees_dir = project_dir / ".worktrees"
self._merge_lock = asyncio.Lock()
def _detect_base_branch(self) -> str:
"""
Detect the base branch for worktree creation.
Priority order:
1. DEFAULT_BRANCH environment variable
2. Auto-detect main/master (if they exist)
3. Fall back to current branch (with warning)
Returns:
The detected base branch name
"""
# 1. Check for DEFAULT_BRANCH env var
env_branch = os.getenv("DEFAULT_BRANCH")
if env_branch:
# Verify the branch exists
result = subprocess.run(
["git", "rev-parse", "--verify", env_branch],
cwd=self.project_dir,
capture_output=True,
text=True,
)
if result.returncode == 0:
return env_branch
else:
print(f"Warning: DEFAULT_BRANCH '{env_branch}' not found, auto-detecting...")
# 2. Auto-detect main/master
for branch in ["main", "master"]:
result = subprocess.run(
["git", "rev-parse", "--verify", branch],
cwd=self.project_dir,
capture_output=True,
text=True,
)
if result.returncode == 0:
return branch
# 3. Fall back to current branch with warning
current = self._get_current_branch()
print(f"Warning: Could not find 'main' or 'master' branch.")
print(f"Warning: Using current branch '{current}' as base for worktree.")
print(f"Tip: Set DEFAULT_BRANCH=your-branch in .env to avoid this.")
return current
def _get_current_branch(self) -> str:
"""Get the current git branch."""
result = subprocess.run(
+1 -1
View File
@@ -240,7 +240,7 @@ Examples:
print()
# Build the run.py command
run_script = Path(__file__).parent / "run.py"
run_script = Path(__file__).parent.parent / "run.py"
run_cmd = [
sys.executable,
str(run_script),
+2 -2
View File
@@ -33,8 +33,8 @@ def run_discovery_script(
if spec_index.exists():
return True, "project_index.json already exists"
# Run analyzer
script_path = project_dir / "auto-claude" / "analyzer.py"
# Run analyzer - use framework-relative path instead of project_dir
script_path = Path(__file__).parent.parent / "analyzer.py"
if not script_path.exists():
return False, f"Script not found: {script_path}"