Skip to content
Draft
2 changes: 2 additions & 0 deletions src/main/agents/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>; // Default environment variables for this agent (merged with user customEnvVars)
readOnlyEnvOverrides?: Record<string, string>; // 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;
}

/**
Expand Down
212 changes: 201 additions & 11 deletions src/main/ipc/handlers/tabNaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -84,6 +93,7 @@ export function registerTabNamingHandlers(deps: TabNamingHandlerDependencies): v
userMessage: string;
agentType: string;
cwd: string;
sessionCustomModel?: string;
sessionSshRemoteConfig?: {
enabled: boolean;
remoteId: string | null;
Expand Down Expand Up @@ -122,26 +132,142 @@ 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 as any).defaultModel &&
typeof (agent as any).defaultModel === 'string'
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unnecessary type casting - defaultModel is already part of AgentConfig

defaultModel?: string was added to the AgentConfig interface in this PR (line 101 of definitions.ts). The as any cast bypasses type safety.

Suggested change
(agent as any).defaultModel &&
typeof (agent as any).defaultModel === 'string'
} else if (
agent.defaultModel &&
typeof agent.defaultModel === 'string'
) {
resolvedModelId = agent.defaultModel;

) {
resolvedModelId = (agent as any).defaultModel as string;
}
Comment on lines +144 to +156
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sessionCustomModel branch is dead code — field is not in the handler config type

The IPC handler's config parameter (lines 83–92) is typed as:

config: {
  userMessage: string;
  agentType: string;
  cwd: string;
  sessionSshRemoteConfig?: { ... };
}

sessionCustomModel is not part of this type. Accessing it via (config as any).sessionCustomModel will always evaluate to undefined, so the first branch of the model-resolution chain (lines 135–139) is never entered. The PR description lists "session override" as the highest-priority model source, but it will never be applied until sessionCustomModel is added to both the config type and the IPC caller.

Suggested change
let resolvedModelId: string | undefined;
if (
typeof (config as any).sessionCustomModel === 'string' &&
(config as any).sessionCustomModel.trim()
) {
resolvedModelId = (config as any).sessionCustomModel.trim();
} else if (
agentConfigValues &&
typeof agentConfigValues.model === 'string' &&
agentConfigValues.model.trim() &&
agentConfigValues.model.includes('/')
) {
resolvedModelId = agentConfigValues.model.trim();
} else if (
(agent as any).defaultModel &&
typeof (agent as any).defaultModel === 'string'
) {
resolvedModelId = (agent as any).defaultModel as string;
}
let resolvedModelId: string | undefined;
if (
typeof (config as any).sessionCustomModel === 'string' &&
(config as any).sessionCustomModel.trim()
) {
resolvedModelId = (config as any).sessionCustomModel.trim();
} else if (

Consider adding sessionCustomModel?: string to the config interface and threading the value through from the caller, or removing this branch if it is not yet implemented.


// 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),
});

// Determine the canonical CLI model to inject.
// resolvedModelId stays stable for logging; cliModelId is what gets injected.
// If resolvedModelId has no provider prefix, fall back to agent.defaultModel.
// If neither has a provider prefix, still inject the value so the model is
// not silently dropped when existing model tokens are stripped.
let cliModelId: string | undefined = resolvedModelId;
if (
cliModelId &&
!cliModelId.includes('/') &&
(agent as any).defaultModel &&
typeof (agent as any).defaultModel === 'string' &&
(agent as any).defaultModel.includes('/')
) {
cliModelId = (agent as any).defaultModel as string;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fallback logic violates stated model precedence order by overriding session models.

If resolvedModelId = "gpt-4" (from session without /), and agent.defaultModel = "openai/gpt-4" (with /), this code replaces the session model with the agent default, violating the documented precedence: session > agent config > agent default.

Suggested change
if (
cliModelId &&
!cliModelId.includes('/') &&
(agent as any).defaultModel &&
typeof (agent as any).defaultModel === 'string' &&
(agent as any).defaultModel.includes('/')
) {
cliModelId = (agent as any).defaultModel as string;
}
// If resolvedModelId has no provider prefix and came from agent.defaultModel,
// keep it as-is. Only models from session/config should reach here.
// The agent.defaultModel fallback was already tried during resolution.
let cliModelId: string | undefined = resolvedModelId;


// Canonicalize model flags: strip all existing --model/-m tokens before the
// prompt separator, then re-inject the single canonical --model=<value>.
// This must run BEFORE SSH wrapping so the flag ends up inside the remote
// agent invocation, not in the SSH wrapper arguments.
try {
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 the canonical model if we have one.
if (cliModelId && typeof cliModelId === 'string') {
// Validate: skip empty or trailing-slash-only values
const sanitized = cliModelId.replace(/\/+$/, '').trim();
if (sanitized) {
filteredPrefix.push('--model=' + sanitized);
safeDebug('[TabNaming] Injected canonical --model for spawn', {
sessionId,
model: sanitized,
});
}
}

finalArgs = [...filteredPrefix, ...suffix];
} catch (err) {
// swallow
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty catch violates CLAUDE.md error handling guidelines

CLAUDE.md states: "DO let exceptions bubble up" and "silently swallowing errors hides bugs from Sentry". If model canonicalization fails, finalArgs remains unchanged with no indication of the failure, which could lead to incorrect model selection.


// 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<string, string> | 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.
Expand All @@ -165,8 +291,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'];
}
Expand Down Expand Up @@ -196,6 +323,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<string | null>((resolve) => {
let output = '';
Expand Down Expand Up @@ -231,6 +372,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,
Expand All @@ -247,6 +418,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,
Expand Down Expand Up @@ -302,13 +487,18 @@ function extractTabName(output: string): string | null {
const lines = cleaned.split(/[.\n→]/).filter((line) => {
const trimmed = line.trim();
// Filter out empty lines and lines that look like instructions/examples
// Treat lines that start with a quote as example inputs and filter them out.
// (Agents sometimes include example quoted names in the prompt.)
if (trimmed.startsWith('"') || trimmed.startsWith("'")) return false;
// Allow quoted single-line outputs to be cleaned later, but lines that begin
// with quotes are typically examples and should be ignored.
const unquoted = trimmed.replace(/^['"]+|['"]+$/g, '');
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic contradiction - quoted lines filtered before unquoted is used

Line 492 returns false for lines starting with quotes, so the unquoted variable at line 495 is only computed for lines that don't start with quotes. The comment at line 493 says "Allow quoted single-line outputs to be cleaned later", but those lines are already filtered out at line 492.

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:')
);
});

Expand Down
2 changes: 2 additions & 0 deletions src/main/preload/tabNaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/renderer/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2598,6 +2598,7 @@ interface MaestroAPI {
userMessage: string;
agentType: string;
cwd: string;
sessionCustomModel?: string;
sessionSshRemoteConfig?: {
enabled: boolean;
remoteId: string | null;
Expand Down
1 change: 1 addition & 0 deletions src/renderer/hooks/input/useInputProcessing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading