mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
* 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
305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
import { findOptionOccurrence } from './command-option-occurrence'
|
|
import { planAgentBinary } from './agent-command-plan'
|
|
export { planAgentBinary } from './agent-command-plan'
|
|
import type { CommandTemplateBackslash } from './commit-message-prompt'
|
|
import {
|
|
getCommitMessageAgentSpec,
|
|
getCommitMessageModel,
|
|
isCustomAgentId
|
|
} from './commit-message-agent-spec'
|
|
import { planCustomCommand, tokenizeCustomCommandTemplate } from './commit-message-prompt'
|
|
import type { TuiAgent } from './tui-agent'
|
|
import { mergeOpenCodeGenerationArgs } from './opencode-generation-command'
|
|
|
|
// Why: planning is a pure transformation from "user request + prompt text"
|
|
// into "spawn-ready binary + argv". Keeping it in shared lets both the local
|
|
// generator (main process) and the SSH provider (which delegates to the
|
|
// relay over JSON-RPC) reuse the exact same validation and arg-building
|
|
// logic without duplicating the spec/model/thinking checks.
|
|
|
|
export type CommitMessagePlanInput = {
|
|
agentId: TuiAgent | 'custom'
|
|
/** How to read `\` in the user's command override / args / custom command.
|
|
* Defaults to POSIX escaping; pass `'literal'` only when the command is known
|
|
* to run on native Windows, where `\` is the path separator (#11375). */
|
|
backslash?: CommandTemplateBackslash
|
|
model: string
|
|
thinkingLevel?: string
|
|
customAgentCommand?: string
|
|
agentCommandOverride?: string
|
|
agentArgs?: string
|
|
}
|
|
|
|
export type CommitMessagePlan = {
|
|
binary: string
|
|
args: string[]
|
|
/** Non-null when the prompt should be piped via stdin. */
|
|
stdinPayload: string | null
|
|
/** Human-readable label used in error prefixes (e.g. "Claude failed: ..."). */
|
|
label: string
|
|
/** Leading command assignments, applied on the execution host. */
|
|
env?: Record<string, string>
|
|
outputFormat?: 'opencode-json'
|
|
}
|
|
|
|
export type CommitMessagePlanResult =
|
|
| { ok: true; plan: CommitMessagePlan }
|
|
| { ok: false; error: string }
|
|
|
|
function planAdditionalAgentArgs(
|
|
agentArgs: string | null | undefined,
|
|
backslash: CommandTemplateBackslash = 'escape'
|
|
): { ok: true; args: string[] } | { ok: false; error: string } {
|
|
const trimmed = agentArgs?.trim()
|
|
if (!trimmed) {
|
|
return { ok: true, args: [] }
|
|
}
|
|
const tokenized = tokenizeCustomCommandTemplate(trimmed, backslash)
|
|
if (!tokenized.ok) {
|
|
return { ok: false, error: `CLI arguments are invalid: ${tokenized.error}` }
|
|
}
|
|
return { ok: true, args: tokenized.tokens }
|
|
}
|
|
|
|
const DEFAULT_SINGLETON_OPTIONS: readonly (readonly string[])[] = [['--model']]
|
|
|
|
function applyRecipeOptionOverride(args: {
|
|
generatedArgs: string[]
|
|
recipeArgs: string[]
|
|
aliases: readonly string[]
|
|
}): { generatedArgs: string[]; recipeArgs: string[] } {
|
|
const recipeOption = findOptionOccurrence(args.recipeArgs, args.aliases, true)
|
|
const generatedOption = findOptionOccurrence(args.generatedArgs, args.aliases, false)
|
|
if (!recipeOption || !generatedOption) {
|
|
return { generatedArgs: args.generatedArgs, recipeArgs: args.recipeArgs }
|
|
}
|
|
|
|
const overrideTokens = args.recipeArgs.slice(
|
|
recipeOption.index,
|
|
recipeOption.index + recipeOption.consumed
|
|
)
|
|
return {
|
|
generatedArgs: [
|
|
...args.generatedArgs.slice(0, generatedOption.index),
|
|
...overrideTokens,
|
|
...args.generatedArgs.slice(generatedOption.index + generatedOption.consumed)
|
|
],
|
|
recipeArgs: [
|
|
...args.recipeArgs.slice(0, recipeOption.index),
|
|
...args.recipeArgs.slice(recipeOption.index + recipeOption.consumed)
|
|
]
|
|
}
|
|
}
|
|
|
|
function removeAllOptionOccurrences(tokens: string[], aliases: readonly string[]): string[] {
|
|
let result = tokens
|
|
while (true) {
|
|
const found = findOptionOccurrence(result, aliases, true)
|
|
if (!found) {
|
|
return result
|
|
}
|
|
result = [...result.slice(0, found.index), ...result.slice(found.index + found.consumed)]
|
|
}
|
|
}
|
|
|
|
/** Drops every occurrence after the first, so a user who types the same singleton
|
|
* twice in one field still gets a single flag rather than a rejected argv. */
|
|
function keepFirstOptionOccurrence(tokens: string[], aliases: readonly string[]): string[] {
|
|
let result = tokens
|
|
while (true) {
|
|
const first = findOptionOccurrence(result, aliases, true)
|
|
if (!first) {
|
|
return result
|
|
}
|
|
const tail = result.slice(first.index + first.consumed)
|
|
const duplicate = findOptionOccurrence(tail, aliases, true)
|
|
if (!duplicate) {
|
|
return result
|
|
}
|
|
const offset = first.index + first.consumed
|
|
result = [
|
|
...result.slice(0, offset + duplicate.index),
|
|
...result.slice(offset + duplicate.index + duplicate.consumed)
|
|
]
|
|
}
|
|
}
|
|
|
|
/** Removes generated singleton options shadowed by user input. Recipe args
|
|
* outrank a command-override prefix, which outranks Orca's generated value. */
|
|
function applySingletonOptionOverrides(args: {
|
|
generatedArgs: string[]
|
|
prefixArgs: string[]
|
|
recipeArgs: string[]
|
|
singletonOptions: readonly (readonly string[])[]
|
|
}): { generatedArgs: string[]; prefixArgs: string[]; recipeArgs: string[] } {
|
|
let generatedArgs = args.generatedArgs
|
|
let prefixArgs = args.prefixArgs
|
|
let recipeArgs = args.recipeArgs
|
|
|
|
for (const aliases of args.singletonOptions) {
|
|
recipeArgs = keepFirstOptionOccurrence(recipeArgs, aliases)
|
|
prefixArgs = keepFirstOptionOccurrence(prefixArgs, aliases)
|
|
const recipeOption = findOptionOccurrence(recipeArgs, aliases, true)
|
|
const prefixOption = findOptionOccurrence(prefixArgs, aliases, true)
|
|
const prefixHasTerminator = prefixArgs.includes('--')
|
|
if (recipeOption && !prefixHasTerminator) {
|
|
prefixArgs = removeAllOptionOccurrences(prefixArgs, aliases)
|
|
} else if (prefixOption && !prefixHasTerminator) {
|
|
const generatedOption = findOptionOccurrence(generatedArgs, aliases, false)
|
|
if (generatedOption) {
|
|
generatedArgs = [
|
|
...generatedArgs.slice(0, generatedOption.index),
|
|
...generatedArgs.slice(generatedOption.index + generatedOption.consumed)
|
|
]
|
|
}
|
|
continue
|
|
}
|
|
const withRecipe = applyRecipeOptionOverride({ generatedArgs, recipeArgs, aliases })
|
|
generatedArgs = withRecipe.generatedArgs
|
|
recipeArgs = withRecipe.recipeArgs
|
|
}
|
|
|
|
return { generatedArgs, prefixArgs, recipeArgs }
|
|
}
|
|
|
|
function insertAdditionalAgentArgs(args: {
|
|
baseArgs: string[]
|
|
agentArgs: string[]
|
|
promptDelivery: 'argv' | 'stdin'
|
|
prompt: string
|
|
}): string[] {
|
|
if (!args.agentArgs.length) {
|
|
return args.baseArgs
|
|
}
|
|
const promptPlaceholderIndex = args.baseArgs.lastIndexOf('{prompt}')
|
|
if (promptPlaceholderIndex !== -1) {
|
|
const merged = [...args.baseArgs]
|
|
merged.splice(promptPlaceholderIndex, 0, ...args.agentArgs)
|
|
return merged
|
|
}
|
|
if (
|
|
args.promptDelivery === 'argv' &&
|
|
args.prompt.length > 0 &&
|
|
args.baseArgs.at(-1) === args.prompt
|
|
) {
|
|
return [...args.baseArgs.slice(0, -1), ...args.agentArgs, args.prompt]
|
|
}
|
|
return [...args.baseArgs, ...args.agentArgs]
|
|
}
|
|
|
|
export function planCommitMessageGeneration(
|
|
input: CommitMessagePlanInput,
|
|
prompt: string,
|
|
options: { strictMcp?: boolean } = {}
|
|
): CommitMessagePlanResult {
|
|
if (isCustomAgentId(input.agentId)) {
|
|
const command = input.customAgentCommand?.trim() ?? ''
|
|
if (!command) {
|
|
return {
|
|
ok: false,
|
|
error: 'Custom command is empty. Add one in Settings → Git → AI Commit Messages.'
|
|
}
|
|
}
|
|
const planned = planCustomCommand(command, prompt, input.backslash)
|
|
if (!planned.ok) {
|
|
return { ok: false, error: planned.error }
|
|
}
|
|
const agentArgs = planAdditionalAgentArgs(input.agentArgs, input.backslash)
|
|
if (!agentArgs.ok) {
|
|
return agentArgs
|
|
}
|
|
return {
|
|
ok: true,
|
|
plan: {
|
|
binary: planned.binary,
|
|
args: insertAdditionalAgentArgs({
|
|
baseArgs: planned.args,
|
|
agentArgs: agentArgs.args,
|
|
promptDelivery: planned.stdinPayload === null ? 'argv' : 'stdin',
|
|
prompt
|
|
}),
|
|
stdinPayload: planned.stdinPayload,
|
|
// Why: a custom command has no friendly name, so the binary doubles
|
|
// as the label in error prefixes ("ollama failed: ...").
|
|
label: planned.binary,
|
|
...(planned.env ? { env: planned.env } : {})
|
|
}
|
|
}
|
|
}
|
|
|
|
const spec = getCommitMessageAgentSpec(input.agentId)
|
|
if (!spec) {
|
|
return { ok: false, error: `Agent "${input.agentId}" does not support AI commit messages.` }
|
|
}
|
|
const model = getCommitMessageModel(input.agentId, input.model)
|
|
if (!model) {
|
|
return { ok: false, error: `Model "${input.model}" is not available for ${spec.label}.` }
|
|
}
|
|
if (input.thinkingLevel) {
|
|
if (!model.thinkingLevels && spec.modelSource !== 'dynamic') {
|
|
return {
|
|
ok: false,
|
|
error: `Model "${model.label}" does not support a thinking effort level.`
|
|
}
|
|
}
|
|
if (model.thinkingLevels && !model.thinkingLevels.some((l) => l.id === input.thinkingLevel)) {
|
|
return {
|
|
ok: false,
|
|
error: `Thinking level "${input.thinkingLevel}" is not valid for ${model.label}.`
|
|
}
|
|
}
|
|
}
|
|
|
|
const argvPrompt = spec.promptDelivery === 'argv' ? prompt : ''
|
|
const agentArgs = planAdditionalAgentArgs(input.agentArgs, input.backslash)
|
|
if (!agentArgs.ok) {
|
|
return agentArgs
|
|
}
|
|
const command = planAgentBinary(spec.binary, input.agentCommandOverride, input.backslash)
|
|
if (!command.ok) {
|
|
return { ok: false, error: command.error }
|
|
}
|
|
const baseArgs = spec.buildArgs({
|
|
prompt: argvPrompt,
|
|
model: input.model,
|
|
thinkingLevel: input.thinkingLevel,
|
|
strictMcp: options.strictMcp,
|
|
userArgs: [...command.prefixArgs, ...agentArgs.args]
|
|
})
|
|
// Why: repeating a singleton flag makes yargs-based CLIs parse it as an array and
|
|
// crash (OpenCode's `model.split('/')`). User values replace Orca's, never stack.
|
|
const merged = applySingletonOptionOverrides({
|
|
generatedArgs: baseArgs,
|
|
prefixArgs: command.prefixArgs,
|
|
recipeArgs: agentArgs.args,
|
|
singletonOptions: spec.singletonOptions ?? DEFAULT_SINGLETON_OPTIONS
|
|
})
|
|
const args = insertAdditionalAgentArgs({
|
|
baseArgs: merged.generatedArgs,
|
|
agentArgs: merged.recipeArgs,
|
|
promptDelivery: spec.promptDelivery,
|
|
prompt: argvPrompt
|
|
})
|
|
const generationArgs = mergeOpenCodeGenerationArgs(
|
|
input.agentId,
|
|
command.binary,
|
|
merged.prefixArgs,
|
|
args
|
|
)
|
|
const formatOption =
|
|
input.agentId === 'opencode' || input.agentId === 'opencode2'
|
|
? findOptionOccurrence(generationArgs, ['--format'], true)
|
|
: null
|
|
return {
|
|
ok: true,
|
|
plan: {
|
|
binary: command.binary,
|
|
args: generationArgs,
|
|
stdinPayload: spec.promptDelivery === 'stdin' ? prompt : null,
|
|
label: spec.label,
|
|
...(formatOption?.value === 'json' ? { outputFormat: 'opencode-json' as const } : {}),
|
|
...(command.env ? { env: command.env } : {})
|
|
}
|
|
}
|
|
}
|