mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
feat: enhance PTY handling for Windows in ClaudeUsageService and TerminalService
- Added detection for Electron environment to improve compatibility with Windows PTY processes. - Implemented winpty fallback for ConPTY failures, ensuring robust terminal session creation in Electron and other contexts. - Updated error handling to provide clearer messages for authentication and terminal access issues. - Refined usage data detection logic to avoid false positives, improving the accuracy of usage reporting. These changes aim to enhance the reliability and user experience of terminal interactions on Windows, particularly in Electron applications.
This commit is contained in:
@@ -22,6 +22,13 @@ export class ClaudeUsageService {
|
||||
private timeout = 30000; // 30 second timeout
|
||||
private isWindows = os.platform() === 'win32';
|
||||
private isLinux = os.platform() === 'linux';
|
||||
// On Windows, ConPTY requires AttachConsole which fails in Electron/service mode
|
||||
// Detect Electron by checking for electron-specific env vars or process properties
|
||||
// When in Electron, always use winpty to avoid ConPTY's AttachConsole errors
|
||||
private isElectron =
|
||||
!!(process.versions && (process.versions as Record<string, string>).electron) ||
|
||||
!!process.env.ELECTRON_RUN_AS_NODE;
|
||||
private useConptyFallback = false; // Track if we need to use winpty fallback on Windows
|
||||
|
||||
/**
|
||||
* Kill a PTY process with platform-specific handling.
|
||||
@@ -197,30 +204,87 @@ export class ClaudeUsageService {
|
||||
? ['/c', 'claude', '--add-dir', workingDirectory]
|
||||
: ['-c', `claude --add-dir "${workingDirectory}"`];
|
||||
|
||||
// Using 'any' for ptyProcess because node-pty types don't include 'killed' property
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let ptyProcess: any = null;
|
||||
|
||||
// Build PTY spawn options
|
||||
const ptyOptions: pty.IPtyForkOptions = {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
cwd: workingDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
} as Record<string, string>,
|
||||
};
|
||||
|
||||
// On Windows, always use winpty instead of ConPTY
|
||||
// ConPTY requires AttachConsole which fails in many contexts:
|
||||
// - Electron apps without a console
|
||||
// - VS Code integrated terminal
|
||||
// - Spawned from other applications
|
||||
// The error happens in a subprocess so we can't catch it - must proactively disable
|
||||
if (this.isWindows) {
|
||||
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
|
||||
logger.info(
|
||||
'[executeClaudeUsageCommandPty] Using winpty on Windows (ConPTY disabled for compatibility)'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
ptyProcess = pty.spawn(shell, args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
cwd: workingDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
} as Record<string, string>,
|
||||
});
|
||||
ptyProcess = pty.spawn(shell, args, ptyOptions);
|
||||
} catch (spawnError) {
|
||||
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
|
||||
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
|
||||
|
||||
// Return a user-friendly error instead of crashing
|
||||
reject(
|
||||
new Error(
|
||||
`Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.`
|
||||
)
|
||||
);
|
||||
return;
|
||||
// Check for Windows ConPTY-specific errors
|
||||
if (this.isWindows && errorMessage.includes('AttachConsole failed')) {
|
||||
// ConPTY failed - try winpty fallback
|
||||
if (!this.useConptyFallback) {
|
||||
logger.warn(
|
||||
'[executeClaudeUsageCommandPty] ConPTY AttachConsole failed, retrying with winpty fallback'
|
||||
);
|
||||
this.useConptyFallback = true;
|
||||
|
||||
try {
|
||||
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
|
||||
ptyProcess = pty.spawn(shell, args, ptyOptions);
|
||||
logger.info(
|
||||
'[executeClaudeUsageCommandPty] Successfully spawned with winpty fallback'
|
||||
);
|
||||
} catch (fallbackError) {
|
||||
const fallbackMessage =
|
||||
fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
|
||||
logger.error(
|
||||
'[executeClaudeUsageCommandPty] Winpty fallback also failed:',
|
||||
fallbackMessage
|
||||
);
|
||||
reject(
|
||||
new Error(
|
||||
`Windows PTY unavailable: Both ConPTY and winpty failed. This typically happens when running in Electron without a console. ConPTY error: ${errorMessage}. Winpty error: ${fallbackMessage}`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
logger.error('[executeClaudeUsageCommandPty] Winpty fallback failed:', errorMessage);
|
||||
reject(
|
||||
new Error(
|
||||
`Windows PTY unavailable: ${errorMessage}. The application is running without console access (common in Electron). Try running from a terminal window.`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
|
||||
reject(
|
||||
new Error(
|
||||
`Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
@@ -260,12 +324,19 @@ export class ClaudeUsageService {
|
||||
const cleanOutput = output.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
|
||||
|
||||
// Check for specific authentication/permission errors
|
||||
if (
|
||||
cleanOutput.includes('OAuth token does not meet scope requirement') ||
|
||||
cleanOutput.includes('permission_error') ||
|
||||
cleanOutput.includes('token_expired') ||
|
||||
cleanOutput.includes('authentication_error')
|
||||
) {
|
||||
// Must be very specific to avoid false positives from garbled terminal encoding
|
||||
// Removed permission_error check as it was causing false positives with winpty encoding
|
||||
const authChecks = {
|
||||
oauth: cleanOutput.includes('OAuth token does not meet scope requirement'),
|
||||
tokenExpired: cleanOutput.includes('token_expired'),
|
||||
// Only match if it looks like a JSON API error response
|
||||
authError:
|
||||
cleanOutput.includes('"type":"authentication_error"') ||
|
||||
cleanOutput.includes('"type": "authentication_error"'),
|
||||
};
|
||||
const hasAuthError = authChecks.oauth || authChecks.tokenExpired || authChecks.authError;
|
||||
|
||||
if (hasAuthError) {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
if (ptyProcess && !ptyProcess.killed) {
|
||||
@@ -281,11 +352,16 @@ export class ClaudeUsageService {
|
||||
}
|
||||
|
||||
// Check if we've seen the usage data (look for "Current session" or the TUI Usage header)
|
||||
if (
|
||||
!hasSeenUsageData &&
|
||||
(cleanOutput.includes('Current session') ||
|
||||
(cleanOutput.includes('Usage') && cleanOutput.includes('% left')))
|
||||
) {
|
||||
// Also check for percentage patterns that appear in usage output
|
||||
const hasUsageIndicators =
|
||||
cleanOutput.includes('Current session') ||
|
||||
(cleanOutput.includes('Usage') && cleanOutput.includes('% left')) ||
|
||||
// Additional patterns for winpty - look for percentage patterns
|
||||
/\d+%\s*(left|used|remaining)/i.test(cleanOutput) ||
|
||||
cleanOutput.includes('Resets in') ||
|
||||
cleanOutput.includes('Current week');
|
||||
|
||||
if (!hasSeenUsageData && hasUsageIndicators) {
|
||||
hasSeenUsageData = true;
|
||||
// Wait for full output, then send escape to exit
|
||||
setTimeout(() => {
|
||||
@@ -324,10 +400,18 @@ export class ClaudeUsageService {
|
||||
}
|
||||
|
||||
// Detect REPL prompt and send /usage command
|
||||
if (
|
||||
!hasSentCommand &&
|
||||
(cleanOutput.includes('❯') || cleanOutput.includes('? for shortcuts'))
|
||||
) {
|
||||
// On Windows with winpty, Unicode prompt char ❯ gets garbled, so also check for ASCII indicators
|
||||
const isReplReady =
|
||||
cleanOutput.includes('❯') ||
|
||||
cleanOutput.includes('? for shortcuts') ||
|
||||
// Fallback for winpty garbled encoding - detect CLI welcome screen elements
|
||||
(cleanOutput.includes('Welcome back') && cleanOutput.includes('Claude')) ||
|
||||
(cleanOutput.includes('Tips for getting started') && cleanOutput.includes('Claude')) ||
|
||||
// Detect model indicator which appears when REPL is ready
|
||||
(cleanOutput.includes('Opus') && cleanOutput.includes('Claude API')) ||
|
||||
(cleanOutput.includes('Sonnet') && cleanOutput.includes('Claude API'));
|
||||
|
||||
if (!hasSentCommand && isReplReady) {
|
||||
hasSentCommand = true;
|
||||
// Wait for REPL to fully settle
|
||||
setTimeout(() => {
|
||||
@@ -364,11 +448,9 @@ export class ClaudeUsageService {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
|
||||
if (
|
||||
output.includes('token_expired') ||
|
||||
output.includes('authentication_error') ||
|
||||
output.includes('permission_error')
|
||||
) {
|
||||
// Check for auth errors - must be specific to avoid false positives
|
||||
// Removed permission_error check as it was causing false positives with winpty encoding
|
||||
if (output.includes('token_expired') || output.includes('"type":"authentication_error"')) {
|
||||
reject(new Error("Authentication required - please run 'claude login'"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -71,6 +71,12 @@ export class TerminalService extends EventEmitter {
|
||||
private dataCallbacks: Set<DataCallback> = new Set();
|
||||
private exitCallbacks: Set<ExitCallback> = new Set();
|
||||
private isWindows = os.platform() === 'win32';
|
||||
// On Windows, ConPTY requires AttachConsole which fails in Electron/service mode
|
||||
// Detect Electron by checking for electron-specific env vars or process properties
|
||||
private isElectron =
|
||||
!!(process.versions && (process.versions as Record<string, string>).electron) ||
|
||||
!!process.env.ELECTRON_RUN_AS_NODE;
|
||||
private useConptyFallback = false; // Track if we need to use winpty fallback on Windows
|
||||
|
||||
/**
|
||||
* Kill a PTY process with platform-specific handling.
|
||||
@@ -339,13 +345,60 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`);
|
||||
|
||||
const ptyProcess = pty.spawn(shell, shellArgs, {
|
||||
// Build PTY spawn options
|
||||
const ptyOptions: pty.IPtyForkOptions = {
|
||||
name: 'xterm-256color',
|
||||
cols: options.cols || 80,
|
||||
rows: options.rows || 24,
|
||||
cwd,
|
||||
env,
|
||||
});
|
||||
};
|
||||
|
||||
// On Windows, always use winpty instead of ConPTY
|
||||
// ConPTY requires AttachConsole which fails in many contexts:
|
||||
// - Electron apps without a console
|
||||
// - VS Code integrated terminal
|
||||
// - Spawned from other applications
|
||||
// The error happens in a subprocess so we can't catch it - must proactively disable
|
||||
if (this.isWindows) {
|
||||
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
|
||||
logger.info(
|
||||
`[createSession] Using winpty for session ${id} (ConPTY disabled for compatibility)`
|
||||
);
|
||||
}
|
||||
|
||||
let ptyProcess: pty.IPty;
|
||||
try {
|
||||
ptyProcess = pty.spawn(shell, shellArgs, ptyOptions);
|
||||
} catch (spawnError) {
|
||||
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
|
||||
|
||||
// Check for Windows ConPTY-specific errors
|
||||
if (this.isWindows && errorMessage.includes('AttachConsole failed')) {
|
||||
// ConPTY failed - try winpty fallback
|
||||
if (!this.useConptyFallback) {
|
||||
logger.warn(`[createSession] ConPTY AttachConsole failed, retrying with winpty fallback`);
|
||||
this.useConptyFallback = true;
|
||||
|
||||
try {
|
||||
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
|
||||
ptyProcess = pty.spawn(shell, shellArgs, ptyOptions);
|
||||
logger.info(`[createSession] Successfully spawned session ${id} with winpty fallback`);
|
||||
} catch (fallbackError) {
|
||||
const fallbackMessage =
|
||||
fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
|
||||
logger.error(`[createSession] Winpty fallback also failed:`, fallbackMessage);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
logger.error(`[createSession] PTY spawn failed (winpty):`, errorMessage);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
logger.error(`[createSession] PTY spawn failed:`, errorMessage);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const session: TerminalSession = {
|
||||
id,
|
||||
@@ -409,7 +462,11 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
// Handle exit
|
||||
ptyProcess.onExit(({ exitCode }) => {
|
||||
logger.info(`Session ${id} exited with code ${exitCode}`);
|
||||
const exitMessage =
|
||||
exitCode === undefined || exitCode === null
|
||||
? 'Session terminated'
|
||||
: `Session exited with code ${exitCode}`;
|
||||
logger.info(`${exitMessage} (${id})`);
|
||||
this.sessions.delete(id);
|
||||
this.exitCallbacks.forEach((cb) => cb(id, exitCode));
|
||||
this.emit('exit', id, exitCode);
|
||||
|
||||
Reference in New Issue
Block a user