From 84a5def2483016ec6cd7a0151637eddd4bcf9720 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:28:54 -0700 Subject: [PATCH] Use managed Pi and OMP extensions (#5681) Co-authored-by: Orca --- src/main/daemon/pty-session-id.ts | 23 +- src/main/daemon/pty-subprocess.test.ts | 5 +- src/main/daemon/pty-subprocess.ts | 7 +- src/main/daemon/shell-ready.test.ts | 20 +- src/main/daemon/shell-ready.ts | 16 - src/main/ipc/pty.test.ts | 140 +++++---- src/main/ipc/pty.ts | 102 +++---- src/main/pi/agent-status-extension-source.ts | 11 +- src/main/pi/prefill-extension-source.ts | 1 + .../titlebar-extension-overlay-path.test.ts | 23 +- .../pi/titlebar-extension-service.test.ts | 216 +++++--------- src/main/pi/titlebar-extension-service.ts | 282 +++++------------- src/main/pi/titlebar-extension-source.ts | 1 + src/main/powershell-osc133-bootstrap.test.ts | 4 +- src/main/powershell-osc133-bootstrap.ts | 4 - src/main/providers/local-pty-provider.ts | 7 +- .../providers/local-pty-shell-ready.test.ts | 18 +- src/main/providers/local-pty-shell-ready.ts | 16 - src/main/providers/windows-shell-args.test.ts | 21 +- .../pty/omp-shell-wrapper.node-pty.test.ts | 46 ++- src/main/pty/omp-shell-wrapper.ts | 69 +---- .../commit-message-agent-environment.ts | 6 +- src/relay/plugin-overlay.test.ts | 92 +++--- src/relay/plugin-overlay.ts | 148 +++------ src/relay/pty-handler.test.ts | 17 +- src/relay/pty-handler.ts | 13 +- src/relay/pty-shell-launch.ts | 17 +- src/relay/relay.ts | 41 +-- src/shared/pi-agent-kind.ts | 15 +- src/shared/tui-agent-startup.test.ts | 11 +- tests/e2e/ssh-localhost.spec.ts | 6 +- 31 files changed, 493 insertions(+), 905 deletions(-) diff --git a/src/main/daemon/pty-session-id.ts b/src/main/daemon/pty-session-id.ts index 447c5f361..9fecdc319 100644 --- a/src/main/daemon/pty-session-id.ts +++ b/src/main/daemon/pty-session-id.ts @@ -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 diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index be6176276..93522066d 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -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 { diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 8b732978e..291757945 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -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 | 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) diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index d603709ef..161115734 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -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) diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 106be52e8..05f3c25e7 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -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)} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index edc78d0c4..cd35b332c 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -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> { const savedEnv: Record = {} @@ -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, // 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') diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 9036abe2b..21afd28c2 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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 * `~/./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, kind: PiAgentKind): void { if (kind === 'omp') { delete baseEnv.ORCA_OMP_CODING_AGENT_DIR @@ -519,29 +514,28 @@ function clearPiAgentShadowEnv(baseEnv: Record, kind: PiAgentKin delete baseEnv.ORCA_PI_SOURCE_AGENT_DIR } -function exposePiAgentOverlayEnv( +function exposePiManagedExtensionEnv( baseEnv: Record, kind: PiAgentKind, - overlayDir: string, - sourceDir: string | undefined + managedEnv: Record ): 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, 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- diff --git a/src/main/pi/agent-status-extension-source.ts b/src/main/pi/agent-status-extension-source.ts index baf351465..d6ff3d811 100644 --- a/src/main/pi/agent-status-extension-source.ts +++ b/src/main/pi/agent-status-extension-source.ts @@ -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/ 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'", diff --git a/src/main/pi/prefill-extension-source.ts b/src/main/pi/prefill-extension-source.ts index 9c07f549a..054502c1c 100644 --- a/src/main/pi/prefill-extension-source.ts +++ b/src/main/pi/prefill-extension-source.ts @@ -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', diff --git a/src/main/pi/titlebar-extension-overlay-path.test.ts b/src/main/pi/titlebar-extension-overlay-path.test.ts index 31cf6510f..4b7c4c674 100644 --- a/src/main/pi/titlebar-extension-overlay-path.test.ts +++ b/src/main/pi/titlebar-extension-overlay-path.test.ts @@ -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' diff --git a/src/main/pi/titlebar-extension-service.test.ts b/src/main/pi/titlebar-extension-service.test.ts index d9587aa9b..c8c9f6a0f 100644 --- a/src/main/pi/titlebar-extension-service.test.ts +++ b/src/main/pi/titlebar-extension-service.test.ts @@ -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 }) diff --git a/src/main/pi/titlebar-extension-service.ts b/src/main/pi/titlebar-extension-service.ts index 9638d516c..adb936da1 100644 --- a/src/main/pi/titlebar-extension-service.ts +++ b/src/main/pi/titlebar-extension-service.ts @@ -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 = { 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` 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 - 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 { 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/ 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 = {} + 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) diff --git a/src/main/pi/titlebar-extension-source.ts b/src/main/pi/titlebar-extension-source.ts index 184e412cd..6070d35a5 100644 --- a/src/main/pi/titlebar-extension-source.ts +++ b/src/main/pi/titlebar-extension-source.ts @@ -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', '', diff --git a/src/main/powershell-osc133-bootstrap.test.ts b/src/main/powershell-osc133-bootstrap.test.ts index 4801418bc..80f9fd088 100644 --- a/src/main/powershell-osc133-bootstrap.test.ts +++ b/src/main/powershell-osc133-bootstrap.test.ts @@ -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') diff --git a/src/main/powershell-osc133-bootstrap.ts b/src/main/powershell-osc133-bootstrap.ts index ff84103bc..cb4e3177a 100644 --- a/src/main/powershell-osc133-bootstrap.ts +++ b/src/main/powershell-osc133-bootstrap.ts @@ -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 } diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index ca2ab29ff..966bd2efd 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -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 = diff --git a/src/main/providers/local-pty-shell-ready.test.ts b/src/main/providers/local-pty-shell-ready.test.ts index 40cd2714f..d029dba6b 100644 --- a/src/main/providers/local-pty-shell-ready.test.ts +++ b/src/main/providers/local-pty-shell-ready.test.ts @@ -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) diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 676a6745b..7872fcd84 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -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)} diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 529118b9b..46d825dc7 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -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') diff --git a/src/main/pty/omp-shell-wrapper.node-pty.test.ts b/src/main/pty/omp-shell-wrapper.node-pty.test.ts index 14b85fed0..db5d3cf28 100644 --- a/src/main/pty/omp-shell-wrapper.node-pty.test.ts +++ b/src/main/pty/omp-shell-wrapper.node-pty.test.ts @@ -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() } ) }) diff --git a/src/main/pty/omp-shell-wrapper.ts b/src/main/pty/omp-shell-wrapper.ts index 67b660e8e..67f4ea616 100644 --- a/src/main/pty/omp-shell-wrapper.ts +++ b/src/main/pty/omp-shell-wrapper.ts @@ -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 } } diff --git a/src/main/text-generation/commit-message-agent-environment.ts b/src/main/text-generation/commit-message-agent-environment.ts index 09be28ce3..30516945f 100644 --- a/src/main/text-generation/commit-message-agent-environment.ts +++ b/src/main/text-generation/commit-message-agent-environment.ts @@ -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' diff --git a/src/relay/plugin-overlay.test.ts b/src/relay/plugin-overlay.test.ts index 7f82c4776..3168a1ce6 100644 --- a/src/relay/plugin-overlay.test.ts +++ b/src/relay/plugin-overlay.test.ts @@ -70,12 +70,24 @@ describe('PluginOverlayManager', () => { expect(manager.materializeOpenCode('tab-missing:0', join(homeDir, 'missing'))).toBeNull() }) - it('materializes Pi extension into /extensions/', () => { + 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 `~/./agent` source — + // Why: per-agent source dir. The renderer picks Pi or OMP per + // launch, and the relay must use the right `~/./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')( diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index c7cf985f2..9131a9d05 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -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 = { 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 `~/./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 } diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index f643f0997..e063094a3 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -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 }) } diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 8b687f09f..9f8eb7895 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -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, diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts index f2603d0be..fb618283c 100644 --- a/src/relay/pty-shell-launch.ts +++ b/src/relay/pty-shell-launch.ts @@ -38,10 +38,7 @@ function windowsShellArgs(shellName: string): string[] | null { function hasOverlayRestoreEnv(env: Record): 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 diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 9a0a96a48..356d77812 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -510,14 +510,9 @@ async function main(): Promise { // 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 = {} @@ -539,9 +534,8 @@ async function main(): Promise { } 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 { 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 { // 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 diff --git a/src/shared/pi-agent-kind.ts b/src/shared/pi-agent-kind.ts index 8efb4346a..4071ace27 100644 --- a/src/shared/pi-agent-kind.ts +++ b/src/shared/pi-agent-kind.ts @@ -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 `~/./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)) { diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index 15bbf5928..9ce0cd839 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -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', diff --git a/tests/e2e/ssh-localhost.spec.ts b/tests/e2e/ssh-localhost.spec.ts index 7d0e997e1..d67b6aa6d 100644 --- a/tests/e2e/ssh-localhost.spec.ts +++ b/tests/e2e/ssh-localhost.spec.ts @@ -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') )