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..54cc999a2 100644 --- a/src/main/ipc/handlers/tabNaming.ts +++ b/src/main/ipc/handlers/tabNaming.ts @@ -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,26 +132,125 @@ 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: resolvedModelId, }); - // Apply config overrides from store - const allConfigs = agentConfigsStore.get('configs', {}); - const agentConfigValues = allConfigs[config.agentType] || {}; + // Apply config overrides from store (other overrides such as customArgs/env) const configResolution = applyAgentConfigOverrides(agent, finalArgs, { agentConfigValues, + sessionCustomModel: resolvedModelId, }); 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, + finalArgsPreview: finalArgs.slice(0, 40), + }); + + // 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') { + i++; // drop flag + value + continue; + } + if (a === '-m' && i + 1 < prefix.length) { + i++; // drop short form + value + 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 sanitized = resolvedModelId.replace(/\/+$/, '').trim(); + if (sanitized) { + const modelArgTokens = agent.modelArgs + ? agent.modelArgs(sanitized) + : [`--model=${sanitized}`]; + filteredPrefix.push(...modelArgTokens); + safeDebug('[TabNaming] Injected canonical model flag for spawn', { + sessionId, + model: sanitized, + tokens: modelArgTokens, + }); + } + } + + 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. @@ -165,8 +274,9 @@ export function registerTabNamingHandlers(deps: TabNamingHandlerDependencies): v const agentSupportsStreamJson = agent.capabilities?.supportsStreamJsonInput ?? false; if (agentSupportsStreamJson) { // Add --input-format stream-json to args so agent reads from stdin + const inputFormatIdx = finalArgs.indexOf('--input-format'); const hasStreamJsonInput = - finalArgs.includes('--input-format') && finalArgs.includes('stream-json'); + inputFormatIdx !== -1 && finalArgs[inputFormatIdx + 1] === 'stream-json'; if (!hasStreamJsonInput) { finalArgs = [...finalArgs, '--input-format', 'stream-json']; } @@ -196,6 +306,20 @@ export function registerTabNamingHandlers(deps: TabNamingHandlerDependencies): v } } + // Final safety sanitization: ensure args are all plain strings + try { + const nonStringItems = finalArgs.filter((a) => typeof a !== 'string'); + if (nonStringItems.length > 0) { + safeDebug('[TabNaming] Removing non-string args before spawn', { + sessionId, + removed: nonStringItems.map((i) => ({ typeof: typeof i, preview: String(i) })), + }); + finalArgs = finalArgs.filter((a) => typeof a === 'string'); + } + } catch (err) { + // swallow safety log errors + } + // Create a promise that resolves when we get the tab name return new Promise((resolve) => { let output = ''; @@ -231,6 +355,36 @@ 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, + finalArgsPreview: finalArgs.slice(0, 40), + promptPreview: fullPrompt + ? `${String(fullPrompt).slice(0, 200)}${String(fullPrompt).length > 200 ? '...' : ''}` + : undefined, + rawOutputPreview: `${String(output).slice(0, 200)}${String(output).length > 200 ? '...' : ''}`, + rawOutputLength: 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))) { + console.warn( + '[TabNaming] Agent returned a generic tab name candidate; consider adjusting prompt or model', + { + sessionId, + detected: String(output).trim().slice(0, 80), + } + ); + } + } catch (err) { + // swallow logging errors + } + const tabName = extractTabName(output); logger.info('Tab naming completed', LOG_CONTEXT, { sessionId, @@ -247,6 +401,20 @@ export function registerTabNamingHandlers(deps: TabNamingHandlerDependencies): v // Spawn the process // When using SSH with stdin, pass the flag so ChildProcessSpawner // sends the prompt via stdin instead of command line args + try { + // 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, + command, + cwd, + sendPromptViaStdin: shouldSendPromptViaStdin, + finalArgsDetail: finalArgs.map((a) => ({ value: a, type: typeof a })), + }); + } catch (err) { + // ignore logging failures + } + processManager.spawn({ sessionId, toolType: config.agentType, @@ -301,14 +469,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/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) => {