mirror of
https://github.com/stablyai/orca.git
synced 2026-10-06 08:02:28 +00:00
Orca had to guess which shell would parse a queued command line, then emit syntax for it. Guessing is unreliable for a remote or WSL host, and every dialect-dependent function is a place to get it wrong. Replace the guess. Everything emitted for a Unix shell is now built to be correct in sh, bash, zsh, dash, ksh and fish alike, so no detection is needed: - quoteStartupArg emits backslashes as "\\" and apostrophes as "'" between single-quoted runs. Both families read that identically, unlike the sh '\'' idiom, which fish silently halves and which makes a trailing backslash a hard syntax error. - clearEnvCommand emits a self-contained fish/sh branch. It deliberately does NOT call a helper defined by Orca's shell wrappers: Orca wraps only zsh, bash and fish, so an `sh`/`dash`/`ksh` login shell launches unwrapped — and the same text is copied to the clipboard and pasted into shells Orca never spawned. In both, a helper would be `command not found`, which is the exact failure this exists to avoid. Two guarded statements rather than `A && B || C`, because fish's `set -e` returns non-zero for an already-unset variable and would fall through to the sh branch; a trailing `true` pins the status, since this is the last statement of a launch line and the prompt renders it. - One tokenizer for Unix. The input is a settings string the shell never parses, so parsing it per-shell only made the same setting mean different things in different workspaces. AgentStartupShell loses its 'fish' and 'unix' members, and the three login-shell resolvers, the fish tokenizer and the agentEnv.SHELL probe go with them. Per-worktree shell history now actually works: - zsh on macOS was a no-op. /etc/zshrc assigns HISTFILE unconditionally before any wrapper Orca controls, so the injected value was already gone — and with ZDOTDIR still pointing at Orca's wrapper dir, history landed inside it. The intended path rides ORCA_HISTFILE and is restored after user config. Fixes #11044. - fish keeps history in its own data dir keyed by session name, since it ignores HISTFILE and has no custom-directory knob. Files are deleted rather than truncated, a symlinked ~/.local/share no longer disables cleanup, and a GC sweep reclaims orphans whose meta.json is gone. The sweep refuses an empty live-worktree set (indistinguishable from a store that failed to hydrate) and skips files younger than GC_MIN_AGE_MS, mirroring the tree GC's guard against the live-set snapshot race. Verified against real shells rather than asserted as strings: startup-shell-portability.live-shell.test.ts runs 194 assertions across sh/bash/zsh/dash/ksh/fish, and zsh-scoped-histfile.live-shell.test.ts drives a real login zsh through /etc/zshrc. Both are vacuity-checked. The same quoting corpus was replayed byte-exact on Linux, where /bin/sh is dash.
170 lines
6.7 KiB
TypeScript
170 lines
6.7 KiB
TypeScript
import type { ResumableTuiAgent } from './agent-session-resume'
|
|
import {
|
|
isPosixStartupShell,
|
|
quoteStartupArg,
|
|
tokenizeStartupCommand,
|
|
type AgentStartupShell
|
|
} from './tui-agent-startup-shell'
|
|
|
|
function isClaudeResumeSelector(token: string): boolean {
|
|
if (token === '--resume' || token.startsWith('--resume=')) {
|
|
return true
|
|
}
|
|
if (token === '--continue' || token.startsWith('--continue=')) {
|
|
return true
|
|
}
|
|
// Why: the joined -r<id> form is deliberately NOT matched — any `-r…` token
|
|
// is ambiguous with another option's dash-leading value (`--agent -review`),
|
|
// and no arity table can keep up with the CLI. Only exact selector shapes
|
|
// are stripped; a persisted joined form degrades to pre-guard behavior.
|
|
return token === '-r' || token.startsWith('-r=') || token === '-c' || token.startsWith('-c=')
|
|
}
|
|
|
|
function isClaudeExecutableToken(token: string): boolean {
|
|
const base = token.split(/[\\/]/).pop() ?? ''
|
|
return /^claude(\.(exe|cmd|bat|ps1))?$/i.test(base)
|
|
}
|
|
|
|
/** Accepts a claude token only in command position — index 0, right after a
|
|
* wrapper's `--`, behind PowerShell's `&` call operator, or preceded solely by
|
|
* NAME=value assignments — so an argument that merely ends in /claude (an ssh
|
|
* key, a project dir) can never be mistaken for the executable. */
|
|
function findClaudeExecutableIndex(tokens: readonly string[], shell: AgentStartupShell): number {
|
|
let commandPosition = true
|
|
for (let i = 0; i < tokens.length; i += 1) {
|
|
const token = tokens[i]
|
|
if (commandPosition) {
|
|
if (isClaudeExecutableToken(token)) {
|
|
return i
|
|
}
|
|
if (
|
|
// Why: `NAME=value cmd` is sh-family syntax (fish included, 3.1+); on
|
|
// cmd/PowerShell such a token is a bogus executable name, not a prefix.
|
|
(isPosixStartupShell(shell) && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) ||
|
|
(shell === 'powershell' && token === '&' && i === 0)
|
|
) {
|
|
continue
|
|
}
|
|
commandPosition = false
|
|
}
|
|
if (token === '--') {
|
|
commandPosition = true
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
/** Joins the resolved base command with the agent's resume argv. Claude goes
|
|
* through the selector guard below; other agents keep plain appending. */
|
|
export function buildAgentResumeLaunchCommand(
|
|
agent: ResumableTuiAgent,
|
|
baseCommand: string,
|
|
resumeArgv: readonly string[],
|
|
shell: AgentStartupShell
|
|
): string {
|
|
const argv = resumeArgv.slice(1)
|
|
if (agent === 'claude') {
|
|
return buildClaudeResumeLaunchCommand(baseCommand, argv, shell)
|
|
}
|
|
const resumeArgs = argv.map((arg) => quoteStartupArg(arg, shell)).join(' ')
|
|
return resumeArgs ? `${baseCommand} ${resumeArgs}` : baseCommand
|
|
}
|
|
|
|
/** Builds the Claude cold-restore launch command: strips any resume/continue
|
|
* selector the user's persisted command carries and appends exactly one
|
|
* authoritative selector, so a stale or bare selector can never compete with
|
|
* the provider session id (#12982).
|
|
*
|
|
* Fails open by design: when the base command cannot be tokenized, or no
|
|
* claude executable token can be located (wrapper commands like
|
|
* `bash -c claude`), the base is left byte-for-byte untouched and the
|
|
* selector is appended, which is the pre-guard behavior. Bytes outside
|
|
* removed selector tokens are always preserved verbatim — the base is
|
|
* spliced by source span, never re-quoted. */
|
|
export function buildClaudeResumeLaunchCommand(
|
|
baseCommand: string,
|
|
resumeArgs: readonly string[],
|
|
shell: AgentStartupShell
|
|
): string {
|
|
const quotedResume = resumeArgs.map((arg) => quoteStartupArg(arg, shell)).join(' ')
|
|
if (!quotedResume) {
|
|
return baseCommand
|
|
}
|
|
const appended = `${baseCommand} ${quotedResume}`
|
|
const tokenized = tokenizeStartupCommand(baseCommand, shell)
|
|
if (!tokenized.ok) {
|
|
return appended
|
|
}
|
|
const { tokens, spans } = tokenized
|
|
const claudeIndex = findClaudeExecutableIndex(tokens, shell)
|
|
if (claudeIndex === -1) {
|
|
return appended
|
|
}
|
|
// Why: any token the tokenizer cannot model for this shell — an operator,
|
|
// comment, expansion, or cmd single-quoted region — means the splice could
|
|
// cut live syntax or misread a literal as a selector. The whole base must
|
|
// be modelable, including the executable itself; only PowerShell's leading
|
|
// call operator is a known-safe divergent token.
|
|
for (let i = 0; i <= tokens.length; i += 1) {
|
|
const gapStart = i === 0 ? 0 : spans[i - 1].end
|
|
const gapEnd = i === tokens.length ? baseCommand.length : spans[i].start
|
|
if (!/^[ \t]*$/.test(baseCommand.slice(gapStart, gapEnd))) {
|
|
return appended
|
|
}
|
|
if (i === tokens.length) {
|
|
break
|
|
}
|
|
// Why: a bare `--%` makes PowerShell pass the rest of the line to the
|
|
// child literally, so appended quoting would arrive as literal bytes. A
|
|
// quoted `--%` can also stop parsing, but only before a parameter token,
|
|
// where the base is already mangled with or without the guard.
|
|
if (shell === 'powershell' && baseCommand.slice(spans[i].start, spans[i].end) === '--%') {
|
|
return appended
|
|
}
|
|
if (spans[i].divergesFromShell) {
|
|
const isCallOperator = shell === 'powershell' && i === 0 && tokens[i] === '&'
|
|
if (!isCallOperator) {
|
|
return appended
|
|
}
|
|
}
|
|
}
|
|
const cuts: { start: number; end: number }[] = []
|
|
let terminatorStart: number | null = null
|
|
for (let i = claudeIndex + 1; i < tokens.length; i += 1) {
|
|
const token = tokens[i]
|
|
if (token === '--') {
|
|
// Why: claude is the executable here, so `--` is claude's own
|
|
// terminator; the selector must stay in option position before it.
|
|
// Span-splice equivalent of insertBeforeTerminator in
|
|
// tui-agent-launch-command.ts, which re-quotes and cannot be reused.
|
|
terminatorStart = spans[i].start
|
|
break
|
|
}
|
|
if (!isClaudeResumeSelector(token)) {
|
|
continue
|
|
}
|
|
// Why: absorb the separator before the selector, but never cross into the
|
|
// previous token, whose span can end with an escaped-space byte.
|
|
let start = spans[i].start
|
|
while (start > spans[i - 1].end && ' \t'.includes(baseCommand[start - 1])) {
|
|
start -= 1
|
|
}
|
|
let end = spans[i].end
|
|
const next = tokens[i + 1]
|
|
if ((token === '--resume' || token === '-r') && next !== undefined && !next.startsWith('-')) {
|
|
// A stale session locator rides along with its selector.
|
|
end = spans[i + 1].end
|
|
i += 1
|
|
}
|
|
cuts.push({ start, end })
|
|
}
|
|
let result = baseCommand
|
|
if (terminatorStart !== null) {
|
|
result = `${result.slice(0, terminatorStart)}${quotedResume} ${result.slice(terminatorStart)}`
|
|
}
|
|
for (let i = cuts.length - 1; i >= 0; i -= 1) {
|
|
result = `${result.slice(0, cuts[i].start)}${result.slice(cuts[i].end)}`
|
|
}
|
|
return terminatorStart !== null ? result : `${result} ${quotedResume}`
|
|
}
|