Use managed Pi and OMP extensions (#5681)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-17 23:28:54 -07:00 committed by GitHub
parent 3bd7b36012
commit 84a5def248
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 493 additions and 905 deletions

View File

@ -15,8 +15,8 @@ export { parsePtySessionId } from '../../shared/pty-session-id-format'
*
* Both pty.ts (host-daemon spawn path) and DaemonPtyAdapter.doSpawn
* (fallback when opts.sessionId is absent) must use this helper a
* drifted format would break cold-restore mapping and Pi overlay
* keying.
* drifted format would break cold-restore mapping and legacy Pi overlay
* cleanup keying.
*/
export function mintPtySessionId(worktreeId?: string): string {
return worktreeId
@ -25,18 +25,17 @@ export function mintPtySessionId(worktreeId?: string): string {
}
/**
* Why: `effectiveSessionId` is used as a filesystem key (Pi overlay
* directory under app.getPath('userData')). The security property we
* want is containment: the derived overlay path must be strictly
* inside the userData root so a crafted IPC payload (args.sessionId
* or args.worktreeId forwarded from the renderer) cannot make us
* write overlay files outside userData.
* Why: `effectiveSessionId` is used as a filesystem key for provider hook
* state and legacy Pi overlay cleanup under app.getPath('userData'). The
* security property we want is containment: derived paths must be strictly
* inside the userData root so a crafted IPC payload (args.sessionId or
* args.worktreeId forwarded from the renderer) cannot make us write outside
* userData.
*
* Callers pass `app.getPath('userData')` as `userDataPath`. Any
* subpath inside userData is acceptable as a filesystem key since
* the Pi overlay path lives deeper inside userData enforcing
* "id cannot escape userData" is a superset of "id cannot escape Pi
* overlay root".
* subpath inside userData is acceptable as a filesystem key since callers use
* deeper roots under userData enforcing "id cannot escape userData" is a
* superset of the per-feature containment checks.
*
* Note: real worktreeIds are `${repo.id}::${absolutePath}` so minted
* session ids contain `/` on POSIX and `\` on Windows. Rejecting

View File

@ -864,7 +864,7 @@ describe('createPtySubprocess', () => {
expect(lastCall[2].env.ORCA_SHELL_READY_MARKER).toBe('0')
})
it('uses shell wrapper when Pi config must survive shell startup', () => {
it('uses shell wrapper when typed OMP commands need the status extension', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
@ -877,8 +877,7 @@ describe('createPtySubprocess', () => {
rows: 24,
env: {
SHELL: '/bin/zsh',
PI_CODING_AGENT_DIR: '/tmp/orca-pi-agent-overlay',
ORCA_PI_CODING_AGENT_DIR: '/tmp/orca-pi-agent-overlay'
ORCA_OMP_STATUS_EXTENSION: '/tmp/.omp/agent/extensions/orca-agent-status.ts'
}
})
} finally {

View File

@ -500,8 +500,8 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
if (opts.env?.TERM) {
env.TERM = opts.env.TERM
}
// Why: any Orca-injected overlay env that user rc files can clobber
// needs the wrapper so the post-rc restore line runs.
// Why: OpenCode/Codex path restoration and OMP's typed-command status
// wrapper need shell-ready code after user startup files run.
let shellLaunch: ReturnType<typeof getShellReadyLaunchConfig> | null = null
if (opts.command && isCodexStartupCommand) {
// Why: Codex needs the env-restoring wrapper, but waiting for a shell
@ -513,8 +513,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
shellLaunch =
env.ORCA_ATTRIBUTION_SHIM_DIR ||
env.ORCA_OPENCODE_CONFIG_DIR ||
env.ORCA_PI_CODING_AGENT_DIR ||
env.ORCA_OMP_CODING_AGENT_DIR ||
env.ORCA_OMP_STATUS_EXTENSION ||
env.ORCA_CODEX_HOME ||
env.ORCA_AGENT_TEAMS_SHIM_DIR
? getAttributionShellLaunchConfig(shellPath)

View File

@ -461,7 +461,7 @@ describePosix('daemon shell-ready launch config', () => {
15_000
)
it('writes wrappers that restore OpenCode and Pi config after user startup files', async () => {
it('writes wrappers without restoring Pi/OMP homes after user startup files', async () => {
const { getShellReadyLaunchConfig } = await importFreshShellReady()
getShellReadyLaunchConfig('/bin/zsh')
@ -472,31 +472,25 @@ describePosix('daemon shell-ready launch config', () => {
const bashRc = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8')
const restoreLine =
'[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"'
const piRestoreLine =
'[[ -n "${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="${ORCA_PI_CODING_AGENT_DIR}"'
const codexRestoreLine =
'[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"'
const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0'
const ompRestoreLine =
'if [[ -z "${ORCA_PI_CODING_AGENT_DIR:-}" && -n "${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then'
const ompWrapperLine = 'command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"'
expect(zshrc).toContain(restoreLine)
expect(zlogin).toContain(restoreLine)
expect(bashRc).toContain(restoreLine)
expect(zshrc).toContain(piRestoreLine)
expect(zlogin).toContain(piRestoreLine)
expect(bashRc).toContain(piRestoreLine)
expect(zshrc).not.toContain('ORCA_PI_CODING_AGENT_DIR')
expect(zlogin).not.toContain('ORCA_PI_CODING_AGENT_DIR')
expect(bashRc).not.toContain('ORCA_PI_CODING_AGENT_DIR')
expect(zshrc).toContain(codexRestoreLine)
expect(zlogin).toContain(codexRestoreLine)
expect(zshrc).toContain(agentTeamsPathRestoreLine)
expect(zlogin).toContain(agentTeamsPathRestoreLine)
expect(bashRc).toContain(agentTeamsPathRestoreLine)
expect(bashRc).toContain(codexRestoreLine)
// OMP launches use ORCA_OMP_CODING_AGENT_DIR; both restore lines must be
// present so a PTY of either kind has its overlay restored after rc files.
expect(zshrc).toContain(ompRestoreLine)
expect(zlogin).toContain(ompRestoreLine)
expect(bashRc).toContain(ompRestoreLine)
expect(zshrc).not.toContain('ORCA_OMP_CODING_AGENT_DIR')
expect(zlogin).not.toContain('ORCA_OMP_CODING_AGENT_DIR')
expect(bashRc).not.toContain('ORCA_OMP_CODING_AGENT_DIR')
expect(zshrc).toContain(ompWrapperLine)
expect(zlogin).toContain(ompWrapperLine)
expect(bashRc).toContain(ompWrapperLine)

View File

@ -114,12 +114,6 @@ __orca_restore_agent_teams_path
# Why: user startup files may set the default OpenCode config after Orca's
# spawn env; restore the Orca-managed config dir before the first prompt.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
# Why: bare shells carry both Pi and OMP shadows so a later typed OMP can
# switch on demand. Keep Pi as the shell default unless this PTY is OMP-only.
[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then
export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}"
fi
${getPosixOmpShellWrapper()}
# Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts.
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
@ -232,12 +226,6 @@ __orca_restore_agent_teams_path() {
if [[ ! -o login ]]; then
# Why: ~/.zshrc can export the user's default OpenCode config after spawn.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
# Why: bare shells carry both Pi and OMP shadows; keep Pi as the default and
# let the OMP wrapper switch to OMP only for that command.
[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then
export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}"
fi
${getPosixOmpShellWrapper()}
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
fi
@ -300,10 +288,6 @@ __orca_restore_agent_teams_path() {
__orca_restore_agent_teams_path
# Why: .zlogin is the final login startup file before the prompt is shown.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then
export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}"
fi
${getPosixOmpShellWrapper()}
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER)}

View File

@ -7,7 +7,7 @@ import { delimiter, join } from 'node:path'
const isWindowsHost = process.platform === 'win32'
const posixOnlyIt = isWindowsHost ? it.skip : it
const expectedOmpStatusExtension = join(
'/tmp/orca-pi-agent-overlay',
'/tmp/default-omp-agent',
'extensions',
'orca-agent-status.ts'
)
@ -293,11 +293,15 @@ describe('registerPtyHandlers', () => {
ORCA_AGENT_HOOK_TOKEN: 'agent-token'
})
piBuildPtyEnvMock.mockImplementation(
(_ptyId: string, existingAgentDir?: string, _kind?: string) => ({
PI_CODING_AGENT_DIR: existingAgentDir
? '/tmp/orca-pi-agent-overlay'
: '/tmp/orca-pi-agent-overlay'
})
(_ptyId: string, existingAgentDir?: string, kind?: string) =>
kind === 'omp'
? {
ORCA_OMP_SOURCE_AGENT_DIR: existingAgentDir ?? '/tmp/default-omp-agent',
ORCA_OMP_STATUS_EXTENSION: `${existingAgentDir ?? '/tmp/default-omp-agent'}/extensions/orca-agent-status.ts`
}
: {
ORCA_PI_SOURCE_AGENT_DIR: existingAgentDir ?? '/tmp/default-pi-agent'
}
)
isPwshAvailableMock.mockReturnValue(false)
spawnMock.mockReturnValue({
@ -518,7 +522,7 @@ describe('registerPtyHandlers', () => {
// Why: PR #2662 finding 2 — the threading from IPC `args.command` through
// buildPtyHostEnv to piTitlebarExtensionService.buildPtyEnv was untested
// for the OMP case because this helper never forwarded a command. Accept
// an optional `command` so callers can exercise OMP overlay resolution.
// an optional `command` so callers can exercise OMP target resolution.
command?: string
): Promise<Record<string, string>> {
const savedEnv: Record<string, string | undefined> = {}
@ -769,18 +773,20 @@ describe('registerPtyHandlers', () => {
}
)
it('injects the Pi agent overlay env into Orca terminal PTYs', async () => {
it('installs Pi managed extensions without redirecting Orca terminal PTY homes', async () => {
const env = await spawnAndGetEnv(undefined, { PI_CODING_AGENT_DIR: '/tmp/user-pi-agent' })
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/tmp/user-pi-agent', 'pi')
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'omp')
expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.ORCA_PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/user-pi-agent')
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/tmp/user-pi-agent')
expect(env.ORCA_OMP_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(expectedOmpStatusExtension)
expect(env.ORCA_OMP_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(
'/tmp/default-omp-agent/extensions/orca-agent-status.ts'
)
})
it('threads command: "omp" through to piBuildPtyEnv and emits ORCA_OMP_* shadow vars', async () => {
it('threads command: "omp" through to piBuildPtyEnv and emits OMP status metadata', async () => {
// Why: OMP launches must emit OMP-named Orca shadow vars (ORCA_OMP_*),
// not Pi-named ones. The PI_CODING_AGENT_DIR binary var is unavoidable
// (OMP's own binary reads it — see C:\tmp\pr-workspace\oh-my-pi
@ -798,9 +804,11 @@ describe('registerPtyHandlers', () => {
'/tmp/user-omp-agent',
'omp'
)
expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.ORCA_OMP_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(expectedOmpStatusExtension)
expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/user-omp-agent')
expect(env.ORCA_OMP_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(
'/tmp/user-omp-agent/extensions/orca-agent-status.ts'
)
expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBe('/tmp/user-omp-agent')
// CRITICAL: a Pi-named shadow MUST NOT leak into an OMP PTY env.
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
@ -813,8 +821,8 @@ describe('registerPtyHandlers', () => {
ORCA_PI_SOURCE_AGENT_DIR: '/tmp/user-pi-agent'
})
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/tmp/user-pi-agent', 'pi')
expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.ORCA_PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/parent-orca-pi-overlay')
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/tmp/user-pi-agent')
})
@ -832,8 +840,8 @@ describe('registerPtyHandlers', () => {
)
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'omp')
expect(env.ORCA_OMP_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBeUndefined()
expect(env.ORCA_OMP_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBe('/tmp/default-omp-agent')
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
})
@ -852,8 +860,8 @@ describe('registerPtyHandlers', () => {
)
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'pi')
expect(env.ORCA_PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/tmp/default-pi-agent')
expect(env.ORCA_OMP_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBeUndefined()
expect(env.ORCA_OMP_STATUS_EXTENSION).toBeUndefined()
@ -877,26 +885,29 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
})
posixOnlyIt('mirrors Pi config exported only by shell startup files', async () => {
readFileSyncMock.mockImplementation((path: string) =>
path.endsWith('.zshrc') ? 'export PI_CODING_AGENT_DIR="$HOME/.config/pi-agent"\n' : ''
)
posixOnlyIt(
'uses Pi config exported only by shell startup files as the managed extension target',
async () => {
readFileSyncMock.mockImplementation((path: string) =>
path.endsWith('.zshrc') ? 'export PI_CODING_AGENT_DIR="$HOME/.config/pi-agent"\n' : ''
)
const env = await spawnAndGetEnv(undefined, {
HOME: '/home/tester',
SHELL: '/bin/zsh',
PI_CODING_AGENT_DIR: undefined
})
const env = await spawnAndGetEnv(undefined, {
HOME: '/home/tester',
SHELL: '/bin/zsh',
PI_CODING_AGENT_DIR: undefined
})
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/home/tester/.config/pi-agent',
'pi'
)
expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.ORCA_PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/home/tester/.config/pi-agent')
})
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/home/tester/.config/pi-agent',
'pi'
)
expect(env.PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/home/tester/.config/pi-agent')
}
)
it('injects the agent hook receiver env into Orca terminal PTYs', async () => {
const env = await spawnAndGetEnv()
@ -1029,7 +1040,7 @@ describe('registerPtyHandlers', () => {
// never invoked, so every host-local env injection must happen inside
// the pty:spawn IPC handler instead. Before the refactor, only the
// hook server env and attribution shims were injected on this path;
// OpenCode plugin dir, Pi overlay, Codex home, and dev-mode CLI
// OpenCode plugin dir, Pi managed extension env, Codex home, and dev-mode CLI
// overrides were silently missing for daemon users (the common case).
function setupDaemonAdapter() {
@ -1113,7 +1124,7 @@ describe('registerPtyHandlers', () => {
},
processEnvOverrides?: Record<string, string | undefined>,
// Why: daemon spawn tests need to exercise both WSL launch metadata
// from main and PR #2662 command threading for OMP overlay selection.
// from main and PR #2662 command threading for OMP target selection.
spawnArgs?: {
cwd?: string
shellOverride?: string
@ -1217,21 +1228,18 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe('/user/custom/opencode')
})
it('injects Pi overlay env (PI_CODING_AGENT_DIR) on the daemon path', async () => {
it('installs Pi managed extensions without redirecting homes on the daemon path', async () => {
const env = await daemonSpawnAndGetEnv({ PI_CODING_AGENT_DIR: '/user/.pi/agent' })
// Why: asserts the overlay key was passed through — the id is the
// daemon-assigned sessionId minted in pty.ts, and the mock returns
// the fixed overlay path from the shared setup.
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), '/user/.pi/agent', 'pi')
expect(piBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined, 'omp')
expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.ORCA_PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.PI_CODING_AGENT_DIR).toBe('/user/.pi/agent')
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/user/.pi/agent')
expect(env.ORCA_OMP_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.ORCA_OMP_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(expectedOmpStatusExtension)
})
it('threads command: "omp" through to piBuildPtyEnv on the daemon path with OMP shadow vars', async () => {
it('threads command: "omp" through to piBuildPtyEnv on the daemon path with OMP status metadata', async () => {
// Why: mirror of the local-spawn OMP threading assertion. The
// daemon path's `command` forwarding could silently regress and
// Pi-only tests would still pass.
@ -1247,9 +1255,11 @@ describe('registerPtyHandlers', () => {
'/user/.omp/agent',
'omp'
)
expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.ORCA_OMP_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(expectedOmpStatusExtension)
expect(env.PI_CODING_AGENT_DIR).toBe('/user/.omp/agent')
expect(env.ORCA_OMP_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(
'/user/.omp/agent/extensions/orca-agent-status.ts'
)
expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBe('/user/.omp/agent')
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined()
@ -1686,7 +1696,7 @@ describe('registerPtyHandlers', () => {
// existing-agent-dir guard stays consistent whether Pi's env was
// carried on the IPC wire or inherited by the daemon via fork. The
// fallback must reach piTitlebarExtensionService.buildPtyEnv as the
// second arg so the overlay preserves the user's existing root.
// second arg so Orca installs managed extensions in the user's root.
const env = await daemonSpawnAndGetEnv({}, undefined, undefined, {
PI_CODING_AGENT_DIR: '/ambient/pi/agent'
})
@ -1695,8 +1705,9 @@ describe('registerPtyHandlers', () => {
'/ambient/pi/agent',
'pi'
)
expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.ORCA_PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(env.PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/ambient/pi/agent')
})
it('skips attribution shims on the daemon path when the setting is disabled', async () => {
@ -1749,10 +1760,10 @@ describe('registerPtyHandlers', () => {
})
it('sweeps per-PTY state when provider.spawn fails for a MINTED sessionId', async () => {
// Why: buildPtyHostEnv has filesystem side-effects (Pi overlay
// materialization). If provider.spawn later fails, the overlay would
// leak. The handler should clear per-PTY state for the minted id so
// it isn't orphaned.
// Why: buildPtyHostEnv has filesystem side-effects (Pi/OMP managed
// extension installation and legacy overlay cleanup). If provider.spawn
// later fails, per-PTY state for the minted id should be cleared so it
// isn't orphaned.
const daemonSpawn = vi.fn(async () => {
throw new Error('spawn boom')
})
@ -1778,8 +1789,9 @@ describe('registerPtyHandlers', () => {
it('does NOT sweep per-PTY state on provider.spawn failure for CALLER-supplied sessionId', async () => {
// Why: a caller-supplied sessionId may refer to an existing PTY whose
// state (OpenCode hooks, Pi overlay, agent-hook pane caches) must not
// be clobbered on a retry/attach failure. Only minted ids get swept.
// state (OpenCode hooks, legacy Pi overlay cleanup, agent-hook pane
// caches) must not be clobbered on a retry/attach failure. Only minted
// ids get swept.
const daemonSpawn = vi.fn(async () => {
throw new Error('spawn boom')
})
@ -4700,8 +4712,8 @@ describe('registerPtyHandlers', () => {
expect(args).toEqual(['-l'])
expect(options.env.OPENCODE_CONFIG_DIR).toBeUndefined()
expect(options.env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined()
expect(options.env.PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(options.env.ORCA_PI_CODING_AGENT_DIR).toBe('/tmp/orca-pi-agent-overlay')
expect(options.env.PI_CODING_AGENT_DIR).toBe('/tmp/user-pi-agent')
expect(options.env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(options.env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/tmp/user-pi-agent')
expect(options.env.ZDOTDIR).toBe('/tmp/orca-user-data/shell-ready/zsh')
expect(options.env.ORCA_SHELL_READY_MARKER).toBe('0')

View File

@ -19,7 +19,6 @@ import { openCodeHookService } from '../opencode/hook-service'
import { agentHookServer } from '../agent-hooks/server'
import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls'
import { piTitlebarExtensionService } from '../pi/titlebar-extension-service'
import { ORCA_PI_AGENT_STATUS_EXTENSION_FILE } from '../pi/agent-status-extension-source'
import { detectPiAgentKindFromCommand, type PiAgentKind } from '../../shared/pi-agent-kind'
import { isPwshAvailable } from '../pwsh'
import { LocalPtyProvider } from '../providers/local-pty-provider'
@ -347,11 +346,11 @@ function finishPtyShutdown(
// ─── Host PTY env assembly ──────────────────────────────────────────
// Why: both the LocalPtyProvider.buildSpawnEnv closure and the daemon-active
// fallback in pty:spawn need the same set of host-local env injections
// (OpenCode plugin dir, agent-hook server coordinates, Pi overlay, Codex
// account home, dev-mode CLI overrides, GitHub attribution shims). They used
// to be implemented twice, which silently drifted — daemon-backed PTYs never
// got the OpenCode plugin, Pi overlay, Codex home, or dev CLI PATH prepend,
// so status dots, Pi state, Codex account switching, and CLI→dev
// (OpenCode plugin dir, agent-hook server coordinates, Pi/OMP managed
// extensions, Codex account home, dev-mode CLI overrides, GitHub attribution
// shims). They used to be implemented twice, which silently drifted —
// daemon-backed PTYs never got the OpenCode plugin, Pi integration, Codex
// home, or dev CLI PATH prepend, so status dots, Pi state, Codex switching, and CLI→dev
// routing were all broken for daemon users (the common case).
//
// Centralizing the injections here makes future additions fail-safe: a new
@ -364,8 +363,8 @@ export type BuildPtyHostEnvOptions = {
skipCodexHomeEnv?: boolean
githubAttributionEnabled: boolean
/** The launch command the renderer chose for this PTY (e.g. 'pi', 'omp',
* 'claude'). Used to resolve the per-agent overlay source dir for Pi /
* OMP - both consume `PI_CODING_AGENT_DIR` but default to different
* 'claude'). Used to resolve the per-agent managed extension target for
* Pi / OMP - both consume `PI_CODING_AGENT_DIR` but default to different
* `~/.<kind>/agent` paths. Undefined for bare-shell spawns; defaults
* resolve to Pi for back-compat. NEVER infer from disk presence; that's
* the bug this option fixes (cross-agent shadowing when both dirs exist). */
@ -504,10 +503,6 @@ function resolveScopedPiAgentSourceDir(
return readEnvWithProcessFallback(baseEnv, sourceKey)
}
function getPiAgentStatusExtensionPath(agentDir: string): string {
return join(agentDir, 'extensions', ORCA_PI_AGENT_STATUS_EXTENSION_FILE)
}
function clearPiAgentShadowEnv(baseEnv: Record<string, string>, kind: PiAgentKind): void {
if (kind === 'omp') {
delete baseEnv.ORCA_OMP_CODING_AGENT_DIR
@ -519,29 +514,28 @@ function clearPiAgentShadowEnv(baseEnv: Record<string, string>, kind: PiAgentKin
delete baseEnv.ORCA_PI_SOURCE_AGENT_DIR
}
function exposePiAgentOverlayEnv(
function exposePiManagedExtensionEnv(
baseEnv: Record<string, string>,
kind: PiAgentKind,
overlayDir: string,
sourceDir: string | undefined
managedEnv: Record<string, string>
): void {
if (kind === 'omp') {
baseEnv.ORCA_OMP_CODING_AGENT_DIR = overlayDir
baseEnv.ORCA_OMP_STATUS_EXTENSION = getPiAgentStatusExtensionPath(overlayDir)
if (sourceDir) {
// Why: preserve the original OMP root across nested Orca terminals; the
// public env var is intentionally restored to the current PTY overlay.
baseEnv.ORCA_OMP_SOURCE_AGENT_DIR = sourceDir
delete baseEnv.ORCA_OMP_CODING_AGENT_DIR
if (managedEnv.ORCA_OMP_SOURCE_AGENT_DIR) {
baseEnv.ORCA_OMP_SOURCE_AGENT_DIR = managedEnv.ORCA_OMP_SOURCE_AGENT_DIR
} else {
delete baseEnv.ORCA_OMP_SOURCE_AGENT_DIR
}
if (managedEnv.ORCA_OMP_STATUS_EXTENSION) {
baseEnv.ORCA_OMP_STATUS_EXTENSION = managedEnv.ORCA_OMP_STATUS_EXTENSION
} else {
delete baseEnv.ORCA_OMP_STATUS_EXTENSION
}
return
}
baseEnv.ORCA_PI_CODING_AGENT_DIR = overlayDir
if (sourceDir) {
// Why: preserve the original Pi root across nested Orca terminals; the
// public env var is intentionally restored to the current PTY overlay.
baseEnv.ORCA_PI_SOURCE_AGENT_DIR = sourceDir
delete baseEnv.ORCA_PI_CODING_AGENT_DIR
if (managedEnv.ORCA_PI_SOURCE_AGENT_DIR) {
baseEnv.ORCA_PI_SOURCE_AGENT_DIR = managedEnv.ORCA_PI_SOURCE_AGENT_DIR
} else {
delete baseEnv.ORCA_PI_SOURCE_AGENT_DIR
}
@ -568,8 +562,9 @@ function getInheritedAgentHookEnvKeysToDelete(
}
// Why: when agent status is disabled, a nested Orca terminal can still pass
// through a prior PTY's OpenCode/Pi overlay env. Restore the user's original
// source dir when Orca recorded one, otherwise strip only values known to be ours.
// through prior OpenCode or legacy Pi/OMP overlay env. Restore the user's
// original source dir when Orca recorded one, otherwise strip only values
// known to be ours.
function restoreOrStripOverlayEnv(
baseEnv: Record<string, string>,
keys: {
@ -660,8 +655,7 @@ export function buildPtyHostEnv(
// value cannot coexist with an Orca-only injection. Hand the user's value
// (when present) to the hook service and let it materialize a source-scoped
// mirror overlay that lets the user's plugins and Orca's status plugin
// load together — same pattern Pi uses below for PI_CODING_AGENT_DIR. See
// docs/opencode-config-dir-collision.md.
// load together. See docs/opencode-config-dir-collision.md.
Object.assign(baseEnv, openCodeHookService.buildPtyEnv(id, preexistingOpenCodeConfigDir))
if (baseEnv.OPENCODE_CONFIG_DIR) {
// Why: ~/.zshrc can re-export the user's default after spawn; shell-ready
@ -698,36 +692,22 @@ export function buildPtyHostEnv(
Object.assign(baseEnv, agentHookServer.buildPtyEnv())
}
// Why: PI_CODING_AGENT_DIR owns Pi's / OMP's full config/session root (OMP
// inherits the env var name from Pi by design; its CHANGELOG documents the
// OMP_CODING_AGENT_DIR -> PI_CODING_AGENT_DIR rename. Build a source-scoped
// overlay from the caller's chosen root so Orca extensions load without
// making each terminal look like a separate Pi home.
// Why: PI_CODING_AGENT_DIR owns Pi's / OMP's full config/session root. Keep
// that home as the user's normal source of truth and install only Orca-owned,
// env-guarded extension files into the selected agent's extension dir.
if (opts.agentStatusHooksEnabled) {
clearPiAgentShadowEnv(baseEnv, 'pi')
clearPiAgentShadowEnv(baseEnv, 'omp')
if (piAgentKind === 'pi') {
const piEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingPiAgentDir, 'pi')
Object.assign(baseEnv, piEnv)
if (piEnv.PI_CODING_AGENT_DIR) {
// Why: ~/.zshrc can re-export the user's default after spawn; shell-ready
// wrappers restore this PTY-scoped value after user startup files run.
baseEnv.PI_CODING_AGENT_DIR = piEnv.PI_CODING_AGENT_DIR
exposePiAgentOverlayEnv(baseEnv, 'pi', piEnv.PI_CODING_AGENT_DIR, preexistingPiAgentDir)
}
exposePiManagedExtensionEnv(baseEnv, 'pi', piEnv)
}
if (shouldPrepareOmpShadow) {
const ompEnv = piTitlebarExtensionService.buildPtyEnv(id, preexistingOmpAgentDir, 'omp')
if (ompEnv.PI_CODING_AGENT_DIR) {
if (piAgentKind === 'omp') {
// Why: an OMP-launched PTY should default the binary-facing env var to
// OMP. Bare shells keep the Pi primary and use the `omp` shell wrapper
// to switch only while OMP is running.
baseEnv.PI_CODING_AGENT_DIR = ompEnv.PI_CODING_AGENT_DIR
}
exposePiAgentOverlayEnv(baseEnv, 'omp', ompEnv.PI_CODING_AGENT_DIR, preexistingOmpAgentDir)
}
Object.assign(baseEnv, ompEnv)
exposePiManagedExtensionEnv(baseEnv, 'omp', ompEnv)
}
} else {
// Why: when agent status is disabled we must strip BOTH kinds' shadow vars
@ -2181,18 +2161,18 @@ export function registerPtyHandlers(
}
// Why: the daemon-backed provider replaces LocalPtyProvider and therefore
// never runs its buildSpawnEnv closure. We must assemble the same
// host-local env (OpenCode plugin, agent-hook server, Pi overlay, Codex
// home, dev CLI overrides, GitHub attribution shims) here so both spawn
// paths behave identically. buildPtyHostEnv is the shared helper that
// encapsulates the full set of injections and their order/guards.
// host-local env (OpenCode plugin, agent-hook server, Pi/OMP managed
// extensions, Codex home, dev CLI overrides, GitHub attribution shims)
// here so both spawn paths behave identically. buildPtyHostEnv is the
// shared helper that encapsulates the full set of injections and guards.
//
// Safety: skip the entire injection when a remote (SSH) connection is in
// play. Every injection here is either host-loopback (the agent-hook
// server binds 127.0.0.1, so shipping its token to an SSH host would
// leak a loopback secret for no functional benefit) or a path on the
// local filesystem (OpenCode plugin dir, Pi overlay, Codex home, dev
// CLI bin, attribution shim dir) that would resolve to nothing — or
// something misleading — on the remote machine.
// local filesystem (OpenCode plugin dir, Pi/OMP extension paths, Codex
// home, dev CLI bin, attribution shim dir) that would resolve to
// nothing — or something misleading — on the remote machine.
const isDaemonHostSpawn = !args.connectionId && !(provider instanceof LocalPtyProvider)
// Why: daemon host-env setup needs a stable id BEFORE provider.spawn so
// provider hooks and legacy Pi overlay cleanup can run in buildPtyHostEnv.
@ -2206,8 +2186,8 @@ export function registerPtyHandlers(
// fresh UUID per spawn; that would orphan reconnectable terminal state.
// Why: only state for ids we minted in THIS request should be cleared on
// spawn failure. If the caller supplied args.sessionId it may refer to
// an existing PTY whose state (OpenCode hooks, Pi overlay, agent-hook
// pane caches) we must not clobber on a retry/attach failure.
// an existing PTY whose state (OpenCode hooks, legacy Pi overlay cleanup,
// agent-hook pane caches) we must not clobber on a retry/attach failure.
const isMintedSessionId = args.sessionId === undefined && isDaemonHostSpawn
const effectiveSessionId =
args.sessionId ?? (isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined)
@ -2326,8 +2306,8 @@ export function registerPtyHandlers(
})
promoteAgentTeamsShimPath(env, requestedAgentTeamsPath)
} catch (err) {
// Why: buildPtyHostEnv has filesystem side-effects (Pi overlay
// materialization). If it throws before we reach provider.spawn,
// Why: buildPtyHostEnv has filesystem side-effects (Pi/OMP managed
// extension installation). If it throws before we reach provider.spawn,
// clear per-PTY state so the next attempt starts clean.
//
// Only sweep state for ids we MINTED in this request — caller-

View File

@ -2,13 +2,12 @@
// in-process TypeScript extension API (pi.on('agent_start'), 'tool_call',
// etc.). To get pi panes into the unified agent-hooks pipeline alongside
// Claude/Codex/Gemini/OpenCode/Cursor, we ship a bundled extension into
// the Pi overlay (PiTitlebarExtensionService) that POSTs to
// the selected Pi/OMP extension dir (PiTitlebarExtensionService) that POSTs to
// /hook/<kind> using the same ORCA_AGENT_HOOK_* + ORCA_PANE_KEY env that every
// PTY already receives from ipc/pty.ts.
//
// Each Pi process still gets its own paneKey through env even when multiple
// PTYs share one source-scoped overlay. Like the OpenCode plugin, the returned
// source is a string (loaded by jiti from disk inside the pi process), so we
// Each Pi process gets its own paneKey through env. Like the OpenCode plugin,
// the returned source is a string (loaded by jiti from disk inside the pi process), so we
// keep the source body in plain JS without TS types and avoid pulling pi or
// any Orca dep into the pi runtime.
import type { PiAgentKind } from '../../shared/pi-agent-kind'
@ -96,8 +95,8 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin
' const isOmpExecutable = executableNames.some((name) =>',
" ['omp', 'omp.js', 'omp.sh', 'omp.cmd', 'omp.exe', 'omp.bat'].includes(name)",
' )',
' // Why: a bare shell gets the Pi overlay at spawn time, but may later',
' // launch OMP. Runtime executable detection keeps that status labeled',
' // Why: a bare shell may launch either Pi or OMP after spawn. Runtime',
' // executable detection keeps that status labeled',
' // as OMP instead of silently reporting it as Pi.',
' if (isOmpExecutable) {',
" return '/hook/omp'",

View File

@ -24,6 +24,7 @@ export function getPiPrefillExtensionSource(kind: PiAgentKind): string {
return [
'export default function (pi) {',
" pi.on('session_start', async (event, ctx) => {",
' if (!process.env.ORCA_PANE_KEY) return',
" if (event.reason !== 'startup') return",
` const prefill = process.env.${envVar}`,
' if (!prefill) return',

View File

@ -1,7 +1,6 @@
import { createHash } from 'crypto'
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { basename, join, sep } from 'path'
import { join, sep } from 'path'
import { afterEach, describe, expect, it, vi } from 'vitest'
const userDataDir = mkdtempSync(join(tmpdir(), 'orca-pi-overlay-path-userdata-'))
@ -29,36 +28,28 @@ const PATH_SHAPED_PTY_ID = [
'feature@@a1b2c3d4'
].join(sep)
function overlayPath(kind: 'pi' | 'omp', sourceAgentDir: string): string {
const rootDir = kind === 'pi' ? 'pi-agent-overlays' : 'omp-agent-overlays'
const safeName = createHash('sha256')
.update(`source:${sourceAgentDir}`)
.digest('hex')
.slice(0, 32)
return join(userDataDir, rootDir, safeName)
}
function legacyOverlayPath(kind: 'pi' | 'omp', ptyId: string): string {
const rootDir = kind === 'pi' ? 'pi-agent-overlays' : 'omp-agent-overlays'
return join(userDataDir, rootDir, ptyId)
}
describe('PiTitlebarExtensionService overlay paths', () => {
describe('PiTitlebarExtensionService legacy overlay paths', () => {
afterEach(() => {
rmSync(join(userDataDir, 'pi-agent-overlays'), { recursive: true, force: true })
rmSync(join(userDataDir, 'omp-agent-overlays'), { recursive: true, force: true })
})
it('hashes source agent dirs into bounded shared overlay directory names', () => {
it('does not redirect path-shaped PTY ids into active Pi homes', () => {
const piHome = mkdtempSync(join(tmpdir(), 'orca-pi-overlay-path-home-'))
const svc = new PiTitlebarExtensionService()
try {
const env = svc.buildPtyEnv(PATH_SHAPED_PTY_ID, piHome, 'pi')
expect(env.PI_CODING_AGENT_DIR).toBe(overlayPath('pi', piHome))
expect(basename(env.PI_CODING_AGENT_DIR!)).toMatch(/^[a-f0-9]{32}$/)
expect(readdirSync(join(env.PI_CODING_AGENT_DIR!, 'extensions')).sort()).toEqual([
expect(env.PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe(piHome)
expect(existsSync(join(userDataDir, 'pi-agent-overlays'))).toBe(false)
expect(readdirSync(join(piHome, 'extensions')).sort()).toEqual([
'orca-agent-status.ts',
'orca-prefill.ts',
'orca-titlebar-spinner.ts'

View File

@ -1,7 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
@ -13,7 +12,6 @@ import {
import { tmpdir } from 'os'
import type * as osModule from 'os'
import { join } from 'path'
import { createHash } from 'crypto'
// The service calls app.getPath('userData') for its overlay root. Point that
// at a real tmp dir so we can exercise the filesystem behavior end-to-end.
@ -47,21 +45,6 @@ vi.mock('electron', () => ({
import { PiTitlebarExtensionService, isSafeDescendCandidate } from './titlebar-extension-service'
function overlayPath(kind: 'pi' | 'omp', sourceAgentDir: string): string {
const rootDir = kind === 'pi' ? 'pi-agent-overlays' : 'omp-agent-overlays'
const safeName = createHash('sha256')
.update(`source:${sourceAgentDir}`)
.digest('hex')
.slice(0, 32)
return join(userDataDir, rootDir, safeName)
}
function ptyOverlayPath(kind: 'pi' | 'omp', ptyId: string): string {
const rootDir = kind === 'pi' ? 'pi-agent-overlays' : 'omp-agent-overlays'
const safeName = createHash('sha256').update(ptyId).digest('hex').slice(0, 32)
return join(userDataDir, rootDir, safeName)
}
function legacyOverlayPath(kind: 'pi' | 'omp', ptyId: string): string {
const rootDir = kind === 'pi' ? 'pi-agent-overlays' : 'omp-agent-overlays'
return join(userDataDir, rootDir, ptyId)
@ -124,90 +107,84 @@ describe('PiTitlebarExtensionService', () => {
})
}
it('buildPtyEnv mirrors the user agent dir into an overlay under userData', () => {
it('buildPtyEnv installs Orca extensions into the user agent dir without redirecting the home', () => {
const svc = new PiTitlebarExtensionService()
const env = svc.buildPtyEnv('pty-1', piHome, 'pi')
expect(env.PI_CODING_AGENT_DIR).toBe(overlayPath('pi', piHome))
// Orca's titlebar extension is added alongside user extensions, not replacing them.
const overlayExtensions = readdirSync(join(env.PI_CODING_AGENT_DIR!, 'extensions')).sort()
expect(overlayExtensions).toEqual([
expect(env.PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe(piHome)
const extensions = readdirSync(join(piHome, 'extensions')).sort()
expect(extensions).toEqual([
'orca-agent-status.ts',
'orca-prefill.ts',
'orca-titlebar-spinner.ts',
'user-ext'
])
const statusExtensionSource = readFileSync(
join(env.PI_CODING_AGENT_DIR!, 'extensions', 'orca-agent-status.ts'),
join(piHome, 'extensions', 'orca-agent-status.ts'),
'utf-8'
)
const titlebarExtensionSource = readFileSync(
join(piHome, 'extensions', 'orca-titlebar-spinner.ts'),
'utf-8'
)
const prefillExtensionSource = readFileSync(
join(piHome, 'extensions', 'orca-prefill.ts'),
'utf-8'
)
expect(statusExtensionSource).toContain('@orca-managed-pi-extension')
expect(statusExtensionSource).toContain('/hook/pi')
expect(statusExtensionSource).toContain('process.title')
expect(statusExtensionSource).toContain("return '/hook/omp'")
expect(
JSON.parse(readFileSync(join(env.PI_CODING_AGENT_DIR!, 'settings.json'), 'utf-8'))
).toEqual({
defaultProvider: 'amazon-bedrock',
hideThinkingBlock: true,
packages: ['npm:pi-web-access'],
terminal: {
showImages: false,
clearOnShrink: true
}
})
// User's top-level resources are reachable via the overlay.
expect(existsSync(join(env.PI_CODING_AGENT_DIR!, 'skills', 'my-skill', 'SKILL.md'))).toBe(true)
expect(existsSync(join(env.PI_CODING_AGENT_DIR!, 'auth.json'))).toBe(true)
expect(titlebarExtensionSource).toContain('@orca-managed-pi-extension')
expect(titlebarExtensionSource).toContain('process.env.ORCA_PANE_KEY')
expect(prefillExtensionSource).toContain('@orca-managed-pi-extension')
expect(prefillExtensionSource).toContain('process.env.ORCA_PANE_KEY')
expectPiHomeIntact()
})
it('clearPty leaves the source overlay alive without touching the user Pi dir', () => {
it('clearPty leaves the real Pi dir and managed extensions intact', () => {
const svc = new PiTitlebarExtensionService()
const env = svc.buildPtyEnv('pty-2', piHome, 'pi')
svc.buildPtyEnv('pty-2', piHome, 'pi')
svc.clearPty('pty-2')
// Why: source-scoped overlays may be shared by other live Pi terminals;
// per-PTY teardown must not remove shared state.
expect(existsSync(env.PI_CODING_AGENT_DIR!)).toBe(true)
expect(existsSync(join(piHome, 'extensions', 'orca-agent-status.ts'))).toBe(true)
expectPiHomeIntact()
})
it('uses one source-scoped overlay for multiple PTYs with the same Pi dir', () => {
it('uses the same source dir for multiple PTYs with the same Pi dir', () => {
const svc = new PiTitlebarExtensionService()
const firstEnv = svc.buildPtyEnv('pty-shared-1', piHome, 'pi')
const secondEnv = svc.buildPtyEnv('pty-shared-2', piHome, 'pi')
expect(secondEnv.PI_CODING_AGENT_DIR).toBe(firstEnv.PI_CODING_AGENT_DIR)
expect(secondEnv.PI_CODING_AGENT_DIR).toBe(overlayPath('pi', piHome))
expect(
readFileSync(
join(secondEnv.PI_CODING_AGENT_DIR!, 'extensions', 'user-ext', 'ext.ts'),
'utf-8'
)
).toBe('user extension')
expect(firstEnv.PI_CODING_AGENT_DIR).toBeUndefined()
expect(secondEnv.PI_CODING_AGENT_DIR).toBeUndefined()
expect(secondEnv.ORCA_PI_SOURCE_AGENT_DIR).toBe(firstEnv.ORCA_PI_SOURCE_AGENT_DIR)
expect(readFileSync(join(piHome, 'extensions', 'user-ext', 'ext.ts'), 'utf-8')).toBe(
'user extension'
)
expectPiHomeIntact()
})
it('source-backs OMP agent.db before OMP lazily creates it', () => {
it('leaves OMP SQLite files in the real home instead of redirecting to an overlay', () => {
const svc = new PiTitlebarExtensionService()
const env = svc.buildPtyEnv('pty-omp-sqlite', piHome, 'omp')
const sourcePath = join(piHome, 'agent.db')
const overlayPath = join(env.PI_CODING_AGENT_DIR!, 'agent.db')
const content = 'agent.db credentials'
expect(existsSync(sourcePath)).toBe(true)
expect(existsSync(overlayPath)).toBe(true)
expect(env.PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBe(piHome)
expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(join(piHome, 'extensions', 'orca-agent-status.ts'))
expect(existsSync(sourcePath)).toBe(false)
expect(existsSync(join(userDataDir, 'omp-agent-overlays'))).toBe(false)
expect(existsSync(join(piHome, 'history.db'))).toBe(false)
writeFileSync(overlayPath, content)
writeFileSync(sourcePath, content)
expect(readFileSync(sourcePath, 'utf-8')).toBe(content)
if (process.platform !== 'win32') {
expect(lstatSync(overlayPath).isSymbolicLink()).toBe(true)
}
})
it('rebuilding an overlay for the same ptyId does not corrupt the user Pi dir', () => {
it('rebuilding managed extensions for the same ptyId does not corrupt the user Pi dir', () => {
const svc = new PiTitlebarExtensionService()
svc.buildPtyEnv('pty-3', piHome, 'pi')
svc.buildPtyEnv('pty-3', piHome, 'pi')
@ -215,13 +192,13 @@ describe('PiTitlebarExtensionService', () => {
expectPiHomeIntact()
})
it('reconciles mirrored entries while preserving Pi-created shared overlay files', () => {
it('rebuilding updates Orca-owned extensions while preserving user files', () => {
const svc = new PiTitlebarExtensionService()
const firstEnv = svc.buildPtyEnv('pty-refresh-1', piHome, 'pi')
const overlayDir = firstEnv.PI_CODING_AGENT_DIR!
mkdirSync(join(overlayDir, 'runtime-cache'), { recursive: true })
writeFileSync(join(overlayDir, 'runtime-cache', 'index.json'), '{}')
svc.buildPtyEnv('pty-refresh-1', piHome, 'pi')
writeFileSync(
join(piHome, 'extensions', 'orca-agent-status.ts'),
'// @orca-managed-pi-extension\nstale'
)
rmSync(join(piHome, 'extensions', 'user-ext'), { recursive: true, force: true })
mkdirSync(join(piHome, 'extensions', 'new-ext'), { recursive: true })
@ -230,13 +207,10 @@ describe('PiTitlebarExtensionService', () => {
const secondEnv = svc.buildPtyEnv('pty-refresh-2', piHome, 'pi')
expect(secondEnv.PI_CODING_AGENT_DIR).toBe(overlayDir)
expect(readFileSync(join(overlayDir, 'auth.json'), 'utf-8')).toBe('rotated token')
expect(existsSync(join(overlayDir, 'extensions', 'user-ext'))).toBe(false)
expect(readFileSync(join(overlayDir, 'extensions', 'new-ext', 'ext.ts'), 'utf-8')).toBe(
'new user extension'
expect(secondEnv.PI_CODING_AGENT_DIR).toBeUndefined()
expect(readFileSync(join(piHome, 'extensions', 'orca-agent-status.ts'), 'utf-8')).toContain(
'/hook/pi'
)
expect(existsSync(join(overlayDir, 'runtime-cache', 'index.json'))).toBe(true)
expect(readFileSync(join(piHome, 'auth.json'), 'utf-8')).toBe('rotated token')
expect(readFileSync(join(piHome, 'extensions', 'new-ext', 'ext.ts'), 'utf-8')).toBe(
'new user extension'
@ -250,17 +224,15 @@ describe('PiTitlebarExtensionService', () => {
const svc = new PiTitlebarExtensionService()
const env = svc.buildPtyEnv('pty-same-name-extension', piHome, 'pi')
expect(env.PI_CODING_AGENT_DIR).toBeUndefined()
expect(readFileSync(join(piHome, 'extensions', 'orca-agent-status.ts'), 'utf-8')).toBe(
userStatusExtension
)
expect(
readFileSync(join(env.PI_CODING_AGENT_DIR!, 'extensions', 'orca-agent-status.ts'), 'utf-8')
).toContain('/hook/pi')
expectPiHomeIntact()
})
it.skipIf(process.platform === 'win32')(
'does not write bundled extensions through a symlinked user extensions dir',
'writes bundled extensions through a symlinked user extensions dir',
() => {
const realExtensionsDir = mkdtempSync(join(tmpdir(), 'orca-real-pi-extensions-'))
try {
@ -271,18 +243,13 @@ describe('PiTitlebarExtensionService', () => {
const svc = new PiTitlebarExtensionService()
const env = svc.buildPtyEnv('pty-symlinked-extensions', piHome, 'pi')
expect(existsSync(join(realExtensionsDir, 'orca-agent-status.ts'))).toBe(false)
expect(existsSync(join(realExtensionsDir, 'orca-prefill.ts'))).toBe(false)
expect(existsSync(join(realExtensionsDir, 'orca-titlebar-spinner.ts'))).toBe(false)
expect(
readFileSync(join(env.PI_CODING_AGENT_DIR!, 'extensions', 'real-user-ext.ts'), 'utf-8')
).toBe('real user extension')
expect(
readFileSync(
join(env.PI_CODING_AGENT_DIR!, 'extensions', 'orca-agent-status.ts'),
'utf-8'
)
).toContain('/hook/pi')
expect(env.PI_CODING_AGENT_DIR).toBeUndefined()
expect(existsSync(join(realExtensionsDir, 'orca-agent-status.ts'))).toBe(true)
expect(existsSync(join(realExtensionsDir, 'orca-prefill.ts'))).toBe(true)
expect(existsSync(join(realExtensionsDir, 'orca-titlebar-spinner.ts'))).toBe(true)
expect(readFileSync(join(realExtensionsDir, 'orca-agent-status.ts'), 'utf-8')).toContain(
'/hook/pi'
)
} finally {
rmSync(realExtensionsDir, { recursive: true, force: true })
}
@ -306,16 +273,14 @@ describe('PiTitlebarExtensionService', () => {
const svc = new PiTitlebarExtensionService()
const env = svc.buildPtyEnv('pty-4', piHome, 'pi')
expect(env.PI_CODING_AGENT_DIR).toBe(overlayPath('pi', piHome))
expect(env.PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe(piHome)
expect(existsSync(legacyOverlayDir)).toBe(false)
expect(existsSync(join(env.PI_CODING_AGENT_DIR!, 'skills', 'my-skill', 'SKILL.md'))).toBe(
true
)
expectPiHomeIntact()
}
)
// Why: per-agent overlay source dir. Orca's user picks Pi or OMP per
// Why: per-agent source dir. Orca's user picks Pi or OMP per
// launch (the agent kind isn't a global install-time choice), so each
// build's source dir MUST be resolved from the agent kind, not from a
// disk-presence check that silently shadows the other agent's user
@ -329,7 +294,7 @@ describe('PiTitlebarExtensionService', () => {
return agentDir
}
it('launching pi with both ~/.pi/agent and ~/.omp/agent present mirrors ~/.pi/agent', () => {
it('launching pi with both ~/.pi/agent and ~/.omp/agent present installs into ~/.pi/agent', () => {
const fakeHome = mkdtempSync(join(tmpdir(), 'orca-pi-both-'))
seedAgentDir(fakeHome, '.pi', 'pi')
seedAgentDir(fakeHome, '.omp', 'omp')
@ -339,22 +304,21 @@ describe('PiTitlebarExtensionService', () => {
const svc = new PiTitlebarExtensionService()
const env = svc.buildPtyEnv('pty-pi-both', undefined, 'pi')
expect(env.PI_CODING_AGENT_DIR).toBe(overlayPath('pi', join(fakeHome, '.pi', 'agent')))
// The Pi auth file must be the one mirrored (not OMP's).
expect(readFileSync(join(env.PI_CODING_AGENT_DIR!, 'auth.json'), 'utf-8')).toBe(
'pi secret token'
)
// The user extension dir must be Pi's, not OMP's.
const overlayExtensions = readdirSync(join(env.PI_CODING_AGENT_DIR!, 'extensions')).sort()
expect(overlayExtensions).toContain('pi-ext')
expect(overlayExtensions).not.toContain('omp-ext')
expect(env.PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe(join(fakeHome, '.pi', 'agent'))
expect(
existsSync(join(fakeHome, '.pi', 'agent', 'extensions', 'orca-agent-status.ts'))
).toBe(true)
expect(
existsSync(join(fakeHome, '.omp', 'agent', 'extensions', 'orca-agent-status.ts'))
).toBe(false)
} finally {
homedirOverride.current = ''
rmSync(fakeHome, { recursive: true, force: true })
}
})
it('launching omp with both ~/.pi/agent and ~/.omp/agent present mirrors ~/.omp/agent into omp-agent-overlays', () => {
it('launching omp with both ~/.pi/agent and ~/.omp/agent present installs into ~/.omp/agent', () => {
const fakeHome = mkdtempSync(join(tmpdir(), 'orca-omp-both-'))
seedAgentDir(fakeHome, '.pi', 'pi')
seedAgentDir(fakeHome, '.omp', 'omp')
@ -364,28 +328,20 @@ describe('PiTitlebarExtensionService', () => {
const svc = new PiTitlebarExtensionService()
const env = svc.buildPtyEnv('pty-omp-both', undefined, 'omp')
// Critical regression guard for "OMP is its own program with its own
// paths": OMP overlays live under userData/omp-agent-overlays, NEVER
// under userData/pi-agent-overlays. A future refactor that re-shares
// the Pi overlay root for OMP would re-introduce cross-agent state
// visibility this PR exists to prevent.
expect(env.PI_CODING_AGENT_DIR).toBe(overlayPath('omp', join(fakeHome, '.omp', 'agent')))
// CRITICAL regression guard: even though ~/.pi/agent exists, the OMP
// launch MUST resolve OMP's own source dir, not Pi's.
expect(readFileSync(join(env.PI_CODING_AGENT_DIR!, 'auth.json'), 'utf-8')).toBe(
'omp secret token'
expect(env.PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBe(join(fakeHome, '.omp', 'agent'))
expect(env.ORCA_OMP_STATUS_EXTENSION).toBe(
join(fakeHome, '.omp', 'agent', 'extensions', 'orca-agent-status.ts')
)
const overlayExtensions = readdirSync(join(env.PI_CODING_AGENT_DIR!, 'extensions')).sort()
expect(overlayExtensions).toContain('omp-ext')
expect(overlayExtensions).not.toContain('pi-ext')
expect(
readFileSync(
join(env.PI_CODING_AGENT_DIR!, 'extensions', 'orca-agent-status.ts'),
join(fakeHome, '.omp', 'agent', 'extensions', 'orca-agent-status.ts'),
'utf-8'
)
).toContain('/hook/omp')
// Pi's overlay root MUST NOT have been touched by the OMP launch.
expect(existsSync(ptyOverlayPath('pi', 'pty-omp-both'))).toBe(false)
expect(
existsSync(join(fakeHome, '.pi', 'agent', 'extensions', 'orca-agent-status.ts'))
).toBe(false)
} finally {
homedirOverride.current = ''
rmSync(fakeHome, { recursive: true, force: true })
@ -405,24 +361,16 @@ describe('PiTitlebarExtensionService', () => {
const svc = new PiTitlebarExtensionService()
const env = svc.buildPtyEnv('pty-omp-empty', undefined, 'omp')
expect(env.PI_CODING_AGENT_DIR).toBe(overlayPath('omp', join(fakeHome, '.omp', 'agent')))
// The Pi-only home must NOT leak into the OMP overlay; the auth
// token from ~/.pi/agent/auth.json must be absent.
expect(existsSync(join(env.PI_CODING_AGENT_DIR!, 'auth.json'))).toBe(false)
// Only Orca's bundled extensions are present — no user extensions
// from the other agent's dir.
const overlayExtensions = readdirSync(join(env.PI_CODING_AGENT_DIR!, 'extensions')).sort()
expect(overlayExtensions).toEqual([
const ompAgentDir = join(fakeHome, '.omp', 'agent')
expect(env.PI_CODING_AGENT_DIR).toBeUndefined()
expect(env.ORCA_OMP_SOURCE_AGENT_DIR).toBe(ompAgentDir)
expect(existsSync(join(ompAgentDir, 'auth.json'))).toBe(false)
const extensions = readdirSync(join(ompAgentDir, 'extensions')).sort()
expect(extensions).toEqual([
'orca-agent-status.ts',
'orca-prefill.ts',
'orca-titlebar-spinner.ts'
])
expect(
JSON.parse(readFileSync(join(env.PI_CODING_AGENT_DIR!, 'settings.json'), 'utf-8'))
).toEqual({
hideThinkingBlock: true,
terminal: { clearOnShrink: true }
})
} finally {
homedirOverride.current = ''
rmSync(fakeHome, { recursive: true, force: true })

View File

@ -1,14 +1,6 @@
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
statSync,
writeFileSync
} from 'fs'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { homedir } from 'os'
import { basename, join } from 'path'
import { join } from 'path'
import { app } from 'electron'
import { createHash } from 'crypto'
import {
@ -23,15 +15,8 @@ export { ORCA_OMP_PREFILL_ENV_VAR, ORCA_PI_PREFILL_ENV_VAR } from './prefill-ext
import { ORCA_PI_EXTENSION_FILE, getPiTitlebarExtensionSource } from './titlebar-extension-source'
import {
isSafeDescendCandidate as sharedIsSafeDescendCandidate,
mirrorEntry,
safeRemoveOverlay,
safeRemoveTree
safeRemoveOverlay
} from '../pty/overlay-mirror'
import {
isOmpPersistentSqliteEntry,
mirrorOmpPersistentSqliteFiles
} from '../pty/omp-sqlite-overlay'
import { mergePiOverlayUiSettings } from '../../shared/pi-overlay-ui-settings'
import type { PiAgentKind } from '../../shared/pi-agent-kind'
// Why: the Pi test suite imports `isSafeDescendCandidate` from this module's
@ -41,23 +26,25 @@ import type { PiAgentKind } from '../../shared/pi-agent-kind'
export const isSafeDescendCandidate = sharedIsSafeDescendCandidate
const PI_AGENT_SUBDIR = 'agent'
const PI_AGENT_SETTINGS_FILE = 'settings.json'
const PI_OVERLAY_MANIFEST_FILE = '.orca-pi-overlay-manifest.json'
const ORCA_MANAGED_EXTENSION_MARKER = '@orca-managed-pi-extension'
type PiOverlayManifest = {
topLevelEntries: string[]
extensionEntries: string[]
type ManagedExtensionWriteResult = 'written' | 'skipped-user-owned' | 'failed'
type PiManagedExtensionEnv = {
extensionDir?: string
sourceAgentDir: string
statusExtensionPath?: string
}
// Why: each agent owns its own overlay tree so OMP launches never touch
// Pi's overlay dir (and vice versa). Shadowing one inside the other would
// re-introduce the cross-agent state leak the per-kind PR exists to prevent.
// Why: old Orca versions used per-kind overlay roots. Keep the names so
// upgrade-time cleanup can remove stale PTY-scoped Pi/OMP overlay dirs without
// guessing which agent a terminated pane launched.
const OVERLAY_ROOT_DIR_NAME: Record<PiAgentKind, string> = {
pi: 'pi-agent-overlays',
omp: 'omp-agent-overlays'
}
// Why: the overlay source dir is chosen by which agent is being launched, NOT
// Why: the managed extension target is chosen by which agent is being launched, NOT
// by which `~/.<agent>/agent` dir happens to exist on disk first. A
// cross-agent fallback (Pi -> OMP or vice versa) silently shadows the other
// agent's user extensions when both are installed and the user picks the
@ -75,18 +62,17 @@ function toSafeOverlayDirName(ptyId: string): string {
return createHash('sha256').update(ptyId).digest('hex').slice(0, 32)
}
function withOrcaManagedExtensionMarker(source: string): string {
return source.includes(ORCA_MANAGED_EXTENSION_MARKER)
? source
: `// ${ORCA_MANAGED_EXTENSION_MARKER}\n${source}`
}
export class PiTitlebarExtensionService {
private getOverlayRoot(kind: PiAgentKind): string {
return join(app.getPath('userData'), OVERLAY_ROOT_DIR_NAME[kind])
}
private getSourceOverlayDir(sourceAgentDir: string, kind: PiAgentKind): string {
// Why: PI_CODING_AGENT_DIR is Pi's whole mutable home. Scope overlays to
// the source home, not a PTY, so Orca Pi terminals share config/session
// state while still avoiding writes to the user's real agent dir.
return join(this.getOverlayRoot(kind), toSafeOverlayDirName(`source:${sourceAgentDir}`))
}
private getPtyOverlayDir(ptyId: string, kind: PiAgentKind): string {
// Why: old Orca versions used PTY-scoped hashed overlays. Keep resolving
// that path so new spawns/teardowns can clean stale pre-migration dirs.
@ -104,143 +90,57 @@ export class PiTitlebarExtensionService {
safeRemoveOverlay(overlayDir, this.getOverlayRoot(kind))
}
private readOverlayManifest(overlayDir: string): PiOverlayManifest {
private canOverwriteManagedExtension(path: string): boolean {
try {
const parsed = JSON.parse(
readFileSync(join(overlayDir, PI_OVERLAY_MANIFEST_FILE), 'utf8')
) as Partial<PiOverlayManifest>
return {
topLevelEntries: Array.isArray(parsed.topLevelEntries) ? parsed.topLevelEntries : [],
extensionEntries: Array.isArray(parsed.extensionEntries) ? parsed.extensionEntries : []
}
return readFileSync(path, 'utf8').includes(ORCA_MANAGED_EXTENSION_MARKER)
} catch {
return { topLevelEntries: [], extensionEntries: [] }
return true
}
}
private writeOverlayManifest(overlayDir: string, manifest: PiOverlayManifest): void {
writeFileSync(
join(overlayDir, PI_OVERLAY_MANIFEST_FILE),
`${JSON.stringify(manifest, null, 2)}\n`
)
}
private clearManifestEntries(overlayDir: string, manifest: PiOverlayManifest): void {
for (const entryName of manifest.topLevelEntries) {
safeRemoveTree(join(overlayDir, entryName))
}
const overlayExtensionsDir = join(overlayDir, 'extensions')
for (const entryName of manifest.extensionEntries) {
safeRemoveTree(join(overlayExtensionsDir, entryName))
}
}
private mirrorAgentDir(sourceAgentDir: string, overlayDir: string, kind: PiAgentKind): void {
const previousManifest = this.readOverlayManifest(overlayDir)
this.clearManifestEntries(overlayDir, previousManifest)
const nextManifest: PiOverlayManifest = { topLevelEntries: [], extensionEntries: [] }
if (!existsSync(sourceAgentDir)) {
if (kind === 'omp') {
nextManifest.topLevelEntries.push(
...mirrorOmpPersistentSqliteFiles(sourceAgentDir, overlayDir)
)
}
this.writeOverlayManifest(overlayDir, nextManifest)
return
}
for (const entry of readdirSync(sourceAgentDir, { withFileTypes: true })) {
const sourcePath = join(sourceAgentDir, entry.name)
if (entry.name === PI_AGENT_SETTINGS_FILE) {
continue
}
if (kind === 'omp' && isOmpPersistentSqliteEntry(entry.name)) {
continue
}
if (entry.name === 'extensions') {
const isSymlink = entry.isSymbolicLink()
let isLinkPointingToDir = false
if (isSymlink) {
try {
isLinkPointingToDir = statSync(sourcePath).isDirectory()
} catch {
isLinkPointingToDir = false
}
}
if (!entry.isDirectory() && !isLinkPointingToDir) {
mirrorEntry(sourcePath, join(overlayDir, basename(sourcePath)))
nextManifest.topLevelEntries.push(entry.name)
continue
}
// Why: `extensions/` must be a real overlay directory so Orca's
// bundled files are written only into userData, never through a user
// symlink/junction that points at their real extension store.
const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath
const overlayExtensionsDir = join(overlayDir, 'extensions')
mkdirSync(overlayExtensionsDir, { recursive: true })
for (const extensionEntry of readdirSync(resolvedSource, { withFileTypes: true })) {
if (
extensionEntry.name === ORCA_PI_EXTENSION_FILE ||
extensionEntry.name === ORCA_PI_PREFILL_EXTENSION_FILE ||
extensionEntry.name === ORCA_PI_AGENT_STATUS_EXTENSION_FILE
) {
continue
}
mirrorEntry(
join(resolvedSource, extensionEntry.name),
join(overlayExtensionsDir, extensionEntry.name)
)
nextManifest.extensionEntries.push(extensionEntry.name)
}
continue
}
// Why: PI_CODING_AGENT_DIR controls Pi's / OMP's entire state tree, not
// just extension discovery. Mirror the user's top-level resources into
// the overlay so enabling Orca's titlebar extension preserves auth,
// sessions, skills, prompts, themes, and any future files stored there.
mirrorEntry(sourcePath, join(overlayDir, basename(sourcePath)))
nextManifest.topLevelEntries.push(entry.name)
}
if (kind === 'omp') {
nextManifest.topLevelEntries.push(
...mirrorOmpPersistentSqliteFiles(sourceAgentDir, overlayDir)
)
}
this.writeOverlayManifest(overlayDir, nextManifest)
}
private readPiSettings(sourceAgentDir: string): unknown {
const settingsPath = join(sourceAgentDir, PI_AGENT_SETTINGS_FILE)
if (!existsSync(settingsPath)) {
return {}
private writeManagedExtension(path: string, source: string): ManagedExtensionWriteResult {
if (existsSync(path) && !this.canOverwriteManagedExtension(path)) {
return 'skipped-user-owned'
}
try {
return JSON.parse(readFileSync(settingsPath, 'utf8'))
writeFileSync(path, source)
return 'written'
} catch {
return {}
return 'failed'
}
}
private writeOverlaySettings(sourceAgentDir: string, overlayDir: string): void {
// Why: settings.json is a real overlay file, not a mirror, so Orca can
// apply UI-only safeguards without modifying the user's Pi / OMP config.
const settings = mergePiOverlayUiSettings(this.readPiSettings(sourceAgentDir))
writeFileSync(
join(overlayDir, PI_AGENT_SETTINGS_FILE),
`${JSON.stringify(settings, null, 2)}\n`
private installManagedExtensions(
sourceAgentDir: string,
kind: PiAgentKind
): PiManagedExtensionEnv {
const extensionsDir = join(sourceAgentDir, 'extensions')
try {
mkdirSync(extensionsDir, { recursive: true })
} catch {
return { sourceAgentDir }
}
this.writeManagedExtension(
join(extensionsDir, ORCA_PI_EXTENSION_FILE),
withOrcaManagedExtensionMarker(getPiTitlebarExtensionSource())
)
this.writeManagedExtension(
join(extensionsDir, ORCA_PI_PREFILL_EXTENSION_FILE),
withOrcaManagedExtensionMarker(getPiPrefillExtensionSource(kind))
)
const statusExtensionPath = join(extensionsDir, ORCA_PI_AGENT_STATUS_EXTENSION_FILE)
const statusResult = this.writeManagedExtension(
statusExtensionPath,
withOrcaManagedExtensionMarker(getPiAgentStatusExtensionSource(kind))
)
return {
extensionDir: extensionsDir,
sourceAgentDir,
statusExtensionPath: statusResult === 'written' ? statusExtensionPath : undefined
}
}
buildPtyEnv(
@ -249,70 +149,32 @@ export class PiTitlebarExtensionService {
kind: PiAgentKind
): Record<string, string> {
const sourceAgentDir = existingAgentDir || getDefaultPiAgentDir(kind)
const overlayDir = this.getSourceOverlayDir(sourceAgentDir, kind)
try {
this.safeRemoveOverlay(this.getPtyOverlayDir(ptyId, kind), kind)
this.safeRemoveOverlay(this.getLegacyOverlayDir(ptyId, kind), kind)
} catch {
// Why: on Windows the overlay directory can be locked by another process
// (e.g. antivirus, indexer, or a previous Orca session that didn't clean up).
// If we can't remove the stale overlay, fall back to the user's own
// agent dir (Pi or OMP - both consume PI_CODING_AGENT_DIR) so the
// terminal still spawns - the titlebar spinner is not worth blocking
// the PTY.
return existingAgentDir ? { PI_CODING_AGENT_DIR: existingAgentDir } : {}
// Why: old per-PTY overlay cleanup is best-effort; a locked stale
// directory should not prevent the terminal from starting.
}
try {
mkdirSync(overlayDir, { recursive: true })
this.mirrorAgentDir(sourceAgentDir, overlayDir, kind)
this.writeOverlaySettings(sourceAgentDir, overlayDir)
const extensionsDir = join(overlayDir, 'extensions')
mkdirSync(extensionsDir, { recursive: true })
// Why: Pi / OMP both auto-load global extensions from
// PI_CODING_AGENT_DIR/extensions. Add Orca's titlebar extension alongside
// the user's existing extensions instead of replacing that directory,
// otherwise Orca terminals would silently disable the user's
// customization inside Orca only.
safeRemoveTree(join(extensionsDir, ORCA_PI_EXTENSION_FILE))
writeFileSync(join(extensionsDir, ORCA_PI_EXTENSION_FILE), getPiTitlebarExtensionSource())
safeRemoveTree(join(extensionsDir, ORCA_PI_PREFILL_EXTENSION_FILE))
writeFileSync(
join(extensionsDir, ORCA_PI_PREFILL_EXTENSION_FILE),
getPiPrefillExtensionSource(kind)
)
// Why: bundled status extension that bridges the in-process event API
// (`pi.on('agent_start', ...)` etc., identical between Pi and OMP) to the
// unified /hook/<kind> endpoint. Without this, panes would have no entry in
// agentStatusByPaneKey and the dashboard would fall back to terminal-title
// heuristics like any uninstrumented CLI.
safeRemoveTree(join(extensionsDir, ORCA_PI_AGENT_STATUS_EXTENSION_FILE))
writeFileSync(
join(extensionsDir, ORCA_PI_AGENT_STATUS_EXTENSION_FILE),
getPiAgentStatusExtensionSource(kind)
)
} catch {
// Why: overlay creation is best-effort - permission errors (EPERM/EACCES)
// on Windows can occur when the userData directory is restricted or when
// symlink/junction creation fails without developer mode. Fall back to
// the user's own agent dir (Pi or OMP) so the terminal spawns without
// the Orca extension.
this.clearPty(ptyId)
return existingAgentDir ? { PI_CODING_AGENT_DIR: existingAgentDir } : {}
}
return {
PI_CODING_AGENT_DIR: overlayDir
const installed = this.installManagedExtensions(sourceAgentDir, kind)
const env: Record<string, string> = {}
if (kind === 'omp') {
env.ORCA_OMP_SOURCE_AGENT_DIR = installed.sourceAgentDir
if (installed.statusExtensionPath) {
env.ORCA_OMP_STATUS_EXTENSION = installed.statusExtensionPath
}
} else {
env.ORCA_PI_SOURCE_AGENT_DIR = installed.sourceAgentDir
}
return env
}
clearPty(ptyId: string): void {
// Why: PTY teardown doesn't know which kind was launched (the daemon
// exit path discards the launch command). Sweep both old PTY-scoped
// overlay roots for migration cleanup, but leave source-scoped overlays
// alive because another Pi terminal may be using the same source home.
// overlay roots for migration cleanup. Source-scoped legacy overlays are
// deliberately left in place so upgrades never delete user runtime state.
for (const kind of Object.keys(OVERLAY_ROOT_DIR_NAME) as PiAgentKind[]) {
try {
this.safeRemoveOverlay(this.getPtyOverlayDir(ptyId, kind), kind)

View File

@ -22,6 +22,7 @@ export function getPiTitlebarExtensionSource(): string {
'}',
'',
'export default function (pi) {',
' if (!process.env.ORCA_PANE_KEY) return',
' let timer = null',
' let frameIndex = 0',
'',

View File

@ -10,8 +10,8 @@ describe('PowerShell OSC 133 bootstrap', () => {
expect(script).toContain('[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()')
expect(script).toContain('ORCA_OPENCODE_CONFIG_DIR')
expect(script).toContain('ORCA_PI_CODING_AGENT_DIR')
expect(script).toContain('ORCA_OMP_CODING_AGENT_DIR')
expect(script).not.toContain('ORCA_PI_CODING_AGENT_DIR')
expect(script).not.toContain('ORCA_OMP_CODING_AGENT_DIR')
expect(script).toContain('ORCA_OMP_STATUS_EXTENSION')
expect(script).toContain('function Global:omp')
expect(script).toContain('--extension $env:ORCA_OMP_STATUS_EXTENSION')

View File

@ -24,10 +24,6 @@ try {
# Profiles can re-export user defaults after Orca's spawn env is set.
if ($env:ORCA_OPENCODE_CONFIG_DIR) { $env:OPENCODE_CONFIG_DIR = $env:ORCA_OPENCODE_CONFIG_DIR }
if ($env:ORCA_PI_CODING_AGENT_DIR) { $env:PI_CODING_AGENT_DIR = $env:ORCA_PI_CODING_AGENT_DIR }
if (-not $env:ORCA_PI_CODING_AGENT_DIR -and $env:ORCA_OMP_CODING_AGENT_DIR) {
$env:PI_CODING_AGENT_DIR = $env:ORCA_OMP_CODING_AGENT_DIR
}
${getPowerShellOmpShellWrapper()}
if ($env:ORCA_CODEX_HOME) { $env:CODEX_HOME = $env:ORCA_CODEX_HOME }

View File

@ -464,13 +464,12 @@ export class LocalPtyProvider implements IPtyProvider {
}
}
if (!wslInfo && process.platform !== 'win32') {
// Why: any Orca-injected overlay env that user rc files can clobber
// needs the wrapper so the post-rc restore line runs.
// Why: OpenCode/Codex path restoration and OMP's typed-command status
// wrapper need shell-ready code after user startup files run.
const needsNoMarkerWrapper =
finalEnv.ORCA_ATTRIBUTION_SHIM_DIR ||
finalEnv.ORCA_OPENCODE_CONFIG_DIR ||
finalEnv.ORCA_PI_CODING_AGENT_DIR ||
finalEnv.ORCA_OMP_CODING_AGENT_DIR ||
finalEnv.ORCA_OMP_STATUS_EXTENSION ||
finalEnv.ORCA_CODEX_HOME ||
finalEnv.ORCA_AGENT_TEAMS_SHIM_DIR
const isCodexStartupCommand =

View File

@ -330,7 +330,7 @@ describePosix('local PTY shell-ready launch config', () => {
expect(zlogin).toContain('== "user:__orca_prompt_mark"')
})
it('writes wrappers that restore agent config homes after user startup files', async () => {
it('writes wrappers without restoring Pi/OMP homes after user startup files', async () => {
const { getBashShellReadyRcfileContent, getShellReadyLaunchConfig } =
await importFreshLocalPtyShellReady()
@ -341,29 +341,25 @@ describePosix('local PTY shell-ready launch config', () => {
const bashRc = getBashShellReadyRcfileContent()
const restoreLine =
'[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"'
const piRestoreLine =
'[[ -n "${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="${ORCA_PI_CODING_AGENT_DIR}"'
const codexRestoreLine =
'[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"'
const agentTeamsPathRestoreLine = '[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0'
const ompRestoreLine =
'if [[ -z "${ORCA_PI_CODING_AGENT_DIR:-}" && -n "${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then'
const ompWrapperLine = 'command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"'
expect(zshrc).toContain(restoreLine)
expect(zlogin).toContain(restoreLine)
expect(bashRc).toContain(restoreLine)
expect(zshrc).toContain(piRestoreLine)
expect(zlogin).toContain(piRestoreLine)
expect(bashRc).toContain(piRestoreLine)
expect(zshrc).not.toContain('ORCA_PI_CODING_AGENT_DIR')
expect(zlogin).not.toContain('ORCA_PI_CODING_AGENT_DIR')
expect(bashRc).not.toContain('ORCA_PI_CODING_AGENT_DIR')
expect(zshrc).toContain(codexRestoreLine)
expect(zlogin).toContain(codexRestoreLine)
expect(zshrc).toContain(agentTeamsPathRestoreLine)
expect(zlogin).toContain(agentTeamsPathRestoreLine)
expect(bashRc).toContain(agentTeamsPathRestoreLine)
expect(bashRc).toContain(codexRestoreLine)
expect(zshrc).toContain(ompRestoreLine)
expect(zlogin).toContain(ompRestoreLine)
expect(bashRc).toContain(ompRestoreLine)
expect(zshrc).not.toContain('ORCA_OMP_CODING_AGENT_DIR')
expect(zlogin).not.toContain('ORCA_OMP_CODING_AGENT_DIR')
expect(bashRc).not.toContain('ORCA_OMP_CODING_AGENT_DIR')
expect(zshrc).toContain(ompWrapperLine)
expect(zlogin).toContain(ompWrapperLine)
expect(bashRc).toContain(ompWrapperLine)

View File

@ -175,12 +175,6 @@ __orca_restore_agent_teams_path
# Why: user startup files may set the default OpenCode config after Orca's
# spawn env; restore the Orca-managed config dir before the first prompt.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
# Why: bare shells carry both Pi and OMP shadows so a later typed OMP can
# switch on demand. Keep Pi as the shell default unless this PTY is OMP-only.
[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then
export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}"
fi
${getPosixOmpShellWrapper()}
# Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts.
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
@ -296,12 +290,6 @@ __orca_restore_agent_teams_path() {
if [[ ! -o login ]]; then
# Why: ~/.zshrc can export the user's default OpenCode config after spawn.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
# Why: bare shells carry both Pi and OMP shadows; keep Pi as the default and
# let the OMP wrapper switch to OMP only for that command.
[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then
export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}"
fi
${getPosixOmpShellWrapper()}
# Why: Codex must keep using Orca's runtime CODEX_HOME after rc files.
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
@ -365,10 +353,6 @@ __orca_restore_agent_teams_path() {
__orca_restore_agent_teams_path
# Why: .zlogin is the final login startup file before the prompt is shown.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then
export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}"
fi
${getPosixOmpShellWrapper()}
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER_ESCAPED)}

View File

@ -41,26 +41,21 @@ describe('resolveWindowsShellLaunchArgs', () => {
const opencodeRestoreIndex = command.indexOf(
'$env:OPENCODE_CONFIG_DIR = $env:ORCA_OPENCODE_CONFIG_DIR'
)
const piRestoreIndex = command.indexOf(
'$env:PI_CODING_AGENT_DIR = $env:ORCA_PI_CODING_AGENT_DIR'
)
const ompRestoreIndex = command.indexOf(
'$env:PI_CODING_AGENT_DIR = $env:ORCA_OMP_CODING_AGENT_DIR'
)
const ompSourceConfigIndex = command.indexOf(
'$env:PI_CODING_AGENT_DIR = $env:ORCA_OMP_SOURCE_AGENT_DIR'
)
const ompWrapperIndex = command.indexOf('function Global:omp')
const ompExtensionIndex = command.indexOf('--extension $env:ORCA_OMP_STATUS_EXTENSION')
const codexRestoreIndex = command.indexOf('$env:CODEX_HOME = $env:ORCA_CODEX_HOME')
const promptIndex = command.indexOf('function Global:prompt')
expect(command).not.toContain('$PROFILE')
expect(command).not.toContain('ORCA_PI_CODING_AGENT_DIR')
expect(command).not.toContain('ORCA_OMP_CODING_AGENT_DIR')
expect(command).not.toContain('$env:PI_CODING_AGENT_DIR = $env:ORCA_OMP_SOURCE_AGENT_DIR')
expect(outputEncodingIndex).toBeGreaterThanOrEqual(0)
expect(opencodeRestoreIndex).toBeGreaterThan(outputEncodingIndex)
expect(piRestoreIndex).toBeGreaterThan(outputEncodingIndex)
expect(ompRestoreIndex).toBeGreaterThan(piRestoreIndex)
expect(ompSourceConfigIndex).toBeGreaterThan(ompRestoreIndex)
expect(ompWrapperIndex).toBeGreaterThan(opencodeRestoreIndex)
expect(ompExtensionIndex).toBeGreaterThan(ompWrapperIndex)
expect(codexRestoreIndex).toBeGreaterThan(outputEncodingIndex)
expect(codexRestoreIndex).toBeGreaterThan(ompRestoreIndex)
expect(codexRestoreIndex).toBeGreaterThan(ompWrapperIndex)
expect(promptIndex).toBeGreaterThan(codexRestoreIndex)
expect(command).toContain('Esc = [char]27')
expect(command).toContain('Bel = [char]7')

View File

@ -118,10 +118,11 @@ describePosix('OMP shell wrapper node-pty reproduction', () => {
...process.env,
HOME: tempDir,
PATH: `${binDir}:${process.env.PATH ?? ''}`,
PI_CODING_AGENT_DIR: piDir,
ORCA_PI_CODING_AGENT_DIR: piDir,
ORCA_OMP_CODING_AGENT_DIR: ompDir,
PI_CODING_AGENT_DIR: '',
ORCA_PI_CODING_AGENT_DIR: '',
ORCA_OMP_CODING_AGENT_DIR: '',
ORCA_OMP_STATUS_EXTENSION: statusExtension,
ORCA_FAKE_OMP_DEFAULT_DIR: ompDir,
ORCA_CAPTURE_FILE: captureFile,
ORCA_AFTER_PI_FILE: afterPiFile,
TERM: process.env.TERM || 'xterm-256color'
@ -140,7 +141,8 @@ exit 0
})
const unwrapped = readFileSync(unwrappedCapture, 'utf8')
expect(unwrapped).toContain(`PI=${piDir}`)
expect(unwrapped).toContain('PI=\n')
expect(unwrapped).toContain(`EFFECTIVE=${ompDir}`)
expect(unwrapped).toContain('ARG1=ask')
expect(unwrapped).not.toContain('ARG1=--extension')
@ -148,8 +150,7 @@ exit 0
const wrappedAfterPi = join(tempDir, 'wrapped-after-pi')
const wrappedOutput = await runInteractiveBashPty({
cwd: tempDir,
rcfileContent: `[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
${getPosixOmpShellWrapper()}`,
rcfileContent: getPosixOmpShellWrapper(),
env: makeEnv(wrappedCapture, wrappedAfterPi),
input: `type omp
omp ask
@ -160,19 +161,19 @@ exit 0
const wrapped = readFileSync(wrappedCapture, 'utf8')
expect(wrappedOutput).toContain('omp is a function')
expect(wrapped).toContain(`PI=${ompDir}`)
expect(wrapped).toContain('PI=\n')
expect(wrapped).toContain(`EFFECTIVE=${ompDir}`)
expect(wrapped).toContain('ARG1=--extension')
expect(wrapped).toContain(`ARG2=${statusExtension}`)
expect(wrapped).toContain('ARG3=ask')
expect(readFileSync(wrappedAfterPi, 'utf8')).toBe(piDir)
expect(readFileSync(wrappedAfterPi, 'utf8')).toBe('')
})
itWithBash('runs OMP config subcommands against the source home, not the overlay', async () => {
itWithBash('runs OMP config subcommands without redirecting the home', async () => {
const tempDir = makeTempDir()
const binDir = join(tempDir, 'bin')
const sourceDir = join(tempDir, 'source-omp-agent')
const overlayDir = join(tempDir, 'overlay-omp-agent')
const extensionDir = join(overlayDir, 'extensions')
const extensionDir = join(sourceDir, 'extensions')
mkdirSync(binDir)
mkdirSync(sourceDir, { recursive: true })
mkdirSync(extensionDir, { recursive: true })
@ -183,14 +184,14 @@ exit 0
const captureFile = join(tempDir, 'config-capture')
await runInteractiveBashPty({
cwd: tempDir,
rcfileContent: `[[ -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}"
${getPosixOmpShellWrapper()}`,
rcfileContent: getPosixOmpShellWrapper(),
env: {
...process.env,
HOME: tempDir,
PATH: `${binDir}:${process.env.PATH ?? ''}`,
PI_CODING_AGENT_DIR: overlayDir,
ORCA_OMP_CODING_AGENT_DIR: overlayDir,
PI_CODING_AGENT_DIR: '',
ORCA_PI_CODING_AGENT_DIR: '',
ORCA_OMP_CODING_AGENT_DIR: '',
ORCA_OMP_SOURCE_AGENT_DIR: sourceDir,
ORCA_OMP_STATUS_EXTENSION: statusExtension,
ORCA_FAKE_OMP_DEFAULT_DIR: sourceDir,
@ -203,11 +204,10 @@ exit 0
})
const capture = readFileSync(captureFile, 'utf8')
expect(capture).toContain(`PI=${sourceDir}`)
expect(capture).toContain('PI=\n')
expect(capture).toContain(`EFFECTIVE=${sourceDir}`)
expect(capture).toContain('ARG1=config')
expect(readFileSync(join(sourceDir, 'config.yml'), 'utf8')).toBe('updated-by-omp-config\n')
expect(() => readFileSync(join(overlayDir, 'config.yml'), 'utf8')).toThrow()
})
itWithBash(
@ -216,8 +216,7 @@ exit 0
const tempDir = makeTempDir()
const binDir = join(tempDir, 'bin')
const defaultOmpDir = join(tempDir, '.omp', 'agent')
const overlayDir = join(tempDir, 'overlay-omp-agent')
const extensionDir = join(overlayDir, 'extensions')
const extensionDir = join(defaultOmpDir, 'extensions')
mkdirSync(binDir)
mkdirSync(defaultOmpDir, { recursive: true })
mkdirSync(extensionDir, { recursive: true })
@ -228,14 +227,14 @@ exit 0
const captureFile = join(tempDir, 'default-config-capture')
await runInteractiveBashPty({
cwd: tempDir,
rcfileContent: `[[ -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}"
${getPosixOmpShellWrapper()}`,
rcfileContent: getPosixOmpShellWrapper(),
env: {
...process.env,
HOME: tempDir,
PATH: `${binDir}:${process.env.PATH ?? ''}`,
PI_CODING_AGENT_DIR: overlayDir,
ORCA_OMP_CODING_AGENT_DIR: overlayDir,
PI_CODING_AGENT_DIR: '',
ORCA_PI_CODING_AGENT_DIR: '',
ORCA_OMP_CODING_AGENT_DIR: '',
ORCA_OMP_STATUS_EXTENSION: statusExtension,
ORCA_FAKE_OMP_DEFAULT_DIR: defaultOmpDir,
ORCA_CAPTURE_FILE: captureFile,
@ -252,7 +251,6 @@ exit 0
expect(readFileSync(join(defaultOmpDir, 'config.yml'), 'utf8')).toBe(
'updated-by-omp-config\n'
)
expect(() => readFileSync(join(overlayDir, 'config.yml'), 'utf8')).toThrow()
}
)
})

View File

@ -1,6 +1,8 @@
// Why: OMP 15.x discovers built-in user extensions from ~/.omp/agent, not
// PI_CODING_AGENT_DIR/extensions. Orca's status extension must be
// passed explicitly when users type `omp` in an existing terminal.
// Why: OMP 15.x discovers built-in user extensions from ~/.omp/agent, but a
// typed `omp` in an existing terminal still needs Orca's status extension
// passed explicitly. Do not redirect PI_CODING_AGENT_DIR here: that variable
// is OMP's mutable home, so config/auth/session commands must keep the user's
// normal source of truth.
const OMP_SUBCOMMANDS = [
'acp',
@ -26,7 +28,7 @@ const OMP_SUBCOMMANDS = [
export function getPosixOmpShellWrapper(): string {
const subcommands = OMP_SUBCOMMANDS.join('|')
return `# Why: OMP does not auto-load Orca's PTY overlay extensions; wrap only
return `# Why: OMP does not auto-load Orca's managed status extension; wrap only
# interactive launch invocations so subcommands such as \`omp config\` keep
# their normal argv shape.
__orca_omp_is_subcommand() {
@ -42,25 +44,9 @@ __orca_omp_should_skip_extension() {
__orca_omp_is_subcommand "\${1:-}"
}
__orca_omp() {
local __orca_prev_pi="\${PI_CODING_AGENT_DIR-}"
local __orca_had_pi=0
[[ -n "\${PI_CODING_AGENT_DIR+x}" ]] && __orca_had_pi=1
local __orca_use_overlay=1
__orca_omp_should_skip_extension "\${1:-}" && __orca_use_overlay=0
if [[ $__orca_use_overlay -eq 1 && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then
export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}"
elif [[ $__orca_use_overlay -eq 0 ]]; then
# Why: config/editing subcommands mutate OMP's home. Route those to the
# user's source home instead of Orca's status-extension runtime overlay.
if [[ -n "\${ORCA_OMP_SOURCE_AGENT_DIR:-}" ]]; then
export PI_CODING_AGENT_DIR="\${ORCA_OMP_SOURCE_AGENT_DIR}"
else
unset PI_CODING_AGENT_DIR
fi
fi
local __orca_status=0
if [[ $__orca_use_overlay -eq 1 && -n "\${ORCA_OMP_STATUS_EXTENSION:-}" && -f "\${ORCA_OMP_STATUS_EXTENSION}" ]]; then
local __orca_use_extension=1
__orca_omp_should_skip_extension "\${1:-}" && __orca_use_extension=0
if [[ $__orca_use_extension -eq 1 && -n "\${ORCA_OMP_STATUS_EXTENSION:-}" && -f "\${ORCA_OMP_STATUS_EXTENSION}" ]]; then
if [[ "\${1:-}" == "launch" ]]; then
shift
command omp launch --extension "\${ORCA_OMP_STATUS_EXTENSION}" "$@"
@ -70,16 +56,8 @@ __orca_omp() {
else
command omp "$@"
fi
__orca_status=$?
if [[ $__orca_had_pi -eq 1 ]]; then
export PI_CODING_AGENT_DIR="$__orca_prev_pi"
else
unset PI_CODING_AGENT_DIR
fi
return $__orca_status
}
if [[ -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" || -n "\${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
if [[ -n "\${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
omp() { __orca_omp "$@"; }
fi
`
@ -87,7 +65,7 @@ fi
export function getPowerShellOmpShellWrapper(): string {
const subcommands = OMP_SUBCOMMANDS.map((value) => `'${value}'`).join(', ')
return `# Why: OMP does not auto-load Orca's PTY overlay extensions; wrap only
return `# Why: OMP does not auto-load Orca's managed status extension; wrap only
# interactive launch invocations so subcommands such as \`omp config\` keep
# their normal argv shape.
function Global:__OrcaOmpIsSubcommand {
@ -100,29 +78,15 @@ function Global:__OrcaOmpShouldSkipExtension {
if (@("help", "--help", "-h", "--version", "-v") -contains $Name) { return $true }
return __OrcaOmpIsSubcommand -Name $Name
}
if ($env:ORCA_OMP_CODING_AGENT_DIR -or $env:ORCA_OMP_STATUS_EXTENSION) {
if ($env:ORCA_OMP_STATUS_EXTENSION) {
function Global:omp {
$orcaPrevPi = $env:PI_CODING_AGENT_DIR
$orcaHadPi = Test-Path Env:PI_CODING_AGENT_DIR
$orcaUseOverlay = -not (__OrcaOmpShouldSkipExtension -Name ([string]($args[0])))
if ($orcaUseOverlay -and $env:ORCA_OMP_CODING_AGENT_DIR) {
$env:PI_CODING_AGENT_DIR = $env:ORCA_OMP_CODING_AGENT_DIR
} elseif (-not $orcaUseOverlay) {
# Why: config/editing subcommands mutate OMP's home. Route those to
# the user's source home instead of Orca's runtime overlay.
if ($env:ORCA_OMP_SOURCE_AGENT_DIR) {
$env:PI_CODING_AGENT_DIR = $env:ORCA_OMP_SOURCE_AGENT_DIR
} else {
Remove-Item Env:PI_CODING_AGENT_DIR -ErrorAction SilentlyContinue
}
}
$orcaUseExtension = -not (__OrcaOmpShouldSkipExtension -Name ([string]($args[0])))
$orcaStatus = 0
$orcaCommand = Get-Command omp -CommandType Application,ExternalScript -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $orcaCommand) {
Write-Error "omp executable not found"
$orcaStatus = 127
} elseif ($orcaUseOverlay -and $env:ORCA_OMP_STATUS_EXTENSION -and
} elseif ($orcaUseExtension -and $env:ORCA_OMP_STATUS_EXTENSION -and
(Test-Path -LiteralPath $env:ORCA_OMP_STATUS_EXTENSION)) {
if ($args.Count -gt 0 -and $args[0] -eq "launch") {
$orcaLaunchArgs = @($args | Select-Object -Skip 1)
@ -136,11 +100,6 @@ if ($env:ORCA_OMP_CODING_AGENT_DIR -or $env:ORCA_OMP_STATUS_EXTENSION) {
$orcaStatus = $LASTEXITCODE
}
if ($orcaHadPi) {
$env:PI_CODING_AGENT_DIR = $orcaPrevPi
} else {
Remove-Item Env:PI_CODING_AGENT_DIR -ErrorAction SilentlyContinue
}
$global:LASTEXITCODE = $orcaStatus
}
}

View File

@ -44,9 +44,9 @@ function prepareShellConfigDirEnv(agentId: string): { ok: true; env?: NodeJS.Pro
return null
}
// Why: each kind owns a distinct ORCA_*_SOURCE_* shadow so a headless commit
// run from inside an OMP overlay restores the OMP source dir, never the Pi
// one (and vice versa). PI_CODING_AGENT_DIR is the binary-facing var both
// kinds emit — see src/main/pi/titlebar-extension-service.ts.
// run from inside a legacy OMP overlay restores the OMP source dir, never
// the Pi one (and vice versa). PI_CODING_AGENT_DIR is the binary-facing var
// both kinds consume — see src/main/pi/titlebar-extension-service.ts.
const sourceVar =
agentId === 'opencode'
? 'ORCA_OPENCODE_SOURCE_CONFIG_DIR'

View File

@ -70,12 +70,24 @@ describe('PluginOverlayManager', () => {
expect(manager.materializeOpenCode('tab-missing:0', join(homeDir, 'missing'))).toBeNull()
})
it('materializes Pi extension into <overlay>/extensions/<file>', () => {
it('installs Pi extension into the real agent extensions dir', () => {
manager.setSources({ piExtensionSource: '// pi extension' })
const dir = manager.materializePi('tab-2:0')
expect(dir).not.toBeNull()
const file = join(dir!, 'extensions', 'orca-agent-status.ts')
expect(existsSync(file)).toBe(true)
expect(readFileSync(file, 'utf8')).toContain('@orca-managed-pi-extension')
})
it("does not overwrite a user's same-named remote Pi extension file", () => {
const piAgentDir = join(homeDir, '.pi', 'agent')
const extensionFile = join(piAgentDir, 'extensions', 'orca-agent-status.ts')
mkdirSync(join(piAgentDir, 'extensions'), { recursive: true })
writeFileSync(extensionFile, 'user-owned remote status extension')
manager.setSources({ piExtensionSource: '// pi extension' })
expect(manager.materializePi('tab-user-owned-pi:0')).toBeNull()
expect(readFileSync(extensionFile, 'utf8')).toBe('user-owned remote status extension')
})
it('uses the kind-specific Pi-compatible extension source when available', () => {
@ -89,15 +101,15 @@ describe('PluginOverlayManager', () => {
expect(piDir).not.toBeNull()
expect(ompDir).not.toBeNull()
expect(readFileSync(join(piDir!, 'extensions', 'orca-agent-status.ts'), 'utf8')).toBe(
expect(readFileSync(join(piDir!, 'extensions', 'orca-agent-status.ts'), 'utf8')).toContain(
'// pi extension'
)
expect(readFileSync(join(ompDir!, 'extensions', 'orca-agent-status.ts'), 'utf8')).toBe(
expect(readFileSync(join(ompDir!, 'extensions', 'orca-agent-status.ts'), 'utf8')).toContain(
'// omp extension'
)
})
it('mirrors the remote default Pi agent dir before adding Orca status extension', () => {
it('installs Orca status extension into the remote default Pi agent dir', () => {
const piAgentDir = join(homeDir, '.pi', 'agent')
mkdirSync(join(piAgentDir, 'skills', 'my-skill'), { recursive: true })
mkdirSync(join(piAgentDir, 'extensions', 'user-ext'), { recursive: true })
@ -133,10 +145,10 @@ describe('PluginOverlayManager', () => {
])
expect(JSON.parse(readFileSync(join(dir!, 'settings.json'), 'utf8'))).toEqual({
defaultProvider: 'amazon-bedrock',
hideThinkingBlock: true,
hideThinkingBlock: false,
terminal: {
showImages: false,
clearOnShrink: true
clearOnShrink: false
}
})
expect(JSON.parse(readFileSync(join(piAgentDir, 'settings.json'), 'utf8'))).toEqual({
@ -164,36 +176,36 @@ describe('PluginOverlayManager', () => {
expect(dir).not.toBeNull()
expect(readFileSync(join(dir!, 'auth.json'), 'utf8')).toBe('custom token')
expect(readFileSync(join(dir!, 'extensions', 'custom.ts'), 'utf8')).toBe('custom extension')
expect(readFileSync(join(dir!, 'extensions', 'orca-agent-status.ts'), 'utf8')).toBe(
expect(readFileSync(join(dir!, 'extensions', 'orca-agent-status.ts'), 'utf8')).toContain(
'// pi extension'
)
})
it('source-backs lazy OMP agent.db on the relay', () => {
it('leaves lazy OMP agent.db in the real remote home on the relay', () => {
manager.setSources({ piExtensionSource: '// pi extension' })
const sourceDir = join(homeDir, '.omp', 'agent')
const firstDir = manager.materializePi('tab-relay-omp-sqlite:0', undefined, 'omp')
expect(firstDir).not.toBeNull()
expect(firstDir).toBe(sourceDir)
const sourcePath = join(sourceDir, 'agent.db')
const overlayPath = join(firstDir!, 'agent.db')
const content = 'agent.db relay credentials'
expect(existsSync(sourcePath)).toBe(true)
expect(existsSync(overlayPath)).toBe(true)
expect(existsSync(sourcePath)).toBe(false)
expect(existsSync(join(homeDir, '.orca-relay', 'omp-overlays'))).toBe(false)
expect(existsSync(join(sourceDir, 'history.db'))).toBe(false)
writeFileSync(overlayPath, content)
writeFileSync(sourcePath, content)
expect(readFileSync(sourcePath, 'utf8')).toBe(content)
const secondDir = manager.materializePi('tab-relay-omp-sqlite:0', undefined, 'omp')
expect(secondDir).toBe(firstDir)
expect(readFileSync(join(secondDir!, 'agent.db'), 'utf8')).toBe('agent.db relay credentials')
expect(readFileSync(join(secondDir!, 'agent.db'), 'utf8')).toBe(content)
})
// Why: per-agent overlay source dir. The renderer picks Pi or OMP per
// launch, and the relay must mirror the right `~/.<kind>/agent` source —
// Why: per-agent source dir. The renderer picks Pi or OMP per
// launch, and the relay must use the right `~/.<kind>/agent` source —
// disk-presence guessing (always-Pi or first-exists) shadows the other
// agent's user extensions when both dirs exist on the remote disk.
describe('per-agent default source dir (no cross-agent fallback)', () => {
@ -205,7 +217,7 @@ describe('PluginOverlayManager', () => {
return agentDir
}
it('launching pi with both ~/.pi/agent and ~/.omp/agent present mirrors ~/.pi/agent into pi-overlays', () => {
it('launching pi with both ~/.pi/agent and ~/.omp/agent present installs into ~/.pi/agent', () => {
seedAgentDir('.pi', 'pi')
seedAgentDir('.omp', 'omp')
@ -213,15 +225,15 @@ describe('PluginOverlayManager', () => {
const dir = manager.materializePi('tab-relay-pi-both:0', undefined, 'pi')
expect(dir).not.toBeNull()
// Pi overlays live under .orca-relay/pi-overlays, separate from OMP's tree.
expect(dir!).toMatch(/[\\/]\.orca-relay[\\/]pi-overlays[\\/]/)
expect(dir).toBe(join(homeDir, '.pi', 'agent'))
expect(readFileSync(join(dir!, 'auth.json'), 'utf8')).toBe('pi token')
const overlayExtensions = readdirSync(join(dir!, 'extensions')).sort()
expect(overlayExtensions).toContain('pi-ext')
expect(overlayExtensions).not.toContain('omp-ext')
const extensions = readdirSync(join(dir!, 'extensions')).sort()
expect(extensions).toContain('pi-ext')
expect(extensions).toContain('orca-agent-status.ts')
expect(extensions).not.toContain('omp-ext')
})
it('launching omp with both ~/.pi/agent and ~/.omp/agent present mirrors ~/.omp/agent into omp-overlays', () => {
it('launching omp with both ~/.pi/agent and ~/.omp/agent present installs into ~/.omp/agent', () => {
seedAgentDir('.pi', 'pi')
seedAgentDir('.omp', 'omp')
@ -229,23 +241,20 @@ describe('PluginOverlayManager', () => {
const dir = manager.materializePi('tab-relay-omp-both:0', undefined, 'omp')
expect(dir).not.toBeNull()
// CRITICAL: OMP overlays live in a distinct subtree (.orca-relay/omp-overlays)
// so the remote box never mixes Pi and OMP overlay state for the same paneKey.
expect(dir!).toMatch(/[\\/]\.orca-relay[\\/]omp-overlays[\\/]/)
expect(dir!).not.toMatch(/[\\/]pi-overlays[\\/]/)
expect(dir).toBe(join(homeDir, '.omp', 'agent'))
// Even though ~/.pi/agent exists, the OMP launch MUST mirror OMP's
// source dir. Cross-agent fallback would silently shadow the user's
// OMP extensions on the remote.
expect(readFileSync(join(dir!, 'auth.json'), 'utf8')).toBe('omp token')
const overlayExtensions = readdirSync(join(dir!, 'extensions')).sort()
expect(overlayExtensions).toContain('omp-ext')
expect(overlayExtensions).not.toContain('pi-ext')
const extensions = readdirSync(join(dir!, 'extensions')).sort()
expect(extensions).toContain('omp-ext')
expect(extensions).toContain('orca-agent-status.ts')
expect(extensions).not.toContain('pi-ext')
})
it('launching omp when only ~/.pi/agent exists does NOT mirror Pi state', () => {
// Why: missing OMP source dir on the remote must materialize the
// overlay from empty — Orca's status extension only, no Pi state
// cross-pollinated in.
// Why: missing OMP source dir on the remote must create only OMP's
// own extension dir. Pi state must never cross-pollinate in.
seedAgentDir('.pi', 'pi')
expect(existsSync(join(homeDir, '.omp'))).toBe(false)
@ -253,15 +262,11 @@ describe('PluginOverlayManager', () => {
const dir = manager.materializePi('tab-relay-omp-empty:0', undefined, 'omp')
expect(dir).not.toBeNull()
expect(dir!).toMatch(/[\\/]\.orca-relay[\\/]omp-overlays[\\/]/)
// Pi-only home must NOT leak into the OMP overlay.
expect(dir).toBe(join(homeDir, '.omp', 'agent'))
// Pi-only home must NOT leak into the OMP home.
expect(existsSync(join(dir!, 'auth.json'))).toBe(false)
const overlayExtensions = readdirSync(join(dir!, 'extensions')).sort()
expect(overlayExtensions).toEqual(['orca-agent-status.ts'])
expect(JSON.parse(readFileSync(join(dir!, 'settings.json'), 'utf8'))).toEqual({
hideThinkingBlock: true,
terminal: { clearOnShrink: true }
})
const extensions = readdirSync(join(dir!, 'extensions')).sort()
expect(extensions).toEqual(['orca-agent-status.ts'])
})
})
@ -271,7 +276,7 @@ describe('PluginOverlayManager', () => {
expect(manager.materializePi('tab-missing-pi:0', join(homeDir, 'missing-pi'))).toBeNull()
})
it('clearOverlay removes opencode + every Pi-kind overlay root for an id', () => {
it('clearOverlay removes OpenCode overlays without deleting real Pi/OMP homes', () => {
manager.setSources({
opencodePluginSource: 'opencode',
piExtensionSource: 'pi',
@ -280,7 +285,6 @@ describe('PluginOverlayManager', () => {
const opencodeDir = manager.materializeOpenCode('tab-3:0')!
const piDir = manager.materializePi('tab-3:0', undefined, 'pi')!
const ompDir = manager.materializePi('tab-3:0', undefined, 'omp')!
// Sanity: each kind got its own subtree, not a shared one.
expect(piDir).not.toBe(ompDir)
expect(existsSync(opencodeDir)).toBe(true)
expect(existsSync(piDir)).toBe(true)
@ -289,8 +293,8 @@ describe('PluginOverlayManager', () => {
manager.clearOverlay('tab-3:0')
expect(existsSync(opencodeDir)).toBe(false)
expect(existsSync(piDir)).toBe(false)
expect(existsSync(ompDir)).toBe(false)
expect(existsSync(piDir)).toBe(true)
expect(existsSync(ompDir)).toBe(true)
})
it.skipIf(process.platform === 'win32')(

View File

@ -1,9 +1,8 @@
// Why: relay-side equivalent of Orca's userData-backed plugin overlay system.
// Orca's local OpenCodeHookService and PiTitlebarExtensionService each
// materialize a per-PTY overlay and inject OPENCODE_CONFIG_DIR /
// PI_CODING_AGENT_DIR pointing at it. Those paths describe the local
// filesystem and would resolve to nothing on a remote box, so when a PTY runs
// on the relay, the relay must do the same materialization on its own disk.
// Why: relay-side equivalent of Orca's local agent integration installers.
// OpenCode still needs a config overlay, while Pi/OMP now get Orca-managed
// extension files installed into the remote agent homes. Host paths from the
// renderer are meaningless on SSH targets, so the relay performs the remote
// filesystem work itself.
//
// Plugin source strings ship over the JSON-RPC channel at session-ready
// (commit #7) — they are NOT bundled with the relay binary because the
@ -14,7 +13,8 @@
// We deliberately do not reuse OpenCodeHookService / PiTitlebarExtensionService
// directly: those modules import `electron` and ride on Orca's userData
// path. The relay's electron-free constraint forces a thin parallel
// implementation rooted at $HOME/.orca-relay/.
// implementation rooted at $HOME/.orca-relay/ for OpenCode and at the remote
// Pi/OMP homes for those agents.
import { createHash } from 'crypto'
import {
@ -28,13 +28,8 @@ import {
writeFileSync
} from 'fs'
import { homedir } from 'os'
import { basename, join } from 'path'
import { join } from 'path'
import { mirrorEntry, safeRemoveOverlay } from '../main/pty/overlay-mirror'
import {
isOmpPersistentSqliteEntry,
mirrorOmpPersistentSqliteFiles
} from '../main/pty/omp-sqlite-overlay'
import { mergePiOverlayUiSettings } from '../shared/pi-overlay-ui-settings'
import type { PiAgentKind } from '../shared/pi-agent-kind'
const RELAY_HOOKS_DIR = '.orca-relay'
@ -46,7 +41,13 @@ const PI_OVERLAY_SUBDIR_BY_KIND: Record<PiAgentKind, string> = {
const OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js'
const PI_EXTENSION_FILE = 'orca-agent-status.ts'
const PI_AGENT_SUBDIR = 'agent'
const PI_AGENT_SETTINGS_FILE = 'settings.json'
const ORCA_MANAGED_EXTENSION_MARKER = '@orca-managed-pi-extension'
function withOrcaManagedPiExtensionMarker(source: string): string {
return source.includes(ORCA_MANAGED_EXTENSION_MARKER)
? source
: `// ${ORCA_MANAGED_EXTENSION_MARKER}\n${source}`
}
// Why: source-dir resolution is keyed off the launching agent (Pi or OMP).
// Both consume `PI_CODING_AGENT_DIR` but default to different `~/.<kind>/agent`
// paths on the remote disk. The renderer-chosen launch command flows in via
@ -106,19 +107,18 @@ export class PluginOverlayManager {
* `agent_hook.installPlugins`. The first install enables the augmenter
* output; subsequent installs (e.g. Orca version upgrade in flight) refresh
* the cached source so future spawns see the new strings.
* Note: existing per-PTY overlays already on disk keep the previous source
* until that PTY exits a long-running PTY does NOT pick up the new
* source, matching the local-Orca behavior where the plugin file is
* written once at spawn time. */
* Note: existing running agents keep whatever source they loaded at
* process start. Future PTYs pick up the refreshed source when the relay
* writes plugin/extension files before spawn. */
setSources(sources: PluginSources): void {
if (typeof sources.opencodePluginSource === 'string') {
this.opencodePluginSource = sources.opencodePluginSource
}
if (typeof sources.piExtensionSource === 'string') {
this.piExtensionSources.pi = sources.piExtensionSource
this.piExtensionSources.pi = withOrcaManagedPiExtensionMarker(sources.piExtensionSource)
}
if (typeof sources.ompExtensionSource === 'string') {
this.piExtensionSources.omp = sources.ompExtensionSource
this.piExtensionSources.omp = withOrcaManagedPiExtensionMarker(sources.ompExtensionSource)
}
}
@ -222,118 +222,38 @@ export class PluginOverlayManager {
return join(this.homeDir, PI_AGENT_HOME_DIR_NAME[kind], PI_AGENT_SUBDIR)
}
private mirrorPiAgentDir(sourceAgentDir: string, overlayDir: string, kind: PiAgentKind): void {
if (!existsSync(sourceAgentDir)) {
if (kind === 'omp') {
mirrorOmpPersistentSqliteFiles(sourceAgentDir, overlayDir)
}
return
}
for (const entry of readdirSync(sourceAgentDir, { withFileTypes: true })) {
const sourcePath = join(sourceAgentDir, entry.name)
if (entry.name === PI_AGENT_SETTINGS_FILE) {
continue
}
if (kind === 'omp' && isOmpPersistentSqliteEntry(entry.name)) {
continue
}
if (entry.name === 'extensions') {
const isSymlink = entry.isSymbolicLink()
let isLinkPointingToDir = false
if (isSymlink) {
try {
isLinkPointingToDir = statSync(sourcePath).isDirectory()
} catch {
isLinkPointingToDir = false
}
}
if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) {
const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath
const overlayExtensionsDir = join(overlayDir, 'extensions')
mkdirSync(overlayExtensionsDir, { recursive: true })
for (const extensionEntry of readdirSync(resolvedSource, { withFileTypes: true })) {
if (extensionEntry.name === PI_EXTENSION_FILE) {
continue
}
mirrorEntry(
join(resolvedSource, extensionEntry.name),
join(overlayExtensionsDir, extensionEntry.name)
)
}
continue
}
}
mirrorEntry(sourcePath, join(overlayDir, basename(sourcePath)))
}
if (kind === 'omp') {
mirrorOmpPersistentSqliteFiles(sourceAgentDir, overlayDir)
}
}
private readPiSettings(sourceAgentDir: string): unknown {
const settingsPath = join(sourceAgentDir, PI_AGENT_SETTINGS_FILE)
if (!existsSync(settingsPath)) {
return {}
}
private canOverwritePiExtension(path: string): boolean {
try {
return JSON.parse(readFileSync(settingsPath, 'utf8'))
return readFileSync(path, 'utf8').includes(ORCA_MANAGED_EXTENSION_MARKER)
} catch {
return {}
return true
}
}
private writePiOverlaySettings(sourceAgentDir: string, overlayDir: string): void {
// Why: relay overlays run on the remote disk, but the same Pi UI guardrails
// need to stay overlay-local so SSH sessions do not mutate user config.
const settings = mergePiOverlayUiSettings(this.readPiSettings(sourceAgentDir))
writeFileSync(
join(overlayDir, PI_AGENT_SETTINGS_FILE),
`${JSON.stringify(settings, null, 2)}\n`
)
}
/** Materialize the Pi extension overlay for `id` and return the directory
* path that should be assigned to PI_CODING_AGENT_DIR. `kind` selects which
* Pi-compatible agent's source dir to mirror when `existingAgentDir` is
* not supplied - defaults to 'pi' for back-compat with pre-OMP callers.
* NEVER fall back across kinds: a missing source dir for the chosen kind
* materializes the overlay from empty (Orca extensions only) rather than
* silently mirroring the other agent's state. */
/** Install the Pi/OMP status extension into the remote real agent dir and
* return that directory. `kind` selects which Pi-compatible agent's default
* dir to use when `existingAgentDir` is not supplied. */
materializePi(id: string, existingAgentDir?: string, kind: PiAgentKind = 'pi'): string | null {
const extensionSource = this.getPiExtensionSource(kind)
if (!extensionSource || !isUsableId(id)) {
return null
}
const root = this.piRoots[kind]
const dir = join(root, safeDirName(id))
try {
const sourceAgentDir = existingAgentDir ?? this.getDefaultPiAgentDir(kind)
if (existingAgentDir && !existsSync(existingAgentDir)) {
return null
}
// Why: PI_CODING_AGENT_DIR is the whole state root for both Pi and OMP
// (OMP inherits the env-var name from Pi by design). Mirror the remote
// user's default agent dir so Orca's status extension does not hide auth,
// sessions, skills, prompts, themes, or user extensions inside SSH panes.
safeRemoveOverlay(dir, root)
mkdirSync(dir, { recursive: true })
this.mirrorPiAgentDir(sourceAgentDir, dir, kind)
this.writePiOverlaySettings(sourceAgentDir, dir)
const extensionsDir = join(dir, 'extensions')
const extensionsDir = join(sourceAgentDir, 'extensions')
mkdirSync(extensionsDir, { recursive: true })
writeFileSync(join(extensionsDir, PI_EXTENSION_FILE), extensionSource)
return dir
const extensionPath = join(extensionsDir, PI_EXTENSION_FILE)
if (!this.canOverwritePiExtension(extensionPath)) {
return null
}
writeFileSync(extensionPath, extensionSource)
return sourceAgentDir
} catch (err) {
process.stderr.write(
`[plugin-overlay] failed to materialize ${kind} overlay: ${err instanceof Error ? err.message : String(err)}\n`
`[plugin-overlay] failed to install ${kind} extension: ${err instanceof Error ? err.message : String(err)}\n`
)
return null
}

View File

@ -694,10 +694,12 @@ describe('PtyHandler', () => {
async () => {
const oldShell = process.env.SHELL
const oldHome = process.env.HOME
const oldOrcaPi = process.env.ORCA_PI_CODING_AGENT_DIR
const homeDir = mkdtempSync(join(tmpdir(), 'relay-pty-shell-launch-'))
process.env.SHELL = '/bin/bash'
process.env.HOME = homeDir
delete process.env.ORCA_PI_CODING_AGENT_DIR
try {
if (!existsSync('/bin/bash')) {
return
@ -706,8 +708,7 @@ describe('PtyHandler', () => {
handler.addEnvAugmenter(() => ({
OPENCODE_CONFIG_DIR: '/remote/overlay/opencode',
ORCA_OPENCODE_CONFIG_DIR: '/remote/overlay/opencode',
PI_CODING_AGENT_DIR: '/remote/overlay/pi',
ORCA_PI_CODING_AGENT_DIR: '/remote/overlay/pi'
ORCA_OMP_STATUS_EXTENSION: '/remote/.omp/agent/extensions/orca-agent-status.ts'
}))
await dispatcher.callRequest('pty.spawn', { env: { HOME: homeDir } })
@ -722,6 +723,11 @@ describe('PtyHandler', () => {
} else {
process.env.HOME = oldHome
}
if (oldOrcaPi === undefined) {
delete process.env.ORCA_PI_CODING_AGENT_DIR
} else {
process.env.ORCA_PI_CODING_AGENT_DIR = oldOrcaPi
}
}
const shellArgs = mockPtySpawn.mock.calls[0][1]
@ -730,13 +736,12 @@ describe('PtyHandler', () => {
expect(shellArgs).toEqual(['--rcfile', rcfile])
expect(spawnOptions.env.ORCA_OPENCODE_CONFIG_DIR).toBe('/remote/overlay/opencode')
expect(spawnOptions.env.ORCA_PI_CODING_AGENT_DIR).toBe('/remote/overlay/pi')
expect(spawnOptions.env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined()
expect(readFileSync(rcfile, 'utf8')).toContain(
'export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"'
)
expect(readFileSync(rcfile, 'utf8')).toContain(
'export PI_CODING_AGENT_DIR="${ORCA_PI_CODING_AGENT_DIR}"'
)
expect(readFileSync(rcfile, 'utf8')).not.toContain('ORCA_PI_CODING_AGENT_DIR')
expect(readFileSync(rcfile, 'utf8')).toContain('command omp --extension')
rmSync(homeDir, { recursive: true, force: true })
}

View File

@ -736,14 +736,11 @@ export class PtyHandler {
const shell = resolveDefaultShell()
// Why: `command` is intentionally absent from this revive path because
// SerializedPtyEntry (see line 99) does not persist it — ManagedPty
// never stored the renderer-chosen launch command. The Pi/OMP overlay
// augmenter in src/relay/relay.ts therefore sees `ctx.command ===
// undefined` for revived PTYs and falls back to the Pi-default kind
// (see detectPiAgentKindFromCommand in src/shared/pi-agent-kind.ts).
// Acceptable pre-OMP fallback: a cold-restart revived OMP shell that
// later relaunches `omp` keeps the historical behavior of loading the
// Pi overlay. Plumbing `command` through serialization is a separate,
// larger change (out of scope for PR #2662).
// never stored the renderer-chosen launch command. The Pi/OMP extension
// installer in src/relay/relay.ts therefore sees `ctx.command ===
// undefined` for revived PTYs and prepares the Pi default plus OMP's
// typed-command wrapper. Plumbing `command` through serialization is a
// separate, larger change.
const spawnEnv = this.buildSpawnEnv(revivedEnv, {
id: entry.id,
paneKey: entry.paneKey,

View File

@ -38,10 +38,7 @@ function windowsShellArgs(shellName: string): string[] | null {
function hasOverlayRestoreEnv(env: Record<string, string>): boolean {
return Boolean(
env.ORCA_OPENCODE_CONFIG_DIR ||
env.ORCA_PI_CODING_AGENT_DIR ||
env.ORCA_OMP_CODING_AGENT_DIR ||
env.ORCA_REMOTE_CLI_BIN_DIR
env.ORCA_OPENCODE_CONFIG_DIR || env.ORCA_REMOTE_CLI_BIN_DIR || env.ORCA_OMP_STATUS_EXTENSION
)
}
@ -101,10 +98,6 @@ ${getZshStartupFileSourceBlock({
if [[ ! -o login ]]; then
# Why: remote startup files can re-export user defaults after relay spawn.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then
export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}"
fi
[[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac
${getPosixOmpShellWrapper()}
fi
@ -120,10 +113,6 @@ ${getZshStartupFileSourceBlock({
})}
# Why: .zlogin is the final zsh login startup file before the prompt.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then
export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}"
fi
[[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac
${getPosixOmpShellWrapper()}
${getZshFinalZdotdirRestoreBlock('"${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"')}
@ -139,10 +128,6 @@ elif [[ -f "$HOME/.profile" ]]; then
fi
# Why: remote startup files can re-export user defaults after relay spawn.
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
[[ -n "\${ORCA_PI_CODING_AGENT_DIR:-}" ]] && export PI_CODING_AGENT_DIR="\${ORCA_PI_CODING_AGENT_DIR}"
if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then
export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}"
fi
[[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac
${getPosixOmpShellWrapper()}
# Why: SSH bash sessions need the same command lifecycle markers as local

View File

@ -510,14 +510,9 @@ async function main(): Promise<void> {
// restart.
ptyHandler.addEnvAugmenter(() => hookServer.buildPtyEnv())
// Why: per-PTY plugin overlays for OpenCode and Pi. `OPENCODE_CONFIG_DIR`
// and `PI_CODING_AGENT_DIR` only make sense on the relay's own filesystem
// — paths the renderer would synthesize for the Orca host's userData are
// meaningless on the remote. The overlay manager materializes a per-PTY
// dir on the remote (rooted at $HOME/.orca-relay/) so the agent CLI inside
// the relay-spawned PTY loads the bundled status plugin and posts to the
// relay's hook server. Source bodies arrive over JSON-RPC (see
// `agent_hook.installPlugins` below) — not bundled with the relay binary.
// Why: plugin install paths must be resolved on the relay host. OpenCode
// still needs a relay-local config overlay, while Pi/OMP receive guarded
// status extensions in their real remote agent dirs.
const pluginOverlay = new PluginOverlayManager()
ptyHandler.addEnvAugmenter((ctx) => {
const env: Record<string, string> = {}
@ -539,9 +534,8 @@ async function main(): Promise<void> {
}
if (pluginOverlay.hasPiSource()) {
// Why: source-dir defaulting is keyed on which Pi-compatible agent is
// being launched (Pi vs OMP). The renderer-supplied `command` is the
// only signal - disk-presence guessing silently shadows the other
// agent's extensions when both `~/.pi/agent` and `~/.omp/agent` exist.
// being launched (Pi vs OMP). Install Orca's guarded extension into that
// real remote agent dir without redirecting PI_CODING_AGENT_DIR.
const kind = detectPiAgentKindFromCommand(ctx.command)
const hasLaunchCommand = typeof ctx.command === 'string' && ctx.command.trim().length > 0
const shouldPrepareOmpShadow = kind === 'omp' || !hasLaunchCommand
@ -549,33 +543,20 @@ async function main(): Promise<void> {
const sourceDir = resolvePiSourceAgentDir(ctx.env, ctx.shell, 'pi')
const dir = pluginOverlay.materializePi(overlayId, sourceDir, 'pi')
if (dir) {
env.PI_CODING_AGENT_DIR = dir
// Why: shadow var is agent-scoped so remote shell-ready wrappers can
// restore Pi by default while the `omp` wrapper switches on demand.
env.ORCA_PI_CODING_AGENT_DIR = dir
if (sourceDir) {
env.ORCA_PI_SOURCE_AGENT_DIR = sourceDir
}
env.ORCA_PI_SOURCE_AGENT_DIR = dir
}
}
if (shouldPrepareOmpShadow) {
// Why: in a bare shell, PI_CODING_AGENT_DIR is historically Pi's
// default. Do not mirror it into OMP; use OMP's own default unless an
// OMP-scoped source shadow is already present from a nested Orca shell.
// Why: in a bare shell, prepare OMP's status extension so a typed
// `omp` gets integration, but do not make OMP the shell's home.
const sourceDir =
kind === 'omp'
? resolvePiSourceAgentDir(ctx.env, ctx.shell, 'omp')
: ctx.env.ORCA_OMP_SOURCE_AGENT_DIR
const dir = pluginOverlay.materializePi(overlayId, sourceDir, 'omp')
if (dir) {
if (kind === 'omp') {
env.PI_CODING_AGENT_DIR = dir
}
env.ORCA_OMP_CODING_AGENT_DIR = dir
env.ORCA_OMP_STATUS_EXTENSION = getRelayPiStatusExtensionPath(dir)
if (sourceDir) {
env.ORCA_OMP_SOURCE_AGENT_DIR = sourceDir
}
env.ORCA_OMP_SOURCE_AGENT_DIR = dir
}
}
}
@ -608,8 +589,8 @@ async function main(): Promise<void> {
// the wire at session-ready (the renderer's bundled hook-service strings
// change as new agent events are added — pinning them to the relay binary
// would force a relay redeploy on every Orca update). Cache them so each
// subsequent PTY spawn can materialize a per-PTY overlay rooted under
// $HOME/.orca-relay/. See docs/design/agent-status-over-ssh.md §4.
// subsequent PTY spawn can materialize the remote OpenCode overlay and
// install Pi/OMP managed extensions. See docs/design/agent-status-over-ssh.md §4.
// Why: bound the per-source size so a buggy/hostile Orca can't OOM the
// relay by pushing a giant string. The HTTP path has HOOK_REQUEST_MAX_BYTES
// = 1 MB; the JSON-RPC path needs an equivalent ceiling. Real plugin sources

View File

@ -4,8 +4,9 @@ import { TUI_AGENT_CONFIG } from './tui-agent-config'
* Pi-compatible agent kinds. Both Pi and OMP (omp.sh) consume the same
* `PI_CODING_AGENT_DIR` env contract and the same extension API, but each
* defaults its on-disk config dir to a different `~/.<kind>/agent` path.
* The Orca overlay needs to know which agent is being launched so it mirrors
* the user's actual source dir for THAT agent, with no cross-agent fallback
* Orca's managed extension installer needs to know which agent is being
* launched so it targets the user's actual source dir for THAT agent, with no
* cross-agent fallback
* (otherwise switching agents in the same workspace silently shadows the
* other agent's user extensions).
*/
@ -41,12 +42,12 @@ const OMP_REGEX = makeLaunchCmdRegex(OMP_LAUNCH_CMD)
*
* Returns 'omp' when the command launches OMP (`omp` / `omp.sh`), otherwise
* defaults to 'pi'. Defaulting to 'pi' preserves prior behavior for the
* non-launch case (e.g. bare shells that may later invoke `pi`) where the
* `~/.pi/agent` overlay was always materialized.
* non-launch case (e.g. bare shells that may later invoke `pi`) where Orca
* prepared Pi integration by default.
*
* NEVER cross-fall-back: a missing source dir for the resolved kind is the
* overlay's "no source, just Orca extensions" branch - the other agent's
* dir MUST NOT be substituted.
* NEVER cross-fall-back: a missing source dir for the resolved kind means
* "create that kind's extension dir only" - the other agent's dir MUST NOT
* be substituted.
*/
export function detectPiAgentKindFromCommand(command: string | undefined): PiAgentKind {
if (typeof command === 'string' && OMP_REGEX.test(command)) {

View File

@ -248,12 +248,11 @@ describe('tui agent startup plans', () => {
})
it('returns an OMP draft plan with ORCA_OMP_PREFILL (OMP-scoped, not Pi-shared)', () => {
// Why: OMP owns its own overlay tree, bundled prefill extension, and
// prefill env var. The OMP overlay's orca-prefill.ts reads
// ORCA_OMP_PREFILL — see src/main/pi/titlebar-extension-service.ts —
// so a draft plan for OMP MUST emit that name. A regression here would
// either silently drop the draft (Pi var ignored by OMP overlay) or
// honor a stale Pi-PTY draft from a previous launch.
// Why: OMP owns its own managed prefill extension and env var.
// orca-prefill.ts reads ORCA_OMP_PREFILL for OMP launches — see
// src/main/pi/titlebar-extension-service.ts — so a draft plan for OMP
// MUST emit that name. A regression here would either silently drop the
// draft (Pi var ignored by OMP) or honor a stale Pi-PTY draft.
const plan = buildAgentDraftLaunchPlan({
agent: 'omp',
draft: 'fix the omp regression',

View File

@ -307,11 +307,11 @@ test.describe('Localhost SSH', () => {
ptyId,
[
'opencode_status_file="$OPENCODE_CONFIG_DIR/plugins/orca-opencode-status.js"',
'pi_status_file="$PI_CODING_AGENT_DIR/extensions/orca-agent-status.ts"',
'if [ -n "$OPENCODE_CONFIG_DIR" ] && [ -f "$opencode_status_file" ] && [ -n "$PI_CODING_AGENT_DIR" ] && [ -f "$pi_status_file" ]; then',
'pi_status_file="$HOME/.pi/agent/extensions/orca-agent-status.ts"',
'if [ -n "$OPENCODE_CONFIG_DIR" ] && [ -f "$opencode_status_file" ] && [ -f "$pi_status_file" ]; then',
` ${emitMarkerCommand(pluginOverlayMarker)}`,
'else',
` printf '%s opencode=%s opencode_file=%s pi=%s pi_file=%s\\n' ${shellQuote(pluginOverlayFailedMarker)} "$OPENCODE_CONFIG_DIR" "$opencode_status_file" "$PI_CODING_AGENT_DIR" "$pi_status_file"`,
` printf '%s opencode=%s opencode_file=%s pi_file=%s\\n' ${shellQuote(pluginOverlayFailedMarker)} "$OPENCODE_CONFIG_DIR" "$opencode_status_file" "$pi_status_file"`,
'fi'
].join('\n')
)