Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0c775f690 | |||
| 9babdc23c1 | |||
| dc886dce44 | |||
| 32760348ae | |||
| 395ba60098 | |||
| d85a1b4f63 | |||
| 43a593c1e1 | |||
| 5078191d74 | |||
| 99850512e6 | |||
| f92fcfa075 | |||
| f201f7e3a8 |
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+24
@@ -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}`);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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)
|
||||
# =============================================================================
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user