diff --git a/src/__tests__/main/ipc/handlers/tabNaming.test.ts b/src/__tests__/main/ipc/handlers/tabNaming.test.ts index b93836fee..0e2788dc8 100644 --- a/src/__tests__/main/ipc/handlers/tabNaming.test.ts +++ b/src/__tests__/main/ipc/handlers/tabNaming.test.ts @@ -59,7 +59,7 @@ vi.mock('../../../../main/utils/ssh-remote-resolver', () => ({ })); vi.mock('../../../../main/utils/ssh-command-builder', () => ({ - buildSshCommand: vi.fn(), + buildSshCommandWithStdin: vi.fn(), })); // Capture registered handlers @@ -479,9 +479,10 @@ describe('Tab Naming IPC Handlers', () => { expect(result).toBeNull(); }); - it('filters out lines starting with quotes (example inputs)', async () => { - // Lines starting with quotes are filtered as they typically represent - // example inputs in the prompt, not actual tab names + it('unquotes fully-wrapped quoted output (e.g. agent wraps name in quotes)', async () => { + // extractTabName accepts fully-wrapped quoted strings and strips the quotes. + // A line like `"Quoted Tab Name"` is kept (isWrappedQuoted = true) and + // returned as 'Quoted Tab Name' — only partially-quoted lines are discarded. let onDataCallback: ((sessionId: string, data: string) => void) | undefined; let onExitCallback: ((sessionId: string) => void) | undefined; @@ -502,13 +503,13 @@ describe('Tab Naming IPC Handlers', () => { expect(mockProcessManager.spawn).toHaveBeenCalled(); }); - // Simulate output with quotes - lines starting with " are filtered + // Fully-wrapped quoted output: the agent wraps the tab name in quotes. + // extractTabName unquotes and returns the inner string. onDataCallback?.('tab-naming-mock-uuid-1234', '"Quoted Tab Name"'); onExitCallback?.('tab-naming-mock-uuid-1234'); const result = await resultPromise; - // Lines starting with quotes are filtered out as example inputs - expect(result).toBeNull(); + expect(result).toBe('Quoted Tab Name'); }); it('removes trailing quotes from tab names', async () => { @@ -543,7 +544,8 @@ describe('Tab Naming IPC Handlers', () => { it('uses stdin for prompt when SSH remote is configured', async () => { // Import and mock the SSH utilities const { getSshRemoteConfig } = await import('../../../../main/utils/ssh-remote-resolver'); - const { buildSshCommand } = await import('../../../../main/utils/ssh-command-builder'); + const { buildSshCommandWithStdin } = + await import('../../../../main/utils/ssh-command-builder'); // Mock SSH config resolution to return a valid config (getSshRemoteConfig as Mock).mockReturnValue({ @@ -555,25 +557,13 @@ describe('Tab Naming IPC Handlers', () => { source: 'session', }); - // Mock buildSshCommand to return SSH-wrapped command - (buildSshCommand as Mock).mockResolvedValue({ + // Mock buildSshCommandWithStdin to return SSH-wrapped command + (buildSshCommandWithStdin as Mock).mockResolvedValue({ command: '/usr/bin/ssh', - args: [ - '-o', - 'BatchMode=yes', - 'test.example.com', - 'claude --print --input-format stream-json', - ], - }); - - // Update mock agent to support stream-json input - const mockAgentWithStreamJson: AgentConfig = { - ...mockClaudeAgent, - capabilities: { - supportsStreamJsonInput: true, - }, - }; - mockAgentDetector.getAgent.mockResolvedValue(mockAgentWithStreamJson); + args: ['-o', 'BatchMode=yes', 'test.example.com', '/bin/bash'], + stdinScript: + 'export PATH=...\nexec claude run --format json\nHelp me with SSH remote feature', + }); let onDataCallback: ((sessionId: string, data: string) => void) | undefined; let onExitCallback: ((sessionId: string) => void) | undefined; @@ -599,18 +589,18 @@ describe('Tab Naming IPC Handlers', () => { expect(mockProcessManager.spawn).toHaveBeenCalled(); }); - // Verify spawn was called with sendPromptViaStdin flag + // Verify spawn was called with sshStdinScript expect(mockProcessManager.spawn).toHaveBeenCalledWith( expect.objectContaining({ - sendPromptViaStdin: true, + sshStdinScript: expect.any(String), }) ); - // Verify buildSshCommand was called with useStdin option - expect(buildSshCommand).toHaveBeenCalledWith( + // Verify buildSshCommandWithStdin was called with stdinInput (the prompt) + expect(buildSshCommandWithStdin).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ - useStdin: true, + stdinInput: expect.any(String), }) ); diff --git a/src/main/agents/definitions.ts b/src/main/agents/definitions.ts index de256fc91..99a7249bd 100644 --- a/src/main/agents/definitions.ts +++ b/src/main/agents/definitions.ts @@ -97,6 +97,8 @@ export interface AgentConfig { noPromptSeparator?: boolean; // If true, don't add '--' before the prompt in batch mode (OpenCode doesn't support it) defaultEnvVars?: Record; // Default environment variables for this agent (merged with user customEnvVars) readOnlyEnvOverrides?: Record; // Env var overrides applied in read-only mode (replaces keys from defaultEnvVars) + // Optional default model id discovered from the agent's local config or binary + defaultModel?: string; } /** diff --git a/src/main/ipc/handlers/tabNaming.ts b/src/main/ipc/handlers/tabNaming.ts index 6eeb2b248..8507e49e3 100644 --- a/src/main/ipc/handlers/tabNaming.ts +++ b/src/main/ipc/handlers/tabNaming.ts @@ -20,7 +20,7 @@ import { } from '../../utils/ipcHandler'; import { buildAgentArgs, applyAgentConfigOverrides } from '../../utils/agent-args'; import { getSshRemoteConfig, createSshRemoteStoreAdapter } from '../../utils/ssh-remote-resolver'; -import { buildSshCommand } from '../../utils/ssh-command-builder'; +import { buildSshCommandWithStdin } from '../../utils/ssh-command-builder'; import { tabNamingPrompt } from '../../../prompts'; import type { ProcessManager } from '../../process-manager'; import type { AgentDetector } from '../../agents'; @@ -28,6 +28,15 @@ import type { MaestroSettings } from './persistence'; const LOG_CONTEXT = '[TabNaming]'; +// Safe debug wrapper to centralize console.debug error isolation +const safeDebug = (message: string, data?: any) => { + try { + console.debug(message, data); + } catch { + // swallow + } +}; + /** * Helper to create handler options with consistent context */ @@ -84,6 +93,7 @@ export function registerTabNamingHandlers(deps: TabNamingHandlerDependencies): v userMessage: string; agentType: string; cwd: string; + sessionCustomModel?: string; sessionSshRemoteConfig?: { enabled: boolean; remoteId: string | null; @@ -122,32 +132,135 @@ export function registerTabNamingHandlers(deps: TabNamingHandlerDependencies): v const baseArgs = (agent.args ?? []).filter( (arg) => arg !== '--dangerously-skip-permissions' ); + + // Fetch stored agent config values (user overrides) early so we can + // prefer the configured model when building args for the tab naming call. + const allConfigs = agentConfigsStore.get('configs', {}); + const agentConfigValues = allConfigs[config.agentType] || {}; + + // Resolve model id with stricter rules: + // Preference: session override -> agent-config model (only if it looks complete) -> agent.defaultModel + // Only accept agent-config model when it contains a provider/model (contains a '/') + let resolvedModelId: string | undefined; + if (typeof config.sessionCustomModel === 'string' && config.sessionCustomModel.trim()) { + resolvedModelId = config.sessionCustomModel.trim(); + } else if ( + agentConfigValues && + typeof agentConfigValues.model === 'string' && + agentConfigValues.model.trim() && + agentConfigValues.model.includes('/') + ) { + resolvedModelId = agentConfigValues.model.trim(); + } else if (agent.defaultModel && typeof agent.defaultModel === 'string') { + resolvedModelId = agent.defaultModel; + } + + // Sanitize resolved model id (remove trailing slashes) + if (resolvedModelId) { + resolvedModelId = resolvedModelId.replace(/\/+$/, '').trim(); + if (resolvedModelId === '') resolvedModelId = undefined; + } + + // Debug: log resolved model for tab naming + safeDebug('[TabNaming] Resolved model', { + sessionId, + agentType: config.agentType, + agentConfigModel: agentConfigValues.model, + resolvedModelId, + }); + let finalArgs = buildAgentArgs(agent, { baseArgs, prompt: fullPrompt, cwd: config.cwd, readOnlyMode: true, // Always read-only since we're not modifying anything + // modelId intentionally omitted — applyAgentConfigOverrides is the single source of model injection }); - // Apply config overrides from store - const allConfigs = agentConfigsStore.get('configs', {}); - const agentConfigValues = allConfigs[config.agentType] || {}; + // Apply config overrides from store (customArgs/env only). + // Do NOT pass sessionCustomModel here so modelSource reflects the true origin + // (agent-config or default). resolvedModelId is applied explicitly below. const configResolution = applyAgentConfigOverrides(agent, finalArgs, { agentConfigValues, }); finalArgs = configResolution.args; + // Debug: log how model was resolved for tab naming requests so we can + // verify whether session/agent overrides are applied as expected. + safeDebug('[TabNaming] Config resolution', { + sessionId, + agentType: config.agentType, + modelSource: configResolution.modelSource, + agentConfigModel: agentConfigValues?.model, + resolvedModelId, + }); + + // Canonicalize model flags: strip all existing --model/-m tokens before the + // prompt separator, then re-inject the single canonical model flag using the + // agent-specific flag style (e.g. Codex uses -m, Claude Code uses --model=). + // This must run BEFORE SSH wrapping so the flag ends up inside the remote + // agent invocation, not in the SSH wrapper arguments. + const sepIndex = + finalArgs.indexOf('--') >= 0 ? finalArgs.indexOf('--') : finalArgs.length; + const prefix = finalArgs.slice(0, sepIndex); + const suffix = finalArgs.slice(sepIndex); + + const filteredPrefix: string[] = []; + for (let i = 0; i < prefix.length; i++) { + const a = prefix[i]; + if (typeof a === 'string') { + if (a.startsWith('--model=')) { + continue; // drop explicit --model=value + } + if (a === '--model') { + // Only consume the next token as a value if it exists and looks like a value (not a flag) + if (i + 1 < prefix.length && typeof prefix[i + 1] === 'string' && !String(prefix[i + 1]).startsWith('-')) { + i++; + } + continue; + } + if (a === '-m') { + // Only consume the next token as a value if it exists and looks like a value (not a flag) + if (i + 1 < prefix.length && typeof prefix[i + 1] === 'string' && !String(prefix[i + 1]).startsWith('-')) { + i++; + } + continue; + } + } + filteredPrefix.push(a); + } + + // Re-inject using resolvedModelId directly — it already reflects session > + // agent-config > agent-default precedence. Use agent.modelArgs() when available + // so each agent gets its own flag style. + if (resolvedModelId) { + const modelArgTokens = agent.modelArgs + ? agent.modelArgs(resolvedModelId) + : [`--model=${resolvedModelId}`]; + filteredPrefix.push(...modelArgTokens); + safeDebug('[TabNaming] Injected canonical model flag for spawn', { + sessionId, + modelLength: resolvedModelId.length, + tokenCount: modelArgTokens.length, + }); + } + + finalArgs = [...filteredPrefix, ...suffix]; + // Determine command and working directory let command = agent.path || agent.command; let cwd = config.cwd; + // Start with resolved env vars from config resolution, allow mutation below const customEnvVars: Record | undefined = - configResolution.effectiveCustomEnvVars; + configResolution.effectiveCustomEnvVars + ? { ...configResolution.effectiveCustomEnvVars } + : undefined; // Handle SSH remote execution if configured - // IMPORTANT: For SSH, we must send the prompt via stdin to avoid shell escaping issues. - // The prompt contains special characters that break when passed through multiple layers - // of shell escaping (local spawn -> SSH -> remote zsh -> bash -c). - let shouldSendPromptViaStdin = false; + // Use stdin-based execution to completely bypass shell escaping issues. + // The prompt contains special characters that break when passed through multiple + // layers of shell escaping (local spawn -> SSH -> remote bash -c). + let sshStdinScript: string | undefined; if (config.sessionSshRemoteConfig?.enabled && config.sessionSshRemoteConfig.remoteId) { const sshStoreAdapter = createSshRemoteStoreAdapter(settingsStore); const sshResult = getSshRemoteConfig(sshStoreAdapter, { @@ -160,42 +273,32 @@ export function registerTabNamingHandlers(deps: TabNamingHandlerDependencies): v const remoteCommand = agent.command; const remoteCwd = config.sessionSshRemoteConfig.workingDirOverride || config.cwd; - // For agents that support stream-json input, use stdin for the prompt - // This completely avoids shell escaping issues with multi-layer SSH commands - const agentSupportsStreamJson = agent.capabilities?.supportsStreamJsonInput ?? false; - if (agentSupportsStreamJson) { - // Add --input-format stream-json to args so agent reads from stdin - const hasStreamJsonInput = - finalArgs.includes('--input-format') && finalArgs.includes('stream-json'); - if (!hasStreamJsonInput) { - finalArgs = [...finalArgs, '--input-format', 'stream-json']; - } - shouldSendPromptViaStdin = true; - logger.debug( - 'Using stdin for tab naming prompt in SSH remote execution', - LOG_CONTEXT, - { - sessionId, - promptLength: fullPrompt.length, - agentSupportsStreamJson, - } - ); - } - - const sshCommand = await buildSshCommand(sshResult.config, { + const sshCommand = await buildSshCommandWithStdin(sshResult.config, { command: remoteCommand, args: finalArgs, cwd: remoteCwd, env: customEnvVars, - useStdin: shouldSendPromptViaStdin, + // Pass the prompt via stdin so it's never parsed by any shell layer + stdinInput: fullPrompt, }); command = sshCommand.command; finalArgs = sshCommand.args; + sshStdinScript = sshCommand.stdinScript; // Local cwd is not used for SSH commands - the command runs on remote cwd = process.cwd(); } } + // Final safety sanitization: ensure args are all plain strings + const nonStringItems = finalArgs.filter((a) => typeof a !== 'string'); + if (nonStringItems.length > 0) { + finalArgs = finalArgs.filter((a) => typeof a === 'string'); + safeDebug('[TabNaming] Removing non-string args before spawn', { + sessionId, + removedCount: nonStringItems.length, + }); + } + // Create a promise that resolves when we get the tab name return new Promise((resolve) => { let output = ''; @@ -231,6 +334,34 @@ export function registerTabNamingHandlers(deps: TabNamingHandlerDependencies): v // Extract the tab name from the output // The agent should return just the tab name, but we clean up any extra whitespace/formatting + // Log raw output and context to help diagnose generic/low-quality tab names + try { + safeDebug('[TabNaming] Raw output before extraction', { + sessionId, + agentType: config.agentType, + agentConfigModel: agentConfigValues?.model, + resolvedModelId, + finalArgsCount: finalArgs.length, + promptLength: String(fullPrompt).length, + outputLength: String(output).length, + }); + // Detect obviously generic outputs to surface in logs + const genericRegex = + /^("|')?\s*(coding task|task tab name|task tab|coding task tab|task name)\b/i; + if (genericRegex.test(String(output))) { + logger.warn( + '[TabNaming] Agent returned a generic tab name candidate; consider adjusting prompt or model', + LOG_CONTEXT, + { + sessionId, + outputLength: String(output).length, + } + ); + } + } catch { + // swallow logging errors + } + const tabName = extractTabName(output); logger.info('Tab naming completed', LOG_CONTEXT, { sessionId, @@ -245,8 +376,16 @@ export function registerTabNamingHandlers(deps: TabNamingHandlerDependencies): v processManager.on('exit', onExit); // Spawn the process - // When using SSH with stdin, pass the flag so ChildProcessSpawner - // sends the prompt via stdin instead of command line args + // For SSH, sshStdinScript contains the full bash script + prompt + // Debug: log full finalArgs array and types just before spawn + // (kept in console.debug for diagnosis only) + safeDebug('[TabNaming] About to spawn with final args', { + sessionId, + agentType: config.agentType, + hasSshStdinScript: !!sshStdinScript, + finalArgsCount: finalArgs.length, + }); + processManager.spawn({ sessionId, toolType: config.agentType, @@ -255,7 +394,7 @@ export function registerTabNamingHandlers(deps: TabNamingHandlerDependencies): v args: finalArgs, prompt: fullPrompt, customEnvVars, - sendPromptViaStdin: shouldSendPromptViaStdin, + sshStdinScript, }); }); } catch (error) { @@ -301,14 +440,19 @@ function extractTabName(output: string): string | null { // Split by newlines, periods, or arrow symbols and take meaningful lines const lines = cleaned.split(/[.\n→]/).filter((line) => { const trimmed = line.trim(); - // Filter out empty lines and lines that look like instructions/examples + // Filter out empty lines and lines that look like instructions/examples. + // Lines that are fully wrapped in quotes (e.g. "Fix CI flaky tests") are valid + // tab name candidates — keep them so the unquoting step below can clean them. + // Only discard lines that START with a quote but are not fully wrapped (example inputs). + const isWrappedQuoted = /^["'].+["']$/.test(trimmed); + if ((trimmed.startsWith('"') || trimmed.startsWith("'")) && !isWrappedQuoted) return false; + const unquoted = trimmed.replace(/^['"]+|['"]+$/g, ''); return ( - trimmed.length > 0 && - trimmed.length <= 40 && // Tab names should be short - !trimmed.toLowerCase().includes('example') && - !trimmed.toLowerCase().includes('message:') && - !trimmed.toLowerCase().includes('rules:') && - !trimmed.startsWith('"') // Skip example inputs in quotes + unquoted.length > 0 && + unquoted.length <= 40 && // Tab names should be short + !unquoted.toLowerCase().includes('example') && + !unquoted.toLowerCase().includes('message:') && + !unquoted.toLowerCase().includes('rules:') ); }); diff --git a/src/main/preload/tabNaming.ts b/src/main/preload/tabNaming.ts index 8a6dd13dd..57a639a32 100644 --- a/src/main/preload/tabNaming.ts +++ b/src/main/preload/tabNaming.ts @@ -17,6 +17,8 @@ export interface TabNamingConfig { agentType: string; /** Working directory for the session */ cwd: string; + /** Optional session-level model override */ + sessionCustomModel?: string; /** Optional SSH remote configuration */ sessionSshRemoteConfig?: { enabled: boolean; diff --git a/src/main/process-manager/spawners/ChildProcessSpawner.ts b/src/main/process-manager/spawners/ChildProcessSpawner.ts index 5c0adf205..ba5f072c1 100644 --- a/src/main/process-manager/spawners/ChildProcessSpawner.ts +++ b/src/main/process-manager/spawners/ChildProcessSpawner.ts @@ -87,7 +87,11 @@ export class ChildProcessSpawner { const argsHaveInputStreamJson = args.some( (arg, i) => arg === 'stream-json' && i > 0 && args[i - 1] === '--input-format' ); - const promptViaStdin = sendPromptViaStdin || sendPromptViaStdinRaw || argsHaveInputStreamJson; + const promptViaStdin = + sendPromptViaStdin || + sendPromptViaStdinRaw || + argsHaveInputStreamJson || + !!config.sshStdinScript; // Build final args based on batch mode and images // Track whether the prompt was added to CLI args (used later to decide stdin behavior) diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index c76aa5e15..6bf165709 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -2598,6 +2598,7 @@ interface MaestroAPI { userMessage: string; agentType: string; cwd: string; + sessionCustomModel?: string; sessionSshRemoteConfig?: { enabled: boolean; remoteId: string | null; diff --git a/src/renderer/hooks/input/useInputProcessing.ts b/src/renderer/hooks/input/useInputProcessing.ts index 095db9375..ca9278383 100644 --- a/src/renderer/hooks/input/useInputProcessing.ts +++ b/src/renderer/hooks/input/useInputProcessing.ts @@ -709,6 +709,7 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces userMessage: effectiveInputValue, agentType: activeSession.toolType, cwd: activeSession.cwd, + sessionCustomModel: activeSession.customModel, sessionSshRemoteConfig: activeSession.sessionSshRemoteConfig, }) .then((generatedName) => {