Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 60 additions & 25 deletions Packs/pai-hook-system/src/hooks/SetQuestionTab.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down
5 changes: 3 additions & 2 deletions Packs/pai-hook-system/src/hooks/StopOrchestrator.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
7 changes: 7 additions & 0 deletions Packs/pai-hook-system/src/hooks/handlers/SystemIntegrity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ async function notifyIntegrityStart(): Promise<void> {
// 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' },
Expand All @@ -62,7 +66,10 @@ async function notifyIntegrityStart(): Promise<void> {
voice_enabled: true,
priority: 'low',
}),
signal: controller.signal,
});

clearTimeout(timeoutId);
} catch {
// Voice server might not be running - silent fail
}
Expand Down
119 changes: 107 additions & 12 deletions Packs/pai-hook-system/src/hooks/handlers/tab-state.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
*/
Expand All @@ -33,13 +70,12 @@ export async function handleTabState(parsed: ParsedTranscript): Promise<void> {

// 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
Expand All @@ -54,14 +90,73 @@ export async function handleTabState(parsed: ParsedTranscript): Promise<void> {
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<void> {
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}`);
}
}
7 changes: 7 additions & 0 deletions Packs/pai-hook-system/src/hooks/handlers/voice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading
Loading