Files
orca/src/main/daemon/shell-ready.ts
T
Jinwoo HongandClaude Opus 5.5 ae41eb414a fix(terminal): give plain fish tabs Orca's codex function without changing fish's startup (#24284)
* fix(terminal): give plain fish tabs Orca's codex function without changing fish's startup

A `codex` typed into a plain fish tab ran without --no-daemon because only
wrapped fish tabs (startup command / ready marker) got Orca's codex function.

Plain fish spawns now prepend an Orca data dir to XDG_DATA_DIRS and record the
exact prefix in ORCA_FISH_XDG_DATA_DIRS_PREFIX. Fish sources the dir's
fish/vendor_conf.d snippet, which first restores XDG_DATA_DIRS (unset again if it
was unset), erases the marker, drops its dir from fish's derived vendor/function/
completion paths, then defines the shared fish codex function at the first prompt
so the user's config.fish still wins. fish argv is unchanged; wrapped tabs keep
their existing -C path. A local fallback to another shell restores the user's
XDG_DATA_DIRS instead of deleting it.

Bumps the terminal daemon protocol to v39 so new tabs move to a daemon that
injects the env; v38 owners stay attachable.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(fish): skip the XDG handoff for -N/--no-config and empty XDG_DATA_DIRS

fish never reads vendor_conf.d under -N/--no-config (also abbreviated or
clustered), so the snippet could not undo the prefix; and the restore cannot
tell an empty XDG_DATA_DIRS from an unset one. Both now launch untouched.
Run the real-fish handoff tests in the shell contracts job, where fish is
required, so they no longer skip in CI.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(fish): compare the unset-restore case against a fish without Orca

Ubuntu runners ship snapd's fish vendor snippet, which sets XDG_DATA_DIRS on
every fish start, so "unset" was never the right oracle there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(fish): treat an empty XDG_DATA_DIRS like unset so the tab still gets the codex hook

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(pty): put back the user's own launch env on a shell fallback

The primary shell's launch config now records the pre-launch value of each
key it writes. A fallback shell restores those values (unsetting keys that
had none) instead of deleting the keys, which hands back an inherited
XDG_DATA_DIRS after a fish fallback and an inherited ZDOTDIR after a
zsh->bash fallback, with no per-shell special case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(fish): drop the Node restore twin and simplify the vendor snippet

- Remove restoreFishXdgDataDirs; the generic fallback restore covers it.
- Snippet: read ":$XDG_DATA_DIRS:" directly and filter Orca's vendor dirs
  with one string match per variable.
- Require inheritedXdgDataDirs in both getShellLaunchConfig option shapes.
- Drop the test-only FISH_XDG_DATA_DIRS_HANDOFF_DAEMON_PROTOCOL_VERSION.
- Fix stale fish comments and trim redundant -N launch cases.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(fish): stop scrubbing fish's lookup paths after the handoff

Only XDG_DATA_DIRS is restored, by exact prefix; Orca's dir holds nothing but this snippet, so leaving it on fish's derived paths loads nothing else and drops the glob match.

* docs(fish): drop the comment for the removed vendor-dir cleanup

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 15:38:53 -04:00

215 lines
7.5 KiB
TypeScript

import { tmpdir } from 'node:os'
import { basename, join, win32 as pathWin32 } from 'node:path'
import { statSync } from 'node:fs'
import {
encodePowerShellCommand,
getPowerShellOsc133Bootstrap,
isPowerShellExecutableName
} from '../powershell-osc133-bootstrap'
import { getFishCodexShellLaunchPreflight } from '../../shared/codex-shell-function'
import { getFishXdgDataDirsLaunchEnv } from '../fish-xdg-data-dirs-handoff'
import { getFishShellReadyInitCommand } from '../shell-templates'
import {
encodeShellStartupFeatures,
SHELL_STARTUP_FEATURE_ENV,
type ShellStartupFeature
} from '../shell-startup-features'
import { resolveShellWrapperRoot } from '../shell-wrapper-content-address'
import { writeShellWrapperFiles } from '../shell-wrapper-file-writer'
import {
buildDaemonShellReadyWrapperFiles,
getDaemonShellReadyWrapperPaths
} from './daemon-shell-ready-wrapper-fileset'
import { inheritedZdotdirEnv, resolveInheritedZdotdir } from '../zsh-wrapper-dir-ownership'
import { SHELL_READY_MARKER } from './daemon-shell-ready-marker'
const ORCA_USER_DATA_PATH_ENV = 'ORCA_USER_DATA_PATH'
function getShellReadyWrapperBaseDir(): string {
const userDataPath = process.env[ORCA_USER_DATA_PATH_ENV]
// Why a base dir of its own rather than the legacy `shell-ready/`: daemons of
// older builds still write that path unconditionally, so leaving it to them
// keeps this build's content-addressed trees out of their reach.
// Why the tmpdir fallback: older/test launchers may not seed
// ORCA_USER_DATA_PATH, and daemon startup must not fail before the parent can
// be fixed. It is dev/test-only -- daemon-init always passes the real path --
// which matters because the presence check is size-only, so a complete tree
// pre-planted under a shared /tmp would be trusted rather than overwritten.
return join(userDataPath || tmpdir(), userDataPath ? 'shell-wrappers' : 'orca-shell-wrappers')
}
// Why memoized and keyed on the base dir: the digest is stable for a given base
// dir, every shell launch asks for it, and the key self-invalidates if
// ORCA_USER_DATA_PATH is ever re-pointed mid-process.
let cachedShellReadyWrapperRoot: { baseDir: string; root: string } | null = null
export function getShellReadyWrapperRoot(): string {
const baseDir = getShellReadyWrapperBaseDir()
if (cachedShellReadyWrapperRoot?.baseDir !== baseDir) {
cachedShellReadyWrapperRoot = {
baseDir,
root: resolveShellWrapperRoot(baseDir, buildDaemonShellReadyWrapperFiles)
}
}
return cachedShellReadyWrapperRoot.root
}
function getRequiredShellReadyWrapperPaths(root = getShellReadyWrapperRoot()): readonly string[] {
return getDaemonShellReadyWrapperPaths(root)
}
// Why non-empty and not just present: a partial write leaves a zero-byte
// .zshenv, and pointing ZDOTDIR at that dir makes zsh skip the user's config.
function shellReadyWrappersExist(): boolean {
return getRequiredShellReadyWrapperPaths().every((path) => {
try {
return statSync(path).size > 0
} catch {
return false
}
})
}
/** True when every wrapper file is present and non-empty afterwards. */
function ensureShellReadyWrappers(): boolean {
if (process.platform === 'win32') {
return false
}
const root = getShellReadyWrapperRoot()
// Why existence alone decides, with no per-process flag: the root is keyed by
// a hash of the exact bytes we would write, so a tree that is present and
// non-empty is a tree this build wrote. Rewriting it would replace a live file
// on the terminal-spawn path for no gain.
if (!shellReadyWrappersExist()) {
const written = writeShellWrapperFiles(
buildDaemonShellReadyWrapperFiles(root),
'[daemon/shell-ready]'
)
if (!written || !shellReadyWrappersExist()) {
// Why no flag to reset: the next launch re-checks the files themselves, so
// a half-written tree is retried without any extra bookkeeping.
return false
}
}
return true
}
export function resolvePtyShellPath(env: Record<string, string>): string {
if (process.platform === 'win32') {
return env.ORCA_TERMINAL_WINDOWS_SHELL || 'powershell.exe'
}
return env.SHELL || process.env.SHELL || '/bin/zsh'
}
export function shellPathSupportsPtyStartupBarrier(shellPath: string): boolean {
const shellName = pathWin32.basename(basename(shellPath)).toLowerCase()
// Why fish: markerless, its startup command is written before fish's reader owns
// the PTY and the launch is lost under slow prompts like Starship (STA-3417).
return shellName === 'zsh' || shellName === 'bash' || shellName === 'fish'
}
export type ShellLaunchConfig = {
args: string[] | null
env: Record<string, string>
supportsReadyMarker: boolean
}
const UNWRAPPED: ShellLaunchConfig = {
args: null,
env: {},
supportsReadyMarker: false
}
/**
* The one launch-config entry point: args + env for a shell that should start
* with exactly `features` enabled. An empty selection is never wrapped.
*/
export function getShellLaunchConfig(
shellPath: string,
features: readonly ShellStartupFeature[],
options?: {
hasStartupCommand?: boolean
inheritedXdgDataDirs: string | undefined
shellArgs?: readonly string[]
}
): ShellLaunchConfig {
const shellName = pathWin32.basename(basename(shellPath)).toLowerCase()
if (shellName === 'zsh') {
if (features.length === 0) {
return UNWRAPPED
}
if (!ensureShellReadyWrappers()) {
// Why plain login zsh: ZDOTDIR pointed at an incomplete wrapper dir makes
// zsh skip the user's whole config. Losing Orca's features is recoverable.
return { args: ['-l'], env: {}, supportsReadyMarker: false }
}
return {
args: ['-l'],
env: {
...inheritedZdotdirEnv(resolveInheritedZdotdir(process.env)),
ZDOTDIR: join(getShellReadyWrapperRoot(), 'zsh'),
[SHELL_STARTUP_FEATURE_ENV]: encodeShellStartupFeatures(features)
},
supportsReadyMarker: features.includes('ready')
}
}
if (shellName === 'bash') {
if (features.length === 0 || !ensureShellReadyWrappers()) {
return UNWRAPPED
}
return {
args: ['--rcfile', join(getShellReadyWrapperRoot(), 'bash', 'rcfile')],
env: {
[SHELL_STARTUP_FEATURE_ENV]: encodeShellStartupFeatures(features)
},
supportsReadyMarker: features.includes('ready')
}
}
if (isPowerShellExecutableName(shellName)) {
return {
args: [
'-NoLogo',
'-NoExit',
// Why base64 and not -Command: see powershell-osc133-bootstrap.ts (MDE review).
'-EncodedCommand',
encodePowerShellCommand(getPowerShellOsc133Bootstrap())
],
env: {},
supportsReadyMarker: false
}
}
// Why: mirrors local-pty-shell-ready.ts; only these need the -C init, plain fish gets
// the env-only handoff below. The selection is baked in, so no feature env var.
if (shellName === 'fish' && (features.includes('ready') || options?.hasStartupCommand)) {
return {
args: [
'-l',
'-C',
`${getFishShellReadyInitCommand(SHELL_READY_MARKER, features.includes('ready'))}\n${getFishCodexShellLaunchPreflight()}`
],
env: {},
supportsReadyMarker: features.includes('ready')
}
}
// Why env only: mirrors local-pty-shell-ready.ts; a plain fish pane keeps fish's own argv.
if (shellName === 'fish' && ensureShellReadyWrappers()) {
return {
args: null,
env: getFishXdgDataDirsLaunchEnv(
getShellReadyWrapperRoot(),
options?.inheritedXdgDataDirs,
options?.shellArgs
),
supportsReadyMarker: false
}
}
return UNWRAPPED
}