diff --git a/Packs/pai-hook-system/src/hooks/SetQuestionTab.hook.ts b/Packs/pai-hook-system/src/hooks/SetQuestionTab.hook.ts index 3370214e8..109d946b3 100755 --- a/Packs/pai-hook-system/src/hooks/SetQuestionTab.hook.ts +++ b/Packs/pai-hook-system/src/hooks/SetQuestionTab.hook.ts @@ -10,37 +10,28 @@ * * TRIGGER: PreToolUse (matcher: AskUserQuestion) * - * INPUT: - * - None (triggered by tool match, no stdin processing) + * TERMINAL DETECTION: + * Uses the same pattern as UpdateTabTitle.hook.ts: + * - Kitty: Uses remote control API for colors + title + * - Other terminals: Falls back to escape codes for basic title support * - * OUTPUT: - * - stdout: None - * - stderr: Status message - * - exit(0): Always (non-blocking) - * - * SIDE EFFECTS: - * - Kitty remote control: Sets tab color to teal (#085050) - * - * INTER-HOOK RELATIONSHIPS: - * - DEPENDS ON: None - * - COORDINATES WITH: UpdateTabTitle (shares tab color management) - * - MUST RUN BEFORE: None - * - MUST RUN AFTER: UpdateTabTitle (overrides working color when asking) - * - * TAB COLOR SCHEME (inactive tab only - active tab stays dark blue): + * TAB COLOR SCHEME (Kitty only - inactive tab): * - Dark teal (#085050): Waiting for user input (this hook) * - Dark orange (#804000): Actively working (UpdateTabTitle) * - Dark purple (#1E0A3C): AI inference/thinking (UpdateTabTitle) * - Dark blue (#002B80): Active tab always uses this * * ERROR HANDLING: - * - Kitty unavailable: Silent failure (other terminals not supported) + * - Kitty unavailable: Falls back to escape codes + * - All errors: Silent failure (non-blocking) * * PERFORMANCE: * - Non-blocking: Yes * - Typical execution: <50ms */ +import { runWithTimeout } from '../lib/subprocess'; + const TAB_AWAITING_BG = '#085050'; // Dark teal - waiting for user input const ACTIVE_TAB_BG = '#002B80'; // Dark blue - active tab always const TAB_TEXT = '#FFFFFF'; @@ -49,18 +40,62 @@ const INACTIVE_TEXT = '#A0A0A0'; // Simple question indicator - teal background does the work const QUESTION_TITLE = '❓ Question'; +// Timeout for Kitty commands +const KITTY_COMMAND_TIMEOUT_MS = 2000; + +/** + * Check if we're running in Kitty terminal with remote control available. + */ +function isKittyTerminal(): boolean { + return process.env.TERM === 'xterm-kitty' || !!process.env.KITTY_LISTEN_ON; +} + +/** + * Set tab title using escape codes (works in most terminals). + */ +function setTabTitleEscapeCode(title: string): void { + process.stderr.write(`\x1b]0;${title}\x07`); + process.stderr.write(`\x1b]2;${title}\x07`); +} + async function main() { try { - // Set tab color: active stays dark blue, inactive shows teal - await Bun.$`kitten @ set-tab-color --self active_bg=${ACTIVE_TAB_BG} active_fg=${TAB_TEXT} inactive_bg=${TAB_AWAITING_BG} inactive_fg=${INACTIVE_TEXT}`; + if (isKittyTerminal()) { + // Kitty: Use remote control with subprocess timeout protection + const colorResult = await runWithTimeout( + [ + 'kitten', '@', 'set-tab-color', '--self', + `active_bg=${ACTIVE_TAB_BG}`, + `active_fg=${TAB_TEXT}`, + `inactive_bg=${TAB_AWAITING_BG}`, + `inactive_fg=${INACTIVE_TEXT}`, + ], + KITTY_COMMAND_TIMEOUT_MS, + { stderr: 'pipe' } + ); - // Set simple question title - teal background provides visual distinction - await Bun.$`kitty @ set-tab-title ${QUESTION_TITLE}`; + if (colorResult.success) { + const titleResult = await runWithTimeout( + ['kitty', '@', 'set-tab-title', QUESTION_TITLE], + KITTY_COMMAND_TIMEOUT_MS, + { stderr: 'pipe' } + ); - console.error('[SetQuestionTab] Tab set to teal with question indicator'); + if (titleResult.success) { + console.error('[SetQuestionTab] Kitty tab set to teal with question indicator'); + } + } else { + console.error('[SetQuestionTab] Kitty remote control failed, falling back to escape codes'); + setTabTitleEscapeCode(QUESTION_TITLE); + } + } else { + // Other terminals: Use escape codes for basic title support + setTabTitleEscapeCode(QUESTION_TITLE); + console.error('[SetQuestionTab] Set title via escape codes'); + } } catch (error) { - // Silently fail if kitty remote control is not available - console.error('[SetQuestionTab] Kitty remote control unavailable'); + // Silently fail - tab state is non-critical + console.error('[SetQuestionTab] Error:', error); } process.exit(0); diff --git a/Packs/pai-hook-system/src/hooks/StopOrchestrator.hook.ts b/Packs/pai-hook-system/src/hooks/StopOrchestrator.hook.ts index a4a11ad79..7f55c8dc0 100755 --- a/Packs/pai-hook-system/src/hooks/StopOrchestrator.hook.ts +++ b/Packs/pai-hook-system/src/hooks/StopOrchestrator.hook.ts @@ -108,16 +108,17 @@ async function main() { console.error(`[StopOrchestrator] Parsed transcript: ${parsed.plainCompletion.slice(0, 50)}...`); // Run handlers with pre-parsed data (isolated failures) + // TabState re-enabled with Bun.spawn() subprocess control for proper timeout/kill handling const results = await Promise.allSettled([ handleVoice(parsed, hookInput.session_id), handleCapture(parsed, hookInput), - handleTabState(parsed), + handleTabState(parsed), // Re-enabled: uses runWithTimeout() for subprocess control handleSystemIntegrity(parsed, hookInput), ]); // Log any failures + const handlerNames = ['Voice', 'Capture', 'TabState', 'SystemIntegrity']; results.forEach((result, index) => { - const handlerNames = ['Voice', 'Capture', 'TabState', 'SystemIntegrity']; if (result.status === 'rejected') { console.error(`[StopOrchestrator] ${handlerNames[index]} handler failed:`, result.reason); } diff --git a/Packs/pai-hook-system/src/hooks/handlers/SystemIntegrity.ts b/Packs/pai-hook-system/src/hooks/handlers/SystemIntegrity.ts index 34d859ce7..1898136a8 100755 --- a/Packs/pai-hook-system/src/hooks/handlers/SystemIntegrity.ts +++ b/Packs/pai-hook-system/src/hooks/handlers/SystemIntegrity.ts @@ -54,6 +54,10 @@ async function notifyIntegrityStart(): Promise { // Wait 4 seconds for main voice handler to finish speaking await new Promise(resolve => setTimeout(resolve, 4000)); + // Create AbortController with 2-second timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 2000); + await fetch('http://localhost:8888/notify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -62,7 +66,10 @@ async function notifyIntegrityStart(): Promise { voice_enabled: true, priority: 'low', }), + signal: controller.signal, }); + + clearTimeout(timeoutId); } catch { // Voice server might not be running - silent fail } diff --git a/Packs/pai-hook-system/src/hooks/handlers/tab-state.ts b/Packs/pai-hook-system/src/hooks/handlers/tab-state.ts index 5e6b5ddde..6d112428c 100755 --- a/Packs/pai-hook-system/src/hooks/handlers/tab-state.ts +++ b/Packs/pai-hook-system/src/hooks/handlers/tab-state.ts @@ -1,14 +1,28 @@ /** - * tab-state.ts - Kitty tab state handler + * tab-state.ts - Terminal tab state handler * - * Pure handler: receives pre-parsed transcript data, updates Kitty tab. + * Pure handler: receives pre-parsed transcript data, updates terminal tab. * No I/O for transcript reading - that's done by orchestrator. + * + * TERMINAL DETECTION: + * Uses the same pattern as UpdateTabTitle.hook.ts: + * - Kitty: Uses remote control API with subprocess timeout protection + * - Other terminals: Falls back to escape codes for basic title support + * + * SUBPROCESS CONTROL (Kitty only): + * Uses runWithTimeout() instead of Bun.$`` to enable killing hung subprocesses. + * The kitten/kitty commands can hang indefinitely when Kitty is unresponsive, + * blocking terminal I/O. With explicit subprocess control, we can: + * 1. Set a 2-second timeout + * 2. Kill the process with SIGTERM if it exceeds timeout + * 3. Force kill with SIGKILL if SIGTERM doesn't work */ import { isValidVoiceCompletion, getTabFallback } from '../lib/response-format'; +import { runWithTimeout } from '../../lib/subprocess'; import type { ParsedTranscript, ResponseState } from '../../skills/CORE/Tools/TranscriptParser'; -// Tab color states for visual feedback (inactive tab only - active tab stays dark blue) +// Tab color states for visual feedback (Kitty only - inactive tab) const TAB_COLORS = { awaitingInput: '#0D6969', // Dark teal - needs input completed: '#022800', // Very dark green - success @@ -25,6 +39,29 @@ const ACTIVE_TAB_COLOR = '#002B80'; // Dark blue const ACTIVE_TEXT_COLOR = '#FFFFFF'; const INACTIVE_TEXT_COLOR = '#A0A0A0'; +// Timeout for Kitty commands (2 seconds should be more than enough) +const KITTY_COMMAND_TIMEOUT_MS = 2000; + +/** + * Check if we're running in Kitty terminal with remote control available. + * Same pattern as UpdateTabTitle.hook.ts. + */ +function isKittyTerminal(): boolean { + return process.env.TERM === 'xterm-kitty' || !!process.env.KITTY_LISTEN_ON; +} + +/** + * Set tab title using escape codes (works in most terminals). + * This is the fallback for non-Kitty terminals. + */ +function setTabTitleEscapeCode(title: string): void { + // OSC 0 - Set icon name and window title + // OSC 2 - Set window title + // These work in iTerm2, Terminal.app, and most other terminals + process.stderr.write(`\x1b]0;${title}\x07`); + process.stderr.write(`\x1b]2;${title}\x07`); +} + /** * Handle tab state update with pre-parsed transcript data. */ @@ -33,13 +70,12 @@ export async function handleTabState(parsed: ParsedTranscript): Promise { // Validate completion if (!isValidVoiceCompletion(plainCompletion)) { - console.error(`[TabState] Invalid completion: "${plainCompletion.slice(0, 50)}..."`); + console.error(`[TabState] Invalid completion, using fallback`); plainCompletion = getTabFallback('end'); } try { const state: ResponseState = parsed.responseState; - const stateColor = TAB_COLORS[state]; const suffix = TAB_SUFFIXES[state]; // Truncate title for tab readability @@ -54,14 +90,73 @@ export async function handleTabState(parsed: ParsedTranscript): Promise { const statePrefix = state === 'completed' ? '✓' : state === 'error' ? '⚠' : ''; const tabTitle = `${statePrefix}${shortTitle}${suffix}`; - console.error(`[TabState] State: ${state}, Color: ${stateColor}, Suffix: "${suffix}"`); + // Terminal-specific handling + if (isKittyTerminal()) { + // Kitty: Use remote control with subprocess timeout protection + await handleKittyTabState(tabTitle, state); + } else { + // Other terminals: Use escape codes for basic title support + // Note: Tab colors not supported in escape codes, only title + setTabTitleEscapeCode(tabTitle); + console.error(`[TabState] Set title via escape codes: "${tabTitle}"`); + } + } catch (error) { + console.error('[TabState] Failed to update tab:', error); + // Don't re-throw - tab state is non-critical, shouldn't crash orchestrator + } +} - // Set tab colors: active tab always dark blue, inactive shows state color - await Bun.$`kitten @ set-tab-color --self active_bg=${ACTIVE_TAB_COLOR} active_fg=${ACTIVE_TEXT_COLOR} inactive_bg=${stateColor} inactive_fg=${INACTIVE_TEXT_COLOR}`; +/** + * Handle Kitty-specific tab state with subprocess control. + * Uses runWithTimeout to prevent hanging on unresponsive Kitty. + */ +async function handleKittyTabState(tabTitle: string, state: ResponseState): Promise { + const stateColor = TAB_COLORS[state]; - // Set tab title - await Bun.$`kitty @ set-tab-title ${tabTitle}`; - } catch (error) { - console.error('[TabState] Failed to update Kitty tab:', error); + console.error(`[TabState] Kitty: State=${state}, Color=${stateColor}`); + + // Set tab colors with timeout protection + const colorResult = await runWithTimeout( + [ + 'kitten', '@', 'set-tab-color', '--self', + `active_bg=${ACTIVE_TAB_COLOR}`, + `active_fg=${ACTIVE_TEXT_COLOR}`, + `inactive_bg=${stateColor}`, + `inactive_fg=${INACTIVE_TEXT_COLOR}`, + ], + KITTY_COMMAND_TIMEOUT_MS, + { stderr: 'pipe' } + ); + + if (!colorResult.success) { + if (colorResult.timedOut) { + console.error(`[TabState] set-tab-color timed out after ${KITTY_COMMAND_TIMEOUT_MS}ms (process killed)`); + } else { + console.error(`[TabState] set-tab-color failed (exit ${colorResult.exitCode}): ${colorResult.stderr}`); + } + // Don't throw - tab colors are non-critical, continue to try title + } + + // Set tab title with timeout protection + const titleResult = await runWithTimeout( + ['kitty', '@', 'set-tab-title', tabTitle], + KITTY_COMMAND_TIMEOUT_MS, + { stderr: 'pipe' } + ); + + if (!titleResult.success) { + if (titleResult.timedOut) { + console.error(`[TabState] set-tab-title timed out after ${KITTY_COMMAND_TIMEOUT_MS}ms (process killed)`); + } else { + console.error(`[TabState] set-tab-title failed (exit ${titleResult.exitCode}): ${titleResult.stderr}`); + } + // Don't throw - tab title is non-critical + } + + // Log success + if (colorResult.success && titleResult.success) { + console.error(`[TabState] Updated Kitty tab: "${tabTitle}" with color ${stateColor}`); + } else if (colorResult.success || titleResult.success) { + console.error(`[TabState] Partial update: color=${colorResult.success}, title=${titleResult.success}`); } } diff --git a/Packs/pai-hook-system/src/hooks/handlers/voice.ts b/Packs/pai-hook-system/src/hooks/handlers/voice.ts index 226e15132..6505dfe05 100755 --- a/Packs/pai-hook-system/src/hooks/handlers/voice.ts +++ b/Packs/pai-hook-system/src/hooks/handlers/voice.ts @@ -85,12 +85,19 @@ async function sendNotification(payload: NotificationPayload, sessionId: string) }; try { + // Create AbortController with 2-second timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 2000); + const response = await fetch('http://localhost:8888/notify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), + signal: controller.signal, }); + clearTimeout(timeoutId); + if (!response.ok) { console.error('[Voice] Server error:', response.statusText); logVoiceEvent({ diff --git a/Packs/pai-hook-system/src/lib/subprocess.ts b/Packs/pai-hook-system/src/lib/subprocess.ts new file mode 100644 index 000000000..3f2263fa1 --- /dev/null +++ b/Packs/pai-hook-system/src/lib/subprocess.ts @@ -0,0 +1,118 @@ +/** + * subprocess.ts - Subprocess utility with explicit timeout and kill control + * + * PURPOSE: + * Unlike Bun.$`...` which doesn't expose the process handle, this uses + * Bun.spawn() to enable killing hung subprocesses. This is critical for + * commands like `kitten @ set-tab-color` that can hang indefinitely when + * Kitty is unresponsive. + * + * PROBLEM SOLVED: + * Promise.race() timeout abandons the promise but does NOT kill the subprocess. + * The subprocess continues running in the background, blocking terminal I/O. + * This utility actually kills the process with SIGTERM → SIGKILL fallback. + * + * USAGE: + * ```typescript + * const result = await runWithTimeout( + * ['kitten', '@', 'set-tab-color', '--self', 'active_bg=#002B80'], + * 2000 // 2 second timeout + * ); + * if (!result.success) { + * console.error('Command failed or timed out:', result.stderr); + * } + * ``` + */ + +export interface SubprocessResult { + success: boolean; + exitCode: number; + timedOut: boolean; + stdout?: string; + stderr?: string; +} + +/** + * Run a command with explicit timeout and subprocess killing. + * + * @param cmd - Command and arguments as array (e.g., ['kitten', '@', 'set-tab-color']) + * @param timeoutMs - Maximum time to wait before killing the process + * @param options - Optional stdout/stderr handling + * @returns Result with success status, exit code, and captured output + */ +export async function runWithTimeout( + cmd: string[], + timeoutMs: number, + options?: { + stdout?: 'inherit' | 'pipe' | 'ignore'; + stderr?: 'inherit' | 'pipe' | 'ignore'; + } +): Promise { + const proc = Bun.spawn(cmd, { + stdout: options?.stdout ?? 'ignore', + stderr: options?.stderr ?? 'pipe', + }); + + let timedOut = false; + let forceKillTimeout: ReturnType | null = null; + + const timeoutId = setTimeout(() => { + console.error(`[subprocess] Killing process after ${timeoutMs}ms timeout: ${cmd.join(' ')}`); + timedOut = true; + proc.kill('SIGTERM'); + + // Force kill if SIGTERM doesn't work within 1s + forceKillTimeout = setTimeout(() => { + try { + proc.kill('SIGKILL'); + console.error(`[subprocess] Force killed with SIGKILL: ${cmd.join(' ')}`); + } catch { + // Process may have already exited + } + }, 1000); + }, timeoutMs); + + try { + const exitCode = await proc.exited; + clearTimeout(timeoutId); + if (forceKillTimeout) clearTimeout(forceKillTimeout); + + // Read stderr if piped + let stderr: string | undefined; + if (options?.stderr === 'pipe' && proc.stderr) { + try { + stderr = await new Response(proc.stderr).text(); + } catch { + // Ignore read errors + } + } + + // Read stdout if piped + let stdout: string | undefined; + if (options?.stdout === 'pipe' && proc.stdout) { + try { + stdout = await new Response(proc.stdout).text(); + } catch { + // Ignore read errors + } + } + + return { + success: !timedOut && exitCode === 0, + exitCode: timedOut ? -1 : exitCode, + timedOut, + stdout, + stderr, + }; + } catch (error) { + clearTimeout(timeoutId); + if (forceKillTimeout) clearTimeout(forceKillTimeout); + + return { + success: false, + exitCode: -1, + timedOut, + stderr: error instanceof Error ? error.message : String(error), + }; + } +}