Files
orca/src/shared/commit-message-agent-spec.ts
T
Brennan Benson 36d13f14a5 Background Claude runs no longer start your MCP servers and hooks (#30939)
* fix(claude): keep Orca's background Claude runs off the user's MCP servers and hooks

The model picker and / menu listings, chat naming, and commit message, PR,
branch and Jira text generation each start a short Claude run. Every one but
the / probe loaded the user's MCP servers and hooks, so each run started every
server (one cut off a Notion OAuth refresh and lost the sign-in) and fired
SessionStart hooks.

Every background run now passes --strict-mcp-config and disableAllHooks.
A user's own inline --settings merges into the one settings value Claude
keeps, and their --mcp-config still loads. When Claude refuses strict mode
because an enterprise MCP config is present (which already keeps user
servers out), the run is retried once without the flag.

Tracked in STA-11095 and #30411.

* fix: centralize background Claude generation and retry policy

* fix: rebuild Claude background retries from generated arguments

* test: include branch in generation retry fixture

* fix: preserve user settings arguments in background generation

* test: define retry fixture streams across runtimes
2026-10-20 11:50:07 -07:00

240 lines
8.5 KiB
TypeScript

import { OMP_MODEL_LIST_ARGS, parseOmpModelList } from './omp-model-list-probe'
import type { TuiAgent } from './tui-agent'
import { isTuiAgentEnabled } from './tui-agent-selection'
import { labelFromModelId } from './model-id-label'
import { buildPrimaryCommitMessageAgentSpecs } from './commit-message-agent-specs-primary'
import { buildSecondaryCommitMessageAgentSpecs } from './commit-message-agent-specs-secondary'
import {
BASIC_THINKING_LEVELS,
CLAUDE_THINKING_LEVELS,
OPENAI_THINKING_LEVELS,
parseAntigravityModels,
parseClaudeModels,
parseCodexModels,
parseCursorModels,
parseLineModels,
parsePiModels,
withOpenAiThinking
} from './commit-message-model-parsers'
// Why: this file is the source of truth for non-interactive agent invocation
// (commit-message generation). It is intentionally separate from
// `tui-agent-config.ts`, which describes interactive PTY launching — mixing
// the two confuses both code paths.
export type ThinkingLevel = { id: string; label: string }
export type CommitMessageModel = {
/** Value passed to the agent CLI's --model flag. */
id: string
/** Visible label in the model dropdown. */
label: string
/** Discovery-provided detail, e.g. what a CLI alias resolves to on this host. */
description?: string
/** Omit when the model does not expose an effort selector — the UI then hides the dropdown. */
thinkingLevels?: ThinkingLevel[]
/** Required when thinkingLevels is present. */
defaultThinkingLevel?: string
/** Whether the model exposes Claude's mid-session Fast mode toggle. */
supportsFastMode?: boolean
/** Whether Claude's listing says the model runs its auto permission mode; absent is unknown. */
supportsAutoMode?: boolean
/** Set when the listing marks this as the id the CLI runs with no --model flag.
* Optional so an older remote host that never reports it simply omits it. */
isDefault?: boolean
/** Tokens the model's context window holds, where the listing states it. */
contextWindowTokens?: number
}
export type CommitMessageAgentSpec = {
id: TuiAgent
/** Visible label in the agent dropdown. */
label: string
/** Binary spawned in non-interactive mode. */
binary: string
/** Where the prompt is delivered. Large diffs go via stdin to avoid argv limits. */
promptDelivery: 'argv' | 'stdin'
buildArgs: (params: {
prompt: string
model: string
thinkingLevel?: string
strictMcp?: boolean
userArgs?: readonly string[]
}) => string[]
/** Alias groups the CLI accepts at most once. Recipe CLI arguments repeating one
* replace the generated flag instead of being appended: yargs-based CLIs collapse
* a repeated flag into an array and crash. Defaults to the model flag alone. */
singletonOptions?: readonly (readonly string[])[]
/** Whether the model list is static or discovered from the agent CLI. */
modelSource: 'static' | 'dynamic'
/** Command used by the main process to discover models when modelSource is dynamic. */
modelDiscovery?: {
binary: string
args: string[]
/** Written to the CLI's stdin, for CLIs whose listing is request-driven. */
stdinPayload?: string
parse: (stdout: string) => CommitMessageModel[]
}
models: CommitMessageModel[]
defaultModelId: string
}
export type CommitMessageModelCapability = {
id: string
label: string
description?: string
thinkingLevels?: ThinkingLevel[]
defaultThinkingLevel?: string
supportsFastMode?: boolean
/** Absent from an older remote host, which simply yields no default to display. */
isDefault?: boolean
/** Absent from an older remote host; readers then treat the window as unknown. */
contextWindowTokens?: number
}
export type CommitMessageAgentCapability = {
id: TuiAgent
label: string
modelSource: 'static' | 'dynamic'
models: CommitMessageModelCapability[]
defaultModelId: string
}
export const COMMIT_MESSAGE_AGENT_SPECS: Partial<Record<TuiAgent, CommitMessageAgentSpec>> = {
omp: {
id: 'omp',
label: 'OMP',
binary: 'omp',
promptDelivery: 'stdin',
buildArgs: ({ model, thinkingLevel }) => [
'--print',
'--no-session',
'--no-tools',
'--no-extensions',
'--no-skills',
'--no-rules',
'--mode',
'text',
...(model && model !== 'default' ? ['--model', model] : []),
...(thinkingLevel ? ['--thinking', thinkingLevel] : [])
],
singletonOptions: [['--model'], ['--thinking']],
modelSource: 'dynamic',
modelDiscovery: { binary: 'omp', args: OMP_MODEL_LIST_ARGS, parse: parseOmpModelList },
models: [{ id: 'default', label: 'Config default' }],
defaultModelId: 'default'
},
...buildPrimaryCommitMessageAgentSpecs({
CLAUDE_THINKING_LEVELS,
OPENAI_THINKING_LEVELS,
parseClaudeModels,
parseCodexModels,
parseLineModels,
parsePiModels,
withOpenAiThinking
}),
...buildSecondaryCommitMessageAgentSpecs({
BASIC_THINKING_LEVELS,
OPENAI_THINKING_LEVELS,
parseCursorModels,
parseAntigravityModels,
parseLineModels
})
}
export const DEFAULT_COMMIT_MESSAGE_AGENT_ID: TuiAgent = 'claude'
// Why: the "custom" choice is not a TuiAgent — it lets the user point Orca
// at any CLI by typing a command template (see customAgentCommand setting +
// planCustomCommand in commit-message-prompt.ts). Keeping it as its own
// sentinel avoids polluting TuiAgent (which is shared with PTY launch /
// new-workspace flows that have nothing to do with this feature).
export const CUSTOM_AGENT_ID = 'custom' as const
export type CustomAgentId = typeof CUSTOM_AGENT_ID
export type CommitMessageAgentChoice = TuiAgent | CustomAgentId
export type DefaultTuiAgentPreference = TuiAgent | 'blank' | null | undefined
export function isCustomAgentId(id: string | null | undefined): id is CustomAgentId {
return id === CUSTOM_AGENT_ID
}
export function getCommitMessageAgentSpec(agentId: TuiAgent): CommitMessageAgentSpec | undefined {
return COMMIT_MESSAGE_AGENT_SPECS[agentId]
}
export function resolveCommitMessageAgentChoice(
configuredAgentId: CommitMessageAgentChoice | null | undefined,
defaultTuiAgent: DefaultTuiAgentPreference,
disabledTuiAgents?: Iterable<unknown> | null
): CommitMessageAgentChoice | null {
if (configuredAgentId) {
return configuredAgentId
}
if (
defaultTuiAgent &&
defaultTuiAgent !== 'blank' &&
isTuiAgentEnabled(defaultTuiAgent, disabledTuiAgents)
) {
return getCommitMessageAgentSpec(defaultTuiAgent) ? defaultTuiAgent : null
}
return isTuiAgentEnabled(DEFAULT_COMMIT_MESSAGE_AGENT_ID, disabledTuiAgents)
? DEFAULT_COMMIT_MESSAGE_AGENT_ID
: null
}
export function getCommitMessageModel(
agentId: TuiAgent,
modelId: string
): CommitMessageModel | undefined {
const spec = getCommitMessageAgentSpec(agentId)
const model = spec?.models.find((m) => m.id === modelId)
if (model || !spec || spec.modelSource !== 'dynamic' || modelId.trim().length === 0) {
return model
}
return {
id: modelId,
label: labelFromModelId(modelId),
...withOpenAiThinking(modelId)
}
}
function toCommitMessageAgentCapability(
spec: CommitMessageAgentSpec
): CommitMessageAgentCapability {
return {
id: spec.id,
label: spec.label,
modelSource: spec.modelSource,
defaultModelId: spec.defaultModelId,
// Why: renderer/settings should consume provider capabilities, not the
// spawn contract. Copy the model metadata so future dynamic probes can
// swap this source without leaking binary/argv details into UI code.
models: spec.models.map((model) => ({
id: model.id,
label: model.label,
...(model.description ? { description: model.description } : {}),
...(model.thinkingLevels ? { thinkingLevels: [...model.thinkingLevels] } : {}),
...(model.defaultThinkingLevel ? { defaultThinkingLevel: model.defaultThinkingLevel } : {}),
...(model.supportsFastMode ? { supportsFastMode: true } : {})
}))
}
}
export function getCommitMessageAgentCapability(
agentId: TuiAgent
): CommitMessageAgentCapability | undefined {
const spec = getCommitMessageAgentSpec(agentId)
return spec ? toCommitMessageAgentCapability(spec) : undefined
}
/** Ordered list of agents that have a non-interactive mode wired up. */
export function listCommitMessageAgentIds(): TuiAgent[] {
return Object.keys(COMMIT_MESSAGE_AGENT_SPECS) as TuiAgent[]
}
export function listCommitMessageAgentCapabilities(): CommitMessageAgentCapability[] {
return listCommitMessageAgentIds()
.map((id) => getCommitMessageAgentCapability(id))
.filter((capability): capability is CommitMessageAgentCapability => Boolean(capability))
}