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
197 changes: 185 additions & 12 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,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;
}
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),
});

// 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);
}
Comment on lines +209 to +231
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Model flag stripping may incorrectly consume a following argument if --model appears without a value.

If finalArgs contains a malformed sequence like ['--model', '--verbose', ...], the i++ at line 216 will skip --verbose, treating it as the model value.

Consider adding a guard to ensure the next element looks like a model value (not another flag):

🛡️ Proposed defensive check
 						if (a === '--model') {
+							// Only consume next arg if it doesn't look like another flag
+							if (i + 1 < prefix.length && !String(prefix[i + 1]).startsWith('-')) {
+								i++; // drop flag + value
+							}
-							i++; // drop flag + value
 							continue;
 						}
 						if (a === '-m' && i + 1 < prefix.length) {
+							// Only consume next arg if it doesn't look like another flag
+							if (!String(prefix[i + 1]).startsWith('-')) {
+								i++; // drop short form + value
+							}
-							i++; // drop short form + value
 							continue;
 						}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
}
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 next arg if it doesn't look like another flag
if (i + 1 < prefix.length && !String(prefix[i + 1]).startsWith('-')) {
i++; // drop flag + value
}
continue;
}
if (a === '-m' && i + 1 < prefix.length) {
// Only consume next arg if it doesn't look like another flag
if (!String(prefix[i + 1]).startsWith('-')) {
i++; // drop short form + value
}
continue;
}
}
filteredPrefix.push(a);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/ipc/handlers/tabNaming.ts` around lines 209 - 225, The loop that
strips model flags in tabNaming.ts can wrongly consume the next token when
encountering '--model' or '-m'; update the logic inside the for loop that
inspects prefix[i] (and handles '--model' and '-m') to only advance i and drop
the next element if that next element exists, is a string, and does not start
with '-' (i.e., looks like a value); otherwise treat the flag as not having a
value and only drop the flag itself (push no increment), ensuring filteredPrefix
and prefix handling remains unchanged.


// 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<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 +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'];
}
Expand Down Expand Up @@ -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<string | null>((resolve) => {
let output = '';
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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:')
);
});

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