Fix cloudflared detection in packaged app + minor UX improvements

- Fix cloudflared not being found when running from /Applications by using
  expanded PATH (includes /opt/homebrew/bin, /usr/local/bin, etc.) in
  cliDetection.ts, matching how agent-detector.ts handles binary detection
- Cache and use full cloudflared path in tunnel-manager.ts to ensure the
  binary is found when spawning the tunnel process
- Add debugging logs for process exit events to help diagnose thinking pill
  issues (App.tsx)
- Add hint about Cmd+/ shortcut in settings modal shortcuts section
- Add Cmd+U shortcut hint to tab filter tooltip

Claude ID: 110ec97f-d876-4fb9-b577-ee7a7cd4f9e5
Maestro ID: b9bc0d08-5be2-4fdf-93cd-5618a8d53b35
This commit is contained in:
Pedram Amini
2025-12-03 16:25:28 -06:00
parent a6663adbd1
commit ad9591f274
5 changed files with 106 additions and 7 deletions

View File

@@ -1,5 +1,6 @@
import { ChildProcess, spawn } from 'child_process';
import { logger } from './utils/logger';
import { getCloudflaredPath, isCloudflaredInstalled } from './utils/cliDetection';
export interface TunnelStatus {
isRunning: boolean;
@@ -27,10 +28,18 @@ class TunnelManager {
// Stop any existing tunnel first
await this.stop();
return new Promise((resolve) => {
logger.info(`Starting cloudflared tunnel for port ${port}`, 'TunnelManager');
// Ensure cloudflared is installed and get its path
const installed = await isCloudflaredInstalled();
if (!installed) {
return { success: false, error: 'cloudflared is not installed' };
}
this.process = spawn('cloudflared', [
const cloudflaredBinary = getCloudflaredPath() || 'cloudflared';
return new Promise((resolve) => {
logger.info(`Starting cloudflared tunnel for port ${port} using ${cloudflaredBinary}`, 'TunnelManager');
this.process = spawn(cloudflaredBinary, [
'tunnel', '--url', `http://localhost:${port}`
]);

View File

@@ -1,6 +1,42 @@
import { execFileNoThrow } from './execFile';
import * as os from 'os';
let cloudflaredInstalledCache: boolean | null = null;
let cloudflaredPathCache: string | null = null;
/**
* Build an expanded PATH that includes common binary installation locations.
* This is necessary because packaged Electron apps don't inherit shell environment.
*/
function getExpandedEnv(): NodeJS.ProcessEnv {
const home = os.homedir();
const env = { ...process.env };
const additionalPaths = [
'/opt/homebrew/bin', // Homebrew on Apple Silicon
'/opt/homebrew/sbin',
'/usr/local/bin', // Homebrew on Intel, common install location
'/usr/local/sbin',
`${home}/.local/bin`, // User local installs
`${home}/bin`, // User bin directory
'/usr/bin',
'/bin',
'/usr/sbin',
'/sbin',
];
const currentPath = env.PATH || '';
const pathParts = currentPath.split(':');
for (const p of additionalPaths) {
if (!pathParts.includes(p)) {
pathParts.unshift(p);
}
}
env.PATH = pathParts.join(':');
return env;
}
export async function isCloudflaredInstalled(): Promise<boolean> {
// Return cached result if available
@@ -10,12 +46,23 @@ export async function isCloudflaredInstalled(): Promise<boolean> {
// Use 'which' on macOS/Linux, 'where' on Windows
const command = process.platform === 'win32' ? 'where' : 'which';
const result = await execFileNoThrow(command, ['cloudflared']);
cloudflaredInstalledCache = result.exitCode === 0;
const env = getExpandedEnv();
const result = await execFileNoThrow(command, ['cloudflared'], undefined, env);
if (result.exitCode === 0 && result.stdout.trim()) {
cloudflaredInstalledCache = true;
cloudflaredPathCache = result.stdout.trim().split('\n')[0];
} else {
cloudflaredInstalledCache = false;
}
return cloudflaredInstalledCache;
}
export function getCloudflaredPath(): string | null {
return cloudflaredPathCache;
}
export function clearCloudflaredCache(): void {
cloudflaredInstalledCache = null;
}

View File

@@ -669,7 +669,14 @@ export default function MaestroConsole() {
});
// Handle process exit
const unsubscribeExit = window.maestro.process.onExit((sessionId: string, code: number) => {
const unsubscribeExit = window.maestro.process.onExit(async (sessionId: string, code: number) => {
// Log all exit events to help diagnose thinking pill disappearing prematurely
console.log('[onExit] Process exit event received:', {
rawSessionId: sessionId,
exitCode: code,
timestamp: new Date().toISOString()
});
// Parse sessionId to determine which process exited
// Format: {id}-ai-{tabId}, {id}-terminal, {id}-batch-{timestamp}
let actualSessionId: string;
@@ -693,6 +700,26 @@ export default function MaestroConsole() {
isFromAi = false;
}
// SAFETY CHECK: Verify the process is actually gone before transitioning to idle
// This prevents the thinking pill from disappearing while the process is still running
// (which can happen if we receive a stale/duplicate exit event)
if (isFromAi) {
try {
const activeProcesses = await window.maestro.process.getActiveProcesses();
const processStillRunning = activeProcesses.some(p => p.sessionId === sessionId);
if (processStillRunning) {
console.warn('[onExit] Process still running despite exit event, ignoring:', {
sessionId,
activeProcesses: activeProcesses.map(p => p.sessionId)
});
return;
}
} catch (error) {
console.error('[onExit] Failed to verify process status:', error);
// Continue with exit handling if we can't verify - better than getting stuck
}
}
// For AI exits, gather toast data BEFORE state update to avoid side effects in updater
// React 18 StrictMode may call state updater functions multiple times
let toastData: {
@@ -910,6 +937,18 @@ export default function MaestroConsole() {
const newState = anyTabStillBusy ? 'busy' as SessionState : 'idle' as SessionState;
const newBusySource = anyTabStillBusy ? s.busySource : undefined;
// Log state transition for debugging thinking pill issues
console.log('[onExit] Session state transition:', {
sessionId: s.id.substring(0, 8),
tabIdFromSession: tabIdFromSession?.substring(0, 8),
previousState: s.state,
newState,
previousBusySource: s.busySource,
newBusySource,
anyTabStillBusy,
tabStates: updatedAiTabs.map(t => ({ id: t.id.substring(0, 8), state: t.state }))
});
// Task complete - also clear pending AI command flag
return {
...s,

View File

@@ -1273,6 +1273,9 @@ export function SettingsModal(props: SettingsModalProps) {
{shortcutsFilter ? `${filteredCount} / ${totalShortcuts}` : totalShortcuts}
</span>
</div>
<p className="text-xs opacity-50 mb-3" style={{ color: theme.colors.textDim }}>
Not all shortcuts can be modified. Press <kbd className="px-1.5 py-0.5 rounded font-mono" style={{ backgroundColor: theme.colors.bgActivity }}>/</kbd> to view the full list of keyboard shortcuts.
</p>
<div className="space-y-2 flex-1 overflow-y-auto pr-2 scrollbar-thin">
{filteredShortcuts.map((sc: Shortcut) => (
<div key={sc.id} className="flex items-center justify-between p-3 rounded border" style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgMain }}>

View File

@@ -651,7 +651,8 @@ export function TabBar({
zIndex: 6
}}
>
{showUnreadOnly ? 'Showing unread only' : 'Filter unread tabs'}
{showUnreadOnly ? 'Showing unread only' : 'Filter unread tabs'}{' '}
<span style={{ color: theme.colors.textDim }}>(Cmd+U)</span>
</div>
</div>