Files
orca/src/shared/ai-vault-resume-command.ts
Brennan Benson efb4050f4d fix(ai-vault): index Cline sessions (#16814)
* fix(ai-vault): index Cline sessions

* fix(ai-vault): constrain Cline session discovery
2026-08-27 15:33:26 -07:00

271 lines
10 KiB
TypeScript

// Resume-command construction for Agent Session History rows: turns a scanned
// session into the shell line that re-enters it, quoted for the target platform
// and (when known) the live tab's shell.
import { TUI_AGENT_CONFIG } from './tui-agent-config'
import {
clearEnvCommand,
commandSeparator,
isPosixStartupShell,
quoteStartupArg,
type AgentStartupShell,
withoutEnvCommand
} from './tui-agent-startup-shell'
import type { AiVaultAgent, AiVaultSession } from './ai-vault-types'
export function buildAiVaultResumeCommand(args: {
agent: AiVaultAgent
sessionId: string
cwd: string | null
platform: NodeJS.Platform
commandOverride?: string | null
codexHome?: string | null
resumeFilePath?: string | null
shell?: AgentStartupShell
clearEnvNames?: readonly string[]
}): string {
const { agent, sessionId, cwd, platform, commandOverride, codexHome, resumeFilePath, shell } =
args
const baseCommand = commandOverride?.trim() || defaultAiVaultResumeCommandBase(agent)
// Why: OMP's and Prime Agent's `--resume` accept an absolute transcript path,
// which resolves regardless of which session-dir root (custom
// OMP_CODING_AGENT_DIR / PRIME_AGENT_CODING_AGENT_DIR / WSL home) the file was
// discovered under, where an id-prefix lookup scoped to the default store
// would miss it. Falls back to the id if no path is known.
const resumeTarget =
(agent === 'omp' || agent === 'prime-agent') && resumeFilePath?.trim()
? resumeFilePath.trim()
: sessionId
const sessionArg =
shell === 'cmd'
? quoteWindowsCmdArg(resumeTarget)
: shell
? quoteStartupArg(resumeTarget, shell)
: quoteShellArg(resumeTarget, platform)
const resumeCommand = buildAgentResumeInvocation(agent, baseCommand, sessionArg)
return buildAiVaultResumeShellCommand({
resumeCommand,
cwd,
platform,
codexHome,
shell,
clearEnvNames: args.clearEnvNames
})
}
export function buildAiVaultResumeShellCommand(args: {
resumeCommand: string
cwd: string | null
platform: NodeJS.Platform
codexHome?: string | null
/** Env names the agent must not inherit. Applied as a prefix on the agent
* itself, never on the whole `cd … && agent` chain — `cd` is a shell builtin
* that `env` cannot run, and a child `cd` would not move the agent anyway. */
clearEnvNames?: readonly string[]
// Why: the QUEUED resume command is typed into the live tab shell, so its
// cd/env prefix must match that shell. Shell-less persisted commands keep the
// legacy self-contained `cmd /d /s /c` wrapper.
shell?: AgentStartupShell
}): string {
const { cwd, platform, codexHome, shell, clearEnvNames } = args
// Why: shell-aware commands are parsed by a known running shell, while
// shell-less persisted commands keep the legacy self-contained cmd wrapper.
// PowerShell routes here whatever the platform: it is the shell, not the
// host, that decides which grammar the line has to be written in.
if (shell === 'powershell' || (platform === 'win32' && shell && shell !== 'cmd')) {
return buildResumeShellCommandForShell({
resumeCommand: args.resumeCommand,
cwd,
codexHome: codexHome?.trim() || null,
shell,
clearEnvNames
})
}
const resolvedCodexHome = codexHome?.trim() || null
// Why filter: the prefix and the removal name the same variable, and `env -u`
// strips what the assignment just set, so an unfiltered list would silently
// resume against the real home. Keeping the assignment authoritative matches
// the old `clear…; CODEX_HOME=x agent` ordering.
const clearNames = resolvedCodexHome
? clearEnvNames?.filter((name) => name !== 'CODEX_HOME')
: clearEnvNames
// Why the two placements differ: `set -u` aborts on the unbound `$fish_pid`
// the POSIX clear statement has to test, so there it must not precede the
// agent — `env -u` carries the removal on the agent itself. cmd has no such
// hazard, and keeping its clear ahead of the `cd` preserves `cd … && agent`,
// so a failed `cd` still cannot run the agent in the wrong directory.
// Keyed on the shell, not the platform: the shell is what picks the grammar.
const dialect = shell ?? (platform === 'win32' ? 'cmd' : 'posix')
const clearsOnAgent = clearNames?.length && isPosixStartupShell(dialect)
const resumeCommand = `${codexHomeEnvPrefix(resolvedCodexHome, platform, shell)}${
clearsOnAgent ? withoutEnvCommand(clearNames, args.resumeCommand, dialect) : args.resumeCommand
}`
const clearPrefix =
clearNames?.length && !clearsOnAgent
? `${clearEnvCommand(clearNames, dialect)}${commandSeparator(dialect)}`
: ''
if (platform === 'win32' && shell === 'cmd') {
// Why: an interactive cmd splits the doubled quotes required by a nested
// `cmd /s /c` wrapper, so queued commands must use direct cmd syntax.
return `${clearPrefix}${cwd ? `cd /d ${quoteWindowsCmdArg(cwd)} && ${resumeCommand}` : resumeCommand}`
}
if (!cwd) {
return `${clearPrefix}${resumeCommand}`
}
if (platform === 'win32') {
const inner = `${clearPrefix}cd /d ${quoteWindowsCmdArg(cwd)} && ${resumeCommand}`
return `cmd /d /s /c ${quoteWindowsCmdArg(inner)}`
}
return `cd ${quoteResumeArg(cwd, platform, shell)} && ${resumeCommand}`
}
function buildResumeShellCommandForShell(args: {
resumeCommand: string
cwd: string | null
codexHome: string | null
shell: Exclude<AgentStartupShell, 'cmd'>
clearEnvNames?: readonly string[]
}): string {
const { cwd, codexHome, shell, clearEnvNames } = args
if (isPosixStartupShell(shell)) {
// Why: git-bash on a Windows host runs a POSIX shell, so reuse the same
// inline-env + `cd '<cwd>'` prefix as the non-Windows path.
const envPrefix = codexHome ? `CODEX_HOME=${quoteStartupArg(codexHome, shell)} ` : ''
// Why filter: see the twin in buildAiVaultResumeShellCommand — `env -u`
// would strip the home the prefix just set.
const clearNames = codexHome
? clearEnvNames?.filter((name) => name !== 'CODEX_HOME')
: clearEnvNames
const command = `${envPrefix}${
clearNames?.length
? withoutEnvCommand(clearNames, args.resumeCommand, shell)
: args.resumeCommand
}`
return cwd ? `cd ${quoteStartupArg(cwd, shell)} && ${command}` : command
}
const separator = commandSeparator(shell)
const segments: string[] = []
// Why ahead of Set-Location: PowerShell has no `set -u` expansion hazard, so
// the removal keeps its original leading position.
if (clearEnvNames?.length) {
segments.push(clearEnvCommand(clearEnvNames, shell))
}
if (cwd) {
segments.push(`Set-Location -LiteralPath ${quoteStartupArg(cwd, shell)}`)
}
if (codexHome) {
segments.push(`$env:CODEX_HOME=${quoteStartupArg(codexHome, shell)}`)
}
segments.push(args.resumeCommand)
return segments.join(separator)
}
// Why: a bare real-home resume carries no CODEX_HOME prefix, so every surface
// that spawns the pane must drop account-routed or daemon-inherited Codex
// homes from its env, not only patch a sparse env on top.
export function realHomeCodexResumeEnvDeletion(
session: Pick<AiVaultSession, 'agent' | 'codexHome'>
): { envToDelete: string[] } | Record<string, never> {
if (session.agent !== 'codex' || session.codexHome !== null) {
return {}
}
return { envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'] }
}
function defaultAiVaultResumeCommandBase(agent: AiVaultAgent): string {
if (agent === 'cursor') {
return 'cursor-agent'
}
if (agent === 'hermes') {
return 'hermes'
}
if (agent === 'rovo') {
return 'acli'
}
return TUI_AGENT_CONFIG[agent].detectCmd
}
function buildAgentResumeInvocation(
agent: AiVaultAgent,
baseCommand: string,
sessionArg: string
): string {
switch (agent) {
case 'codex':
return `${baseCommand} resume ${sessionArg}`
case 'rovo':
return `${baseCommand} rovodev run --restore ${sessionArg}`
case 'opencode':
case 'pi':
// Why: Kimi Code resumes with `kimi --session <id>` (alias `-S`). Sessions
// are work-dir-scoped, so the cwd prefix from buildAiVaultResumeCommand is
// required — resuming from another directory is rejected by the CLI.
// falls through
case 'kimi':
return `${baseCommand} --session ${sessionArg}`
case 'copilot':
return `${baseCommand} --resume=${sessionArg}`
case 'cline':
return `${baseCommand} --id ${sessionArg}`
case 'claude':
case 'cursor':
case 'gemini':
case 'grok':
case 'hermes':
case 'devin':
case 'openclaw':
case 'droid':
// Why: OMP and Prime Agent resume by absolute transcript path (see
// buildAiVaultResumeCommand), but the `--resume <arg>` invocation form is
// identical to the others here.
// falls through
case 'omp':
case 'prime-agent':
return `${baseCommand} --resume ${sessionArg}`
case 'antigravity':
return `${baseCommand} --conversation ${sessionArg}`
}
}
function codexHomeEnvPrefix(
codexHome: string | null,
platform: NodeJS.Platform,
shell?: AgentStartupShell
): string {
if (!codexHome) {
return ''
}
if (platform === 'win32') {
return `set ${quoteWindowsCmdArg(`CODEX_HOME=${codexHome}`)} && `
}
// fish accepts the `NAME=value cmd` prefix (3.1+), but not sh's quoting.
return `CODEX_HOME=${quoteResumeArg(codexHome, platform, shell)} `
}
/** Quotes for the live shell when one is known, else for the platform's default. */
function quoteResumeArg(
value: string,
platform: NodeJS.Platform,
shell?: AgentStartupShell
): string {
return shell ? quoteStartupArg(value, shell) : quoteShellArg(value, platform)
}
/** Why not the sh `'\''` idiom here: this is the same resume command the shell
* branch above builds, and fish reads that idiom differently — it would halve
* backslashes in a path and reject a trailing one. Deferring to
* `quoteStartupArg` keeps one spelling regardless of whether a caller happened
* to pass a shell. */
function quoteShellArg(value: string, platform: NodeJS.Platform): string {
return platform === 'win32' ? quoteWindowsCmdArg(value) : quoteStartupArg(value, 'posix')
}
function quoteWindowsCmdArg(value: string): string {
return `"${value.replace(/"/g, '""')}"`
}