mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
* fix(commit-message): keep Windows paths intact in agent command overrides `tokenizeCustomCommandTemplate` applies POSIX backslash-escape rules on every platform. On Windows `\` is the path separator, so a native absolute path in an agent command override is silently destroyed: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -> C:WindowsSystem32WindowsPowerShellv1.0powershell.exe which is then reported as not found on PATH. The agent *startup* path already routes Windows shells to the Windows tokenizer, but the commit-message AI path still calls the generic tokenizer directly, so overrides, extra CLI args and custom commands there are all affected. The tokenizer gains an explicit `'escape' | 'literal'` mode rather than reading `process.platform`, because the same template can be parsed on one host and executed on another. `'escape'` stays the default, so POSIX behaviour — where `foo\ bar` is deliberately one token — is unchanged. `'literal'` is selected only where the command provably runs on native Windows: a LOCAL target, on win32, with no WSL distro. A WSL target runs a Linux binary inside the distro, and a remote target runs on a host whose platform this process cannot see; both keep POSIX escaping. Fixes #11375 * test: pin the platform decision for literal-backslash parsing commandBackslashMode is the only place that reads the platform, so it is where this can be wrong in the direction that matters — applying Windows rules to a command that will actually run under a POSIX shell. WSL and remote targets are pinned explicitly; both were previously untested.
336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
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'
|
|
|
|
// 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
|
|
}
|
|
|
|
export type CommitMessagePlanResult =
|
|
| { ok: true; plan: CommitMessagePlan }
|
|
| { ok: false; error: string }
|
|
|
|
export function planAgentBinary(
|
|
defaultBinary: string,
|
|
commandOverride: string | undefined,
|
|
backslash: CommandTemplateBackslash = 'escape'
|
|
): { ok: true; binary: string; prefixArgs: string[] } | { ok: false; error: string } {
|
|
const command = commandOverride?.trim()
|
|
if (!command) {
|
|
return { ok: true, binary: defaultBinary, prefixArgs: [] }
|
|
}
|
|
|
|
const tokenized = tokenizeCustomCommandTemplate(command, backslash)
|
|
if (!tokenized.ok) {
|
|
return { ok: false, error: `Agent command override is invalid: ${tokenized.error}` }
|
|
}
|
|
const [binary, ...prefixArgs] = tokenized.tokens
|
|
if (!binary) {
|
|
return { ok: false, error: 'Agent command override must start with a binary name.' }
|
|
}
|
|
return { ok: true, binary, prefixArgs }
|
|
}
|
|
|
|
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 matchesOption(token: string, aliases: readonly string[]): boolean {
|
|
return aliases.some(
|
|
(alias) =>
|
|
token === alias ||
|
|
token.startsWith(`${alias}=`) ||
|
|
(alias.startsWith('-') &&
|
|
!alias.startsWith('--') &&
|
|
token.startsWith(alias) &&
|
|
token.length > alias.length)
|
|
)
|
|
}
|
|
|
|
function findOptionOccurrence(
|
|
tokens: string[],
|
|
aliases: readonly string[],
|
|
stopAtTerminator: boolean
|
|
): { index: number; consumed: number } | null {
|
|
for (let index = 0; index < tokens.length; index += 1) {
|
|
const token = tokens[index]
|
|
if (stopAtTerminator && token === '--') {
|
|
break
|
|
}
|
|
if (!matchesOption(token, aliases)) {
|
|
continue
|
|
}
|
|
const nextToken = tokens[index + 1]
|
|
const consumesNext =
|
|
aliases.includes(token) && nextToken !== undefined && !nextToken.startsWith('-')
|
|
return { index, consumed: consumesNext ? 2 : 1 }
|
|
}
|
|
return null
|
|
}
|
|
|
|
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
|
|
): 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
|
|
}
|
|
}
|
|
}
|
|
|
|
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 baseArgs = spec.buildArgs({
|
|
prompt: argvPrompt,
|
|
model: input.model,
|
|
thinkingLevel: input.thinkingLevel
|
|
})
|
|
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 }
|
|
}
|
|
// 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
|
|
})
|
|
return {
|
|
ok: true,
|
|
plan: {
|
|
binary: command.binary,
|
|
args: [...merged.prefixArgs, ...args],
|
|
stdinPayload: spec.promptDelivery === 'stdin' ? prompt : null,
|
|
label: spec.label
|
|
}
|
|
}
|
|
}
|