mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
* fix(automations): deliver prompt to Hermes TUI via process-ready signal Hermes's prompt_toolkit TUI never emits the DECSET 2004 bracketed-paste handshake that the default draft-paste readiness waiter gates on, so the automation prompt was silently dropped and the agent sat idle (terminal opens, nothing entered). Add a 'process-ready' DraftPasteReadySignal that arms the quiet-window on first PTY output and pastes once the TUI settles, and assign it to the hermes agent config. Adds a Hermes unit test covering the no-handshake path. Design for the follow-up PR/issue review loop lives in docs/design/pr-issue-review-loop.md (not committed; gitignored). * fix(automations): skip broken process-name fallback for process-ready CodeRabbit: the process-ready fallback consulted waitForAgentReady, which compares the foreground process basename via isExpectedAgentProcess. Wrapped interpreter launches surface as 'python3 .../hermes', so the check can never confirm readiness and only drops the paste. process-ready readiness is the PTY-quiet window (handled by waitForAgentDraftInputReady); the process-name fallback is dead for this signal. Skip it and return false on timeout instead. * test(automations): assert single paste for Hermes quiet-window path Guard against a duplicate paste in the no-handshake Hermes path by asserting sendRuntimePtyInputVerified is called exactly once. Addresses the CodeRabbit nitpick on PR #7862. * fix(automations): skip broken process-name fallback on pty-bound paste path pasteDraftToAgentPtyWhenReady (quick-create/work-item route) still ran the python3-vs-hermes process-name fallback that can never match for process-ready agents, burning ~1s and dropping the paste. Mirror the process-ready guard from pasteDraftWhenAgentReady and surface onTimeout. Adds regression tests for both the quiet-window happy path and the fallback-skip on the pty-bound path. Co-authored-by: Orca <help@stably.ai> * fix(automations): defer submit Enter until the Hermes TUI is interactive Live-testing against Hermes v0.18.2 showed the fixed 50ms post-paste Enter is swallowed: the node ui-tui takes 15s+ to boot, the paste fires ~1.5s in (process-ready quiet window), and the cooked-mode line discipline turns the early \r into \n, which the editor treats as newline-insert. The prompt parked in the input box and the automation never executed. For process-ready agents, defer the Enter until the TUI signals interactivity (DECSET 2004 enable) or echoes the pasted content marker-free (legacy prompt_toolkit), with a 120s best-effort cap. PTY input is FIFO, so the deferred Enter always lands after the buffered paste text. The paste echo of the content itself (raw or caret-notation markers adjacent) is rejected so a cooked-mode echo can't release the Enter early. Verified end-to-end in the dev app: automation prompt pasted, deferred Enter released on tui-ready, Hermes submitted and ran the turn. Co-authored-by: Orca <help@stably.ai> * fix(automations): use Hermes native startup query Co-authored-by: Orca <help@stably.ai> * fix(automations): preserve quoted Hermes queries on Windows * fix(agents): preserve invalid argument error message Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Brandon Bennett <brandonbennett@macbookair.myfiosgateway.com> Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Orca <help@stably.ai> Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
204 lines
7.1 KiB
TypeScript
204 lines
7.1 KiB
TypeScript
import {
|
|
buildShellCommandFromArgv,
|
|
quoteStartupArg,
|
|
tokenizeStartupCommand,
|
|
type AgentStartupShell
|
|
} from './tui-agent-startup-shell'
|
|
|
|
const QUERY_ENV_LIMIT = 24_000
|
|
const QUERY_PLACEHOLDER = '__ORCA_HERMES_STARTUP_QUERY__'
|
|
const QUERY_ARG_PLACEHOLDER = `--query=${QUERY_PLACEHOLDER}`
|
|
const POSIX_QUERY_VARIABLE = '__orca_hermes_startup_query'
|
|
const POWERSHELL_QUERY_VARIABLE = 'orcaHermesStartupQuery'
|
|
const POWERSHELL_NATIVE_QUERY_VARIABLE = 'orcaHermesNativeQuery'
|
|
|
|
export const ORCA_HERMES_STARTUP_QUERY_ENV = 'ORCA_HERMES_STARTUP_QUERY'
|
|
|
|
function encodePowerShellCommand(command: string): string {
|
|
let bytes = ''
|
|
for (let index = 0; index < command.length; index += 1) {
|
|
const code = command.charCodeAt(index)
|
|
bytes += String.fromCharCode(code & 0xff, code >>> 8)
|
|
}
|
|
return btoa(bytes)
|
|
}
|
|
|
|
function encodePosixEvalScript(command: string): string {
|
|
return Array.from(
|
|
new TextEncoder().encode(command),
|
|
(byte) => `\\0${byte.toString(8).padStart(3, '0')}`
|
|
).join('')
|
|
}
|
|
|
|
function tokenizeCommand(value: string, shell: AgentStartupShell): string[] | null {
|
|
const tokenized = tokenizeStartupCommand(value, shell)
|
|
return tokenized.ok && tokenized.tokens.length > 0 ? tokenized.tokens : null
|
|
}
|
|
|
|
function isHermesExecutableToken(value: string): boolean {
|
|
const basename = value.replaceAll('\\', '/').split('/').pop()?.toLowerCase() ?? ''
|
|
return basename === 'hermes' || basename === 'hermes.exe' || basename === 'hermes.cmd'
|
|
}
|
|
|
|
const HERMES_VALUELESS_FLAGS = new Set([
|
|
'--tui',
|
|
'--cli',
|
|
'--verbose',
|
|
'-v',
|
|
'--quiet',
|
|
'-Q',
|
|
'--worktree',
|
|
'-w',
|
|
'--accept-hooks',
|
|
'--checkpoints',
|
|
'--yolo',
|
|
'--pass-session-id',
|
|
'--ignore-user-config',
|
|
'--ignore-rules',
|
|
'--safe-mode',
|
|
'--dev'
|
|
])
|
|
|
|
function findChatSubcommand(args: readonly string[]): number {
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const token = args[index]
|
|
if (token === 'chat') {
|
|
return index
|
|
}
|
|
if (token.startsWith('-') && !token.includes('=') && !HERMES_VALUELESS_FLAGS.has(token)) {
|
|
index += 1
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
function stripOrcaOwnedHermesArgs(args: readonly string[]): string[] {
|
|
const normalized: string[] = []
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const token = args[index]
|
|
if (token === '--query' || token === '-q') {
|
|
index += 1
|
|
continue
|
|
}
|
|
if (
|
|
token.startsWith('--query=') ||
|
|
token.startsWith('-q=') ||
|
|
(token.startsWith('-q') && token.length > 2) ||
|
|
token === '--tui' ||
|
|
token === '--cli'
|
|
) {
|
|
continue
|
|
}
|
|
normalized.push(token)
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
function normalizeHermesArgv(
|
|
baseArgv: string[],
|
|
configuredArgv: string[],
|
|
shell: AgentStartupShell
|
|
): string[] | null {
|
|
const executableCandidates: number[] = []
|
|
for (let index = 0; index < baseArgv.length; index += 1) {
|
|
if (isHermesExecutableToken(baseArgv[index])) {
|
|
executableCandidates.push(index)
|
|
}
|
|
}
|
|
let anchoredExecutable: number | undefined
|
|
for (const index of executableCandidates) {
|
|
if (findChatSubcommand(baseArgv.slice(index + 1)) !== -1) {
|
|
// Wrapper arguments can also be named "hermes"; the final match is the executable.
|
|
anchoredExecutable = index
|
|
}
|
|
}
|
|
const executableIndex = anchoredExecutable ?? executableCandidates.at(-1) ?? -1
|
|
if (executableIndex === -1) {
|
|
return null
|
|
}
|
|
let commandPrefix = baseArgv.slice(0, executableIndex + 1)
|
|
let assignmentCount = 0
|
|
while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(commandPrefix[assignmentCount] ?? '')) {
|
|
assignmentCount += 1
|
|
}
|
|
if (assignmentCount > 0) {
|
|
if (shell !== 'posix') {
|
|
return null
|
|
}
|
|
commandPrefix = ['env', ...commandPrefix]
|
|
}
|
|
const baseArgs = baseArgv.slice(executableIndex + 1)
|
|
const chatIndex = findChatSubcommand(baseArgs)
|
|
const baseArgsWithoutChat =
|
|
chatIndex === -1 ? baseArgs : baseArgs.filter((_, index) => index !== chatIndex)
|
|
const configuredChatIndex = findChatSubcommand(configuredArgv)
|
|
const configuredArgsWithoutChat =
|
|
configuredChatIndex === -1
|
|
? configuredArgv
|
|
: configuredArgv.filter((_, index) => index !== configuredChatIndex)
|
|
return [
|
|
...commandPrefix,
|
|
'chat',
|
|
QUERY_ARG_PLACEHOLDER,
|
|
...stripOrcaOwnedHermesArgs(baseArgsWithoutChat),
|
|
...stripOrcaOwnedHermesArgs(configuredArgsWithoutChat),
|
|
'--tui'
|
|
]
|
|
}
|
|
|
|
function buildQueryCommand(argv: string[], shell: AgentStartupShell): string {
|
|
if (shell !== 'posix') {
|
|
const invocation = buildShellCommandFromArgv(argv, 'powershell').replace(
|
|
quoteStartupArg(QUERY_ARG_PLACEHOLDER, 'powershell'),
|
|
`"--query=$${POWERSHELL_NATIVE_QUERY_VARIABLE}"`
|
|
)
|
|
// Why: startup prompts must not remain exported to Hermes tools; the
|
|
// long-lived parent shell retains the transport env for compatibility.
|
|
// PowerShell 5 needs Windows-native quote escaping before building child argv.
|
|
const script = `$${POWERSHELL_QUERY_VARIABLE} = $env:${ORCA_HERMES_STARTUP_QUERY_ENV}; $${POWERSHELL_NATIVE_QUERY_VARIABLE} = $${POWERSHELL_QUERY_VARIABLE} -replace '(\\\\*)"', '$1$1\\"'; Remove-Item Env:${ORCA_HERMES_STARTUP_QUERY_ENV} -ErrorAction SilentlyContinue; ${invocation}`
|
|
return `powershell.exe -NoProfile -EncodedCommand ${encodePowerShellCommand(script)}`
|
|
}
|
|
const invocation = buildShellCommandFromArgv(argv, 'posix').replace(
|
|
quoteStartupArg(QUERY_ARG_PLACEHOLDER, 'posix'),
|
|
`"--query=\${${POSIX_QUERY_VARIABLE}}"`
|
|
)
|
|
const encodedInvocation = encodePosixEvalScript(invocation)
|
|
// Why: a fixed single-quote-safe wrapper parses in POSIX shells and pwsh;
|
|
// the dynamic argv is decoded only after entering the known `sh` grammar.
|
|
const script = `${POSIX_QUERY_VARIABLE}="\${${ORCA_HERMES_STARTUP_QUERY_ENV}}"; unset ${ORCA_HERMES_STARTUP_QUERY_ENV}; eval "$(printf %b "${encodedInvocation}")"`
|
|
return `sh -c ${quoteStartupArg(script, 'posix')}`
|
|
}
|
|
|
|
export function planHermesStartupQuery(args: {
|
|
baseCommand: string
|
|
agentArgs?: string | null
|
|
prompt: string
|
|
agentEnv?: Record<string, string> | null
|
|
platform: NodeJS.Platform
|
|
shell: AgentStartupShell
|
|
isRemote?: boolean
|
|
}): { command: string; env: Record<string, string> } | null {
|
|
const baseArgv = tokenizeCommand(args.baseCommand, args.shell)
|
|
const configuredArgv = args.agentArgs?.trim() ? tokenizeCommand(args.agentArgs, args.shell) : []
|
|
if (!baseArgv || !configuredArgv) {
|
|
return null
|
|
}
|
|
const argv = normalizeHermesArgv(baseArgv, configuredArgv, args.shell)
|
|
if (!argv) {
|
|
return null
|
|
}
|
|
const command = buildQueryCommand(argv, args.shell)
|
|
const env = { ...args.agentEnv, [ORCA_HERMES_STARTUP_QUERY_ENV]: args.prompt }
|
|
const envSize = Object.entries(env).reduce((total, [key, value]) => {
|
|
if (args.platform === 'win32') {
|
|
return total + key.length + value.length + 2
|
|
}
|
|
return total + new TextEncoder().encode(`${key}=${value}`).byteLength + 1
|
|
}, 0)
|
|
const commandSize =
|
|
args.platform === 'win32' ? command.length : new TextEncoder().encode(command).byteLength
|
|
// Why: WSL plans execute as Linux but cross the Windows environment block;
|
|
// the conservative shared bound is safe for every transport host.
|
|
return commandSize + envSize <= QUERY_ENV_LIMIT ? { command, env } : null
|
|
}
|