From f80704cc6023a7e0cd522f66c1ee2bc3dc7d7474 Mon Sep 17 00:00:00 2001 From: slashdevcorpse <73707049+slashdevcorpse@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:21:48 -0400 Subject: [PATCH] [perf] Speed up CLI launch through Orca Speed up Windows CLI launch by delivering short startup commands through shell args, falling back to stdin for oversized commands, and moving Codex historical session bridging into an incremental background task. Includes review fixes for Windows symlink and WSL test reliability. --- config/vitest.config.ts | 11 +- src/main/claude/hook-service.test.ts | 2 +- .../runtime-home-service.test.ts | 5 +- .../codex-accounts/runtime-home-service.ts | 12 +- src/main/codex/codex-session-bridge.test.ts | 94 +++++++++- src/main/codex/codex-session-bridge.ts | 170 ++++++++++++++---- src/main/codex/codex-session-file-listing.ts | 99 ++++++++++ src/main/codex/hook-service.test.ts | 2 +- src/main/daemon/pty-subprocess.test.ts | 59 ++++++ src/main/daemon/pty-subprocess.ts | 68 ++++++- src/main/daemon/session.ts | 3 + src/main/daemon/terminal-host.test.ts | 32 +++- src/main/daemon/terminal-host.ts | 8 +- src/main/devin/hook-service.test.ts | 15 +- .../ipc/pty-startup-barrier-ordering.test.ts | 5 +- src/main/ipc/pty.test.ts | 6 +- src/main/providers/local-pty-provider.ts | 54 +++++- src/main/providers/windows-shell-args.test.ts | 80 ++++++++- src/main/providers/windows-shell-args.ts | 80 ++++++++- src/relay/git-handler-branch-cleanup.test.ts | 3 +- .../git-handler-pull-reconciliation.test.ts | 4 +- src/relay/git-handler-worktree-ops.test.ts | 3 +- src/relay/git-handler-worktree-paths.test.ts | 3 +- ...rkspaceNameField-source-boundaries.test.ts | 5 +- .../WorktreeCard.compact-hover.test.tsx | 8 +- .../WorktreeCardAgents.send-target.test.tsx | 2 +- .../sidebar/WorktreeCardAgents.test.tsx | 2 +- ...ktreeList.lineage-child-real-card.test.tsx | 2 +- .../components/tab-bar/BrowserTab.test.tsx | 2 +- .../TabBar.windows-shell-launch.test.ts | 2 +- .../terminal-pane/pty-connection.test.ts | 2 +- src/shared/wsl-login-shell-command.test.ts | 51 ++++-- 32 files changed, 792 insertions(+), 102 deletions(-) create mode 100644 src/main/codex/codex-session-file-listing.ts diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 5ef1490bf..b3846c92c 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -1,6 +1,8 @@ import { resolve } from 'path' import { defineConfig } from 'vitest/config' +const windowsTestWorkerOptions = process.platform === 'win32' ? { maxWorkers: 4 } : {} + export default defineConfig({ define: { ORCA_FEATURE_WALL_ENABLED: 'true' @@ -13,6 +15,13 @@ export default defineConfig({ }, test: { environment: 'node', - include: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'config/scripts/**/*.test.mjs'] + include: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'config/scripts/**/*.test.mjs'], + // Why: the full suite runs heavy TS transforms plus real git/http fixtures; + // the Vitest 5s defaults are too tight for the slowest integration cases. + hookTimeout: 60_000, + testTimeout: 30_000, + // Why: Windows process and shell startup are slower under full-suite load; + // macOS/Linux keep Vitest's default worker parallelism. + ...windowsTestWorkerOptions } }) diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index e0ad45b63..3b280183b 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -293,7 +293,7 @@ describe('OpenClaudeHookService-compatible install', () => { readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8') ).toContain('/hook/claude') expect( - readFileSync(join(tmpHome, '.orca', 'agent-hooks', 'openclaude-hook.sh'), 'utf-8') + readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8') ).not.toContain('DEVIN_PROJECT_DIR') expect(existsSync(join(tmpHome, '.claude', 'settings.json'))).toBe(false) } finally { diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 92b6f8987..dd29c2ebc 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -1095,7 +1095,7 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeProfilePath, 'utf-8')).toBe('profile\n') }) - it('bridges system Codex sessions before launch without replacing runtime sessions', async () => { + it('starts the system Codex session bridge without replacing runtime sessions', async () => { const systemMissingRuntimeSessionPath = join( getSystemCodexHomePath(), 'sessions', @@ -1130,9 +1130,12 @@ describe('CodexRuntimeHomeService', () => { writeFileSync(join(getSystemCodexHomePath(), 'state_5.sqlite'), 'sqlite\n', 'utf-8') const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const { startSystemCodexSessionBridgeInBackground } = + await import('../codex/codex-session-bridge') const service = new CodexRuntimeHomeService(store as never) service.prepareForCodexLaunch() + await startSystemCodexSessionBridgeInBackground() const runtimeMissingSessionPath = join( getRuntimeCodexHomePath(), diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 7024b8fa9..12d119a85 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -39,7 +39,7 @@ import { getSystemCodexHomePath, syncSystemCodexResourcesIntoManagedHome } from '../codex/codex-home-paths' -import { syncSystemCodexSessionsIntoManagedHome } from '../codex/codex-session-bridge' +import { startSystemCodexSessionBridgeInBackground } from '../codex/codex-session-bridge' import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' import { parseWslUncPath } from '../../shared/wsl-paths' import { @@ -122,6 +122,12 @@ export class CodexRuntimeHomeService { : normalizeCodexRuntimeSelection(settings).host } + /** + * Materializes the runtime home needed before launching the CLI. + * + * Historical session bridging is requested in the background so launch setup + * returns as soon as the active runtime home is ready. + */ prepareForCodexLaunch(target?: CodexAccountSelectionTarget): string | null { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) @@ -133,7 +139,9 @@ export class CodexRuntimeHomeService { this.syncForCurrentSelection() syncSystemCodexResourcesIntoManagedHome() syncSystemConfigIntoManagedCodexHome() - syncSystemCodexSessionsIntoManagedHome() + // Why: historical Codex sessions can be large; bridge them after launch + // setup so starting a fresh Codex TUI never waits on a full tree walk. + void startSystemCodexSessionBridgeInBackground() return this.getRuntimeHomePath() } diff --git a/src/main/codex/codex-session-bridge.test.ts b/src/main/codex/codex-session-bridge.test.ts index 3ee5ebec2..ae4e17be6 100644 --- a/src/main/codex/codex-session-bridge.test.ts +++ b/src/main/codex/codex-session-bridge.test.ts @@ -20,9 +20,21 @@ const { homedirMock } = vi.hoisted(() => ({ })) const { fsMockState } = vi.hoisted(() => ({ - fsMockState: { failLink: false, failSymlink: false } + fsMockState: { + failLink: false, + failSymlink: false, + fakeSymlinks: new Map() + } })) +function isWindowsSymlinkPrivilegeError(error: unknown): boolean { + if (process.platform !== 'win32' || !(error instanceof Error)) { + return false + } + const errorWithCode = error as Error & { code?: string } + return errorWithCode.code === 'EPERM' || errorWithCode.code === 'EACCES' +} + vi.mock('node:fs', async () => { const actual = await vi.importActual('node:fs') return { @@ -33,11 +45,52 @@ vi.mock('node:fs', async () => { } return actual.linkSync(...args) }, + lstatSync: ((path: Parameters[0]) => { + const stat = actual.lstatSync(path) + if (!fsMockState.fakeSymlinks.has(String(path))) { + return stat + } + // Why: Windows often disallows file symlink creation outside Developer + // Mode; tests simulate the link metadata while keeping a real path. + return { ...stat, isSymbolicLink: () => true } + }) as typeof actual.lstatSync, + readlinkSync: ((path: Parameters[0]) => { + const fakeTarget = fsMockState.fakeSymlinks.get(String(path)) + if (fakeTarget !== undefined) { + return fakeTarget + } + return actual.readlinkSync(path) + }) as typeof actual.readlinkSync, + renameSync: (...args: Parameters) => { + const [oldPath, newPath] = args + const fakeTarget = fsMockState.fakeSymlinks.get(String(oldPath)) + const result = actual.renameSync(...args) + if (fakeTarget !== undefined) { + fsMockState.fakeSymlinks.delete(String(oldPath)) + fsMockState.fakeSymlinks.set(String(newPath), fakeTarget) + } else { + fsMockState.fakeSymlinks.delete(String(newPath)) + } + return result + }, + rmSync: (...args: Parameters) => { + fsMockState.fakeSymlinks.delete(String(args[0])) + return actual.rmSync(...args) + }, symlinkSync: (...args: Parameters) => { if (fsMockState.failSymlink) { throw new Error('symlink disabled for test') } - return actual.symlinkSync(...args) + try { + return actual.symlinkSync(...args) + } catch (error) { + if (!isWindowsSymlinkPrivilegeError(error)) { + throw error + } + const [target, path] = args + fsMockState.fakeSymlinks.set(String(path), String(target)) + actual.writeFileSync(path, '', 'utf-8') + } } } }) @@ -50,7 +103,10 @@ vi.mock('node:os', async () => { } }) -import { syncSystemCodexSessionsIntoManagedHome } from './codex-session-bridge' +import { + syncSystemCodexSessionsIntoManagedHome, + syncSystemCodexSessionsIntoManagedHomeIncrementally +} from './codex-session-bridge' let fakeHomeDir: string let userDataDir: string @@ -103,6 +159,7 @@ function writeLegacyCopyMarker(relativePath: string, sourcePath: string, targetP beforeEach(() => { fsMockState.failLink = false fsMockState.failSymlink = false + fsMockState.fakeSymlinks.clear() fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-session-home-')) userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-session-user-data-')) previousUserDataPath = process.env.ORCA_USER_DATA_PATH @@ -276,4 +333,35 @@ describe('syncSystemCodexSessionsIntoManagedHome', () => { expect(lstatSync(runtimeSessionPath).isSymbolicLink()).toBe(false) expect(readFileSync(runtimeSessionPath, 'utf-8')).toBe('{"id":"legacy"}\n') }) + + it('incrementally bridges session files without requiring the synchronous launch path', async () => { + const systemSessionRoot = join(getSystemCodexHomePath(), 'sessions', '2026', '06', '18') + mkdirSync(systemSessionRoot, { recursive: true }) + for (let index = 0; index < 5; index += 1) { + writeFileSync( + join(systemSessionRoot, `rollout-incremental-${index}.jsonl`), + `{"id":"incremental-${index}"}\n`, + 'utf-8' + ) + } + + const summary = await syncSystemCodexSessionsIntoManagedHomeIncrementally({ + batchSize: 2, + yieldMs: 0 + }) + + expect(summary).toEqual({ scannedFiles: 5, linkedFiles: 5 }) + for (let index = 0; index < 5; index += 1) { + const systemSessionPath = join(systemSessionRoot, `rollout-incremental-${index}.jsonl`) + const runtimeSessionPath = join( + getRuntimeCodexHomePath(), + 'sessions', + '2026', + '06', + '18', + `rollout-incremental-${index}.jsonl` + ) + expectResourceLinked(runtimeSessionPath, systemSessionPath) + } + }) }) diff --git a/src/main/codex/codex-session-bridge.ts b/src/main/codex/codex-session-bridge.ts index 7a2815f02..a57422895 100644 --- a/src/main/codex/codex-session-bridge.ts +++ b/src/main/codex/codex-session-bridge.ts @@ -3,7 +3,6 @@ import { linkSync, lstatSync, mkdirSync, - readdirSync, readFileSync, readlinkSync, renameSync, @@ -12,6 +11,13 @@ import { } from 'node:fs' import { dirname, isAbsolute, join, relative, sep } from 'node:path' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' +import { + listCodexSessionJsonlFiles, + listCodexSessionJsonlFilesIncrementally +} from './codex-session-file-listing' +import type { CodexSessionBridgeIncrementalOptions } from './codex-session-file-listing' + +export type { CodexSessionBridgeIncrementalOptions } from './codex-session-file-listing' type LegacyCopiedSessionMarker = { sourcePath: string @@ -27,6 +33,16 @@ export type LegacyCopiedCodexSessionBridgeScanPreference = { sourceSkipBytes: number | null } +export type CodexSessionBridgeSummary = { + scannedFiles: number + linkedFiles: number +} + +let backgroundSessionBridgeTask: Promise | null = null + +/** + * Synchronously mirrors system session files into the managed runtime home. + */ export function syncSystemCodexSessionsIntoManagedHome(): void { const systemSessionsRoot = join(getSystemCodexHomePath(), 'sessions') if (!existsSync(systemSessionsRoot)) { @@ -35,53 +51,99 @@ export function syncSystemCodexSessionsIntoManagedHome(): void { const managedSessionsRoot = join(getOrcaManagedCodexHomePath(), 'sessions') for (const systemSessionFilePath of listCodexSessionJsonlFiles(systemSessionsRoot)) { - const relativePath = relative(systemSessionsRoot, systemSessionFilePath) - const managedSessionFilePath = join(managedSessionsRoot, relativePath) - if (existsSync(managedSessionFilePath)) { - if ( - replaceSymlinkSessionBridgeWithHardlink( - systemSessionFilePath, - managedSessionFilePath, - relativePath - ) - ) { - continue - } - migrateLegacyCopiedSessionBridge(systemSessionFilePath, managedSessionFilePath, relativePath) - continue + bridgeSystemCodexSessionFile(systemSessionsRoot, managedSessionsRoot, systemSessionFilePath) + } +} + +/** + * Starts a single background bridge task for historical system sessions. + * + * Concurrent callers share the same in-flight task so launch code can request + * background bridging without starting duplicate directory walks. + */ +export function startSystemCodexSessionBridgeInBackground( + options: CodexSessionBridgeIncrementalOptions = {} +): Promise { + if (backgroundSessionBridgeTask) { + return backgroundSessionBridgeTask + } + const task = syncSystemCodexSessionsIntoManagedHomeIncrementally(options) + .catch((error: unknown) => { + console.warn('[codex-session-bridge] Background session bridge failed:', error) + }) + .then(() => undefined) + backgroundSessionBridgeTask = task + void task.finally(() => { + if (backgroundSessionBridgeTask === task) { + backgroundSessionBridgeTask = null } - mkdirSync(dirname(managedSessionFilePath), { recursive: true }) - linkSystemCodexSessionFile(systemSessionFilePath, managedSessionFilePath, relativePath) - } + }) + return task } -function listCodexSessionJsonlFiles(rootPath: string): string[] { - const files: string[] = [] - try { - for (const entry of readdirSync(rootPath, { withFileTypes: true })) { - const childPath = join(rootPath, entry.name) - if (entry.isDirectory()) { - appendSessionFilePaths(files, listCodexSessionJsonlFiles(childPath)) - continue - } - if (entry.isFile() && entry.name.endsWith('.jsonl')) { - files.push(childPath) - } +/** + * Incrementally mirrors system session files into the managed runtime home. + * + * Returns scan/link counts for tests and diagnostics while keeping each file + * bridge operation equivalent to the synchronous path. + */ +export async function syncSystemCodexSessionsIntoManagedHomeIncrementally( + options: CodexSessionBridgeIncrementalOptions = {} +): Promise { + const systemSessionsRoot = join(getSystemCodexHomePath(), 'sessions') + if (!existsSync(systemSessionsRoot)) { + return { scannedFiles: 0, linkedFiles: 0 } + } + + const managedSessionsRoot = join(getOrcaManagedCodexHomePath(), 'sessions') + const summary: CodexSessionBridgeSummary = { scannedFiles: 0, linkedFiles: 0 } + for await (const systemSessionFilePath of listCodexSessionJsonlFilesIncrementally( + systemSessionsRoot, + options + )) { + summary.scannedFiles += 1 + if ( + bridgeSystemCodexSessionFile(systemSessionsRoot, managedSessionsRoot, systemSessionFilePath) + ) { + summary.linkedFiles += 1 } - } catch (error) { - console.warn('[codex-session-bridge] Failed to list system Codex sessions:', error) } - return files.sort() + return summary } -function appendSessionFilePaths(target: string[], source: readonly string[]): void { - // Why: existing Codex homes can accumulate enough nested sessions to exceed - // V8's argument limit if child arrays are spread into push(). - for (const filePath of source) { - target.push(filePath) +/** + * Bridges one system session file into the managed sessions tree. + * + * Existing managed files are migrated when possible; missing files are linked + * and counted as newly available to the managed runtime home. + */ +function bridgeSystemCodexSessionFile( + systemSessionsRoot: string, + managedSessionsRoot: string, + systemSessionFilePath: string +): boolean { + const relativePath = relative(systemSessionsRoot, systemSessionFilePath) + const managedSessionFilePath = join(managedSessionsRoot, relativePath) + if (existsSync(managedSessionFilePath)) { + if ( + replaceSymlinkSessionBridgeWithHardlink( + systemSessionFilePath, + managedSessionFilePath, + relativePath + ) + ) { + return true + } + migrateLegacyCopiedSessionBridge(systemSessionFilePath, managedSessionFilePath, relativePath) + return false } + mkdirSync(dirname(managedSessionFilePath), { recursive: true }) + return linkSystemCodexSessionFile(systemSessionFilePath, managedSessionFilePath, relativePath) } +/** + * Links a source session file and clears any stale copied-session marker. + */ function linkSystemCodexSessionFile( sourcePath: string, targetPath: string, @@ -94,6 +156,9 @@ function linkSystemCodexSessionFile( return linked } +/** + * Attempts to link a session file with hardlink first and symlink fallback. + */ function tryLinkSystemCodexSessionFile(sourcePath: string, targetPath: string): boolean { if (tryHardlinkSystemCodexSessionFile(sourcePath, targetPath)) { return true @@ -109,6 +174,9 @@ function tryLinkSystemCodexSessionFile(sourcePath: string, targetPath: string): return false } +/** + * Attempts a hardlink so resume sees one physical JSONL session log. + */ function tryHardlinkSystemCodexSessionFile(sourcePath: string, targetPath: string): boolean { try { // Why: Codex resume ignores symlinked JSONL sessions, while a hardlink @@ -120,6 +188,10 @@ function tryHardlinkSystemCodexSessionFile(sourcePath: string, targetPath: strin } } +/** + * Replaces an older symlink bridge with a hardlink when the target still points + * at the expected source session. + */ function replaceSymlinkSessionBridgeWithHardlink( sourcePath: string, targetPath: string, @@ -160,6 +232,10 @@ function replaceSymlinkSessionBridgeWithHardlink( return false } +/** + * Migrates a legacy copied bridge to a linked bridge when the copied file still + * matches its marker. + */ function migrateLegacyCopiedSessionBridge( sourcePath: string, targetPath: string, @@ -198,6 +274,12 @@ function migrateLegacyCopiedSessionBridge( } } +/** + * Resolves how scanners should treat a legacy copied session bridge. + * + * The result keeps resume scans coherent until the copied bridge is migrated to + * a hardlink or symlink. + */ export function getLegacyCopiedCodexSessionBridgeScanPreference( sessionFilePath: string ): LegacyCopiedCodexSessionBridgeScanPreference | null { @@ -234,10 +316,16 @@ export function getLegacyCopiedCodexSessionBridgeScanPreference( } } +/** + * Returns the marker path for a legacy copied session bridge. + */ function getLegacySessionCopyMarkerPath(relativePath: string): string { return join(getOrcaManagedCodexHomePath(), '.orca-session-copies', `${relativePath}.json`) } +/** + * Reads and validates the marker for a legacy copied session bridge. + */ function readLegacyCopiedSessionMarker(relativePath: string): LegacyCopiedSessionMarker | null { try { const parsed: unknown = JSON.parse( @@ -262,6 +350,9 @@ function readLegacyCopiedSessionMarker(relativePath: string): LegacyCopiedSessio } } +/** + * Checks whether source or target file stats still match a legacy bridge marker. + */ function fileStatsMatchMarker( stat: { size: number; mtimeMs: number }, marker: LegacyCopiedSessionMarker, @@ -272,6 +363,9 @@ function fileStatsMatchMarker( return stat.size === expectedSize && stat.mtimeMs === expectedMtimeMs } +/** + * Removes the marker after a copied session bridge has been migrated or retired. + */ function clearLegacyCopiedSessionMarker(relativePath: string): void { rmSync(getLegacySessionCopyMarkerPath(relativePath), { force: true }) } diff --git a/src/main/codex/codex-session-file-listing.ts b/src/main/codex/codex-session-file-listing.ts new file mode 100644 index 000000000..c6eeb9b1f --- /dev/null +++ b/src/main/codex/codex-session-file-listing.ts @@ -0,0 +1,99 @@ +import { readdirSync } from 'node:fs' +import { opendir } from 'node:fs/promises' +import { join } from 'node:path' + +export type CodexSessionBridgeIncrementalOptions = { + /** Directory entries to process before yielding back to the event loop. */ + batchSize?: number + /** Delay after each processed batch; zero still yields on a timer turn. */ + yieldMs?: number +} + +const INCREMENTAL_BRIDGE_BATCH_SIZE = 64 +const INCREMENTAL_BRIDGE_YIELD_MS = 10 + +/** + * Recursively lists session JSONL files below a root directory. + * + * This synchronous variant preserves the historical bridge behavior for callers + * that run outside the CLI launch path. + */ +export function listCodexSessionJsonlFiles(rootPath: string): string[] { + const files: string[] = [] + try { + for (const entry of readdirSync(rootPath, { withFileTypes: true })) { + const childPath = join(rootPath, entry.name) + if (entry.isDirectory()) { + appendSessionFilePaths(files, listCodexSessionJsonlFiles(childPath)) + continue + } + if (entry.isFile() && entry.name.endsWith('.jsonl')) { + files.push(childPath) + } + } + } catch (error) { + console.warn('[codex-session-bridge] Failed to list system Codex sessions:', error) + } + return files.sort() +} + +/** + * Appends session paths without spreading large arrays into a single call. + */ +function appendSessionFilePaths(target: string[], source: readonly string[]): void { + // Why: existing Codex homes can accumulate enough nested sessions to exceed + // V8's argument limit if child arrays are spread into push(). + for (const filePath of source) { + target.push(filePath) + } +} + +/** + * Yields session JSONL files incrementally while walking a directory tree. + * + * The generator yields control between batches so large history directories do + * not monopolize startup work. + */ +export async function* listCodexSessionJsonlFilesIncrementally( + rootPath: string, + options: CodexSessionBridgeIncrementalOptions +): AsyncGenerator { + const batchSize = Math.max(1, options.batchSize ?? INCREMENTAL_BRIDGE_BATCH_SIZE) + const yieldMs = Math.max(0, options.yieldMs ?? INCREMENTAL_BRIDGE_YIELD_MS) + const pendingDirectories = [rootPath] + let entriesSinceYield = 0 + + while (pendingDirectories.length > 0) { + const currentDirectory = pendingDirectories.pop() + if (!currentDirectory) { + continue + } + try { + const directory = await opendir(currentDirectory) + for await (const entry of directory) { + const childPath = join(currentDirectory, entry.name) + if (entry.isDirectory()) { + pendingDirectories.push(childPath) + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + yield childPath + } + entriesSinceYield += 1 + if (entriesSinceYield >= batchSize) { + entriesSinceYield = 0 + await delayIncrementalBridge(yieldMs) + } + } + } catch (error) { + console.warn('[codex-session-bridge] Failed to list system Codex sessions:', error) + } + } +} + +/** + * Defers incremental bridge work to a later timer turn. + */ +function delayIncrementalBridge(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} diff --git a/src/main/codex/hook-service.test.ts b/src/main/codex/hook-service.test.ts index d42d76060..dc0dc060a 100644 --- a/src/main/codex/hook-service.test.ts +++ b/src/main/codex/hook-service.test.ts @@ -758,7 +758,7 @@ describe('CodexHookService', () => { hooks: Record } expect(systemHooks.hooks.Stop).toBeUndefined() - }, 15_000) + }, 30_000) it('removes the legacy Orca Codex profile file when it only contains managed hooks', () => { const systemCodexHome = join(tmpHome, '.codex') diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index ada8a4f32..cadff9b5e 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -1269,6 +1269,65 @@ describe('createPtySubprocess', () => { ) }) + it('embeds short PowerShell startup commands in the Windows shell launch', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + + Object.defineProperty(process, 'platform', { value: 'win32' }) + + let handle: ReturnType + try { + handle = createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + shellOverride: 'powershell.exe', + command: "& 'codex' '--no-alt-screen'" + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + const lastCall = spawnMock.mock.calls.at(-1)! + const encoded = String(lastCall[1][3]) + const command = Buffer.from(encoded, 'base64').toString('utf16le') + expect(command.trimEnd().endsWith("& 'codex' '--no-alt-screen'")).toBe(true) + expect(handle!.startupCommandDeliveredInShellArgs).toBe(true) + }) + + it('keeps oversized Windows startup commands on PTY stdin delivery', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + + Object.defineProperty(process, 'platform', { value: 'win32' }) + + let handle: ReturnType + try { + handle = createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + shellOverride: 'cmd.exe', + command: `codex ${'x'.repeat(7000)}` + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + expect(spawnMock).toHaveBeenCalledWith( + 'cmd.exe', + ['/K', 'chcp 65001 > nul'], + expect.any(Object) + ) + expect(handle!.startupCommandDeliveredInShellArgs).toBeUndefined() + }) + it('launches Git Bash with login args and CHERE_INVOKING on Windows', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 60f046a8e..769797ee9 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -64,6 +64,9 @@ export type PtySubprocessOptions = { terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' } +/** + * Returns a stable default working directory for daemon-spawned PTYs. + */ function getDefaultCwd(): string { if (process.platform !== 'win32') { return process.env.HOME || '/' @@ -81,6 +84,9 @@ function getDefaultCwd(): string { return 'C:\\' } +/** + * Removes pane identity inherited from the daemon parent unless explicitly set. + */ function removeUnspecifiedPaneIdentityEnv( env: Record, explicitEnv: Record | undefined @@ -92,6 +98,9 @@ function removeUnspecifiedPaneIdentityEnv( } } +/** + * Promotes the agent-teams shim path ahead of inherited PATH entries. + */ function promoteAgentTeamsShimPath( env: Record, requestedPath: string | undefined @@ -107,6 +116,9 @@ function promoteAgentTeamsShimPath( env.PATH = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(delimiter) } +/** + * Removes stale development hook endpoints inherited by daemon children. + */ function removeInheritedDevAgentHookEndpoint( env: Record, explicitEnv: Record | undefined @@ -119,6 +131,9 @@ function removeInheritedDevAgentHookEndpoint( } } +/** + * Resolves a WSL launch context from a user-selected distro name. + */ function getWslContextFromPreferredDistro( distro: string | null | undefined ): { distro: string } | undefined { @@ -126,12 +141,18 @@ function getWslContextFromPreferredDistro( return trimmed ? { distro: trimmed } : undefined } +/** + * Strips Electron's internal run-as-node flag from user shell environments. + */ function removeInheritedElectronRunAsNode(env: Record): void { // Why: the daemon needs ELECTRON_RUN_AS_NODE=1 internally, but user shells // must not inherit it or nested Electron commands run as plain Node. delete env.ELECTRON_RUN_AS_NODE } +/** + * Formats a daemon preflight failure with the same ENOENT details node-pty exposes. + */ function formatMissingDaemonPathError(kind: 'helper' | 'cwd', path: string): DaemonProtocolError { const detailName = kind === 'helper' ? 'helper' : 'cwd' const step = kind === 'helper' ? 'posix_spawn' : 'daemon_cwd' @@ -142,6 +163,9 @@ function formatMissingDaemonPathError(kind: 'helper' | 'cwd', path: string): Dae ) } +/** + * Checks whether a path currently exists and is a directory. + */ function isExistingDirectory(path: string | undefined): path is string { if (!path) { return false @@ -153,6 +177,9 @@ function isExistingDirectory(path: string | undefined): path is string { } } +/** + * Moves the daemon process to a stable cwd after its original cwd disappears. + */ function repairDaemonCwd(): string | null { const candidates = [ process.env.ORCA_USER_DATA_PATH, @@ -172,6 +199,9 @@ function repairDaemonCwd(): string | null { return null } +/** + * Ensures the daemon cwd is valid before native PTY spawning. + */ function preflightDaemonCwd(): void { let daemonCwd = '' try { @@ -192,6 +222,9 @@ function preflightDaemonCwd(): void { throw formatMissingDaemonPathError('cwd', daemonCwd) } +/** + * Validates macOS node-pty helper availability before spawning terminals. + */ function preflightMacNodePtySpawnEnvironment(): void { if (process.platform !== 'darwin') { return @@ -219,10 +252,16 @@ function preflightMacNodePtySpawnEnvironment(): void { throw formatMissingDaemonPathError('helper', candidates[0] ?? '') } +/** + * Detects native Windows paths that should be validated before spawn. + */ function isNativeWindowsPath(path: string): boolean { return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\') } +/** + * Validates explicit native Windows cwd paths before ConPTY launch. + */ function preflightWindowsPtySpawnEnvironment(args: { validationCwd: string cwdWasExplicit: boolean @@ -238,6 +277,9 @@ function preflightWindowsPtySpawnEnvironment(args: { validateWorkingDirectory(args.validationCwd) } +/** + * Wraps native PTY spawn failures with shell and cwd context. + */ function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string): Error { const message = err instanceof Error ? err.message : String(err) const formatted = new DaemonProtocolError( @@ -249,6 +291,9 @@ function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string): return formatted } +/** + * Runs a short native PTY spawn probe for daemon health checks. + */ export async function checkPtySpawnHealth(): Promise { if (process.platform !== 'darwin') { return @@ -318,6 +363,9 @@ export async function checkPtySpawnHealth(): Promise { }) } +/** + * Normalizes node-pty foreground process strings to executable basenames. + */ function normalizeForegroundProcessName(processName: string | null | undefined): string | null { const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? '' if (!trimmed || trimmed === 'xterm-256color') { @@ -326,6 +374,9 @@ function normalizeForegroundProcessName(processName: string | null | undefined): return trimmed.split(/[\\/]/).pop() || null } +/** + * Falls back to the spawned Windows shell when node-pty reports a terminal name. + */ function resolveFallbackForegroundProcess( processName: string | null | undefined, shellPath: string @@ -339,6 +390,12 @@ function resolveFallbackForegroundProcess( return normalizeForegroundProcessName(pathWin32.basename(shellPath)) } +/** + * Spawns the daemon-owned PTY subprocess for a terminal session. + * + * The returned handle records whether the startup command was already embedded + * in Windows shell args so the daemon host does not write it a second time. + */ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandle { const size = normalizePtySize(opts.cols, opts.rows) const env: Record = { @@ -392,6 +449,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl let shellPath = cwdWslInfo || sessionWslContext ? 'wsl.exe' : opts.shellOverride || resolvePtyShellPath(env) let shellArgs: string[] + let startupCommandDeliveredInShellArgs = false const startupAgentRecognition = recognizeAgentProcessFromCommandLine(opts.command) const isCodexStartupCommand = startupAgentRecognition?.agent === 'codex' const requestedCwd = opts.cwd || getDefaultCwd() @@ -436,11 +494,13 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl shellPath, spawnCwd, getDefaultCwd(), - sessionWslContext ?? preferredWslContext + sessionWslContext ?? preferredWslContext, + opts.command ) shellArgs = resolved.shellArgs spawnCwd = resolved.effectiveCwd validationCwd = resolved.validationCwd + startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true if (isWindowsGitBashShellPath(shellPath)) { // Why: Git for Windows login startup files otherwise cd to $HOME, // ignoring node-pty's cwd for repo-scoped terminals. @@ -466,11 +526,14 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl getDefaultCwd(), { distro: codexHomeWslInfo.distro - } + }, + opts.command ) shellArgs = resolved.shellArgs spawnCwd = resolved.effectiveCwd validationCwd = resolved.validationCwd + startupCommandDeliveredInShellArgs = + resolved.startupCommandDeliveredInShellArgs === true } } } else if (isHostCodexHomeForWsl(env.CODEX_HOME)) { @@ -683,6 +746,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl return { pid: proc.pid, + ...(startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}), getForegroundProcess: () => { // Why: node-pty's `.process` getter reports the PTY's live foreground // process name (the agent running in the shell, or the shell itself) and diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 94e434d7f..f3262b4ec 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -31,6 +31,9 @@ export type SubprocessHandle = { /** Live foreground process name of the PTY (node-pty's `.process`), e.g. * 'claude' / 'codex' / 'zsh'. Null once the child has exited. */ getForegroundProcess(): string | null + /** True when shell launch args already delivered the startup command, so the + * terminal host must skip its stdin fallback write. */ + startupCommandDeliveredInShellArgs?: boolean write(data: string): void resize(cols: number, rows: number): void kill(): void diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index a0c4b9579..f26aa6817 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -3,11 +3,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TerminalHost } from './terminal-host' import type { SubprocessHandle } from './session' -function createMockSubprocess(): SubprocessHandle { +function createMockSubprocess( + options: { startupCommandDeliveredInShellArgs?: boolean } = {} +): SubprocessHandle { let onDataCb: ((data: string) => void) | null = null let onExitCb: ((code: number) => void) | null = null return { pid: 99999, + ...(options.startupCommandDeliveredInShellArgs + ? { startupCommandDeliveredInShellArgs: true } + : {}), getForegroundProcess: vi.fn(() => null), write: vi.fn(), resize: vi.fn(), @@ -163,6 +168,31 @@ describe('TerminalHost', () => { process.platform === 'win32' ? 'echo hello\r' : 'echo hello\n' ) }) + + it('does not write startup commands already embedded in shell args', async () => { + spawnFn = vi.fn(() => { + const sub = createMockSubprocess({ + startupCommandDeliveredInShellArgs: true + }) as ReturnType & { + _onDataCb: ((data: string) => void) | null + _onExitCb: ((code: number) => void) | null + } + lastSubprocess = sub + return sub + }) + host.dispose() + host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn }) + + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + command: 'codex --no-alt-screen', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + expect(lastSubprocess.write).not.toHaveBeenCalled() + }) }) describe('write', () => { diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 2bb4473b4..a772de6ef 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -77,6 +77,12 @@ export class TerminalHost { this.maxTombstones = opts.maxTombstones ?? DEFAULT_MAX_TOMBSTONES } + /** + * Creates a terminal session or attaches to an existing live one. + * + * Startup commands are written through stdin only when the subprocess did not + * already deliver them through shell launch arguments. + */ async createOrAttach(opts: CreateOrAttachOptions): Promise { const existing = this.sessions.get(opts.sessionId) @@ -137,7 +143,7 @@ export class TerminalHost { const token = session.attachClient(opts.streamClient) - if (opts.command) { + if (opts.command && !subprocess.startupCommandDeliveredInShellArgs) { // Why: startup commands must run inside the long-lived interactive shell // the daemon keeps for the pane. Session.write() handles the shell-ready // barrier for supported shells and falls back to an immediate write for diff --git a/src/main/devin/hook-service.test.ts b/src/main/devin/hook-service.test.ts index 46e1b2b4d..e7b90c7fc 100644 --- a/src/main/devin/hook-service.test.ts +++ b/src/main/devin/hook-service.test.ts @@ -16,7 +16,11 @@ vi.mock('os', async () => { }) import { DevinHookService } from './hook-service' -import { getDevinConfigPath, getDevinManagedCommand } from './hook-settings' +import { + getDevinConfigPath, + getDevinManagedCommand, + getDevinManagedScriptFileName +} from './hook-settings' describe('DevinHookService', () => { let homeDir: string @@ -24,9 +28,11 @@ describe('DevinHookService', () => { beforeEach(() => { homeDir = mkdtempSync(join(tmpdir(), 'orca-devin-home-')) homedirMock.mockReturnValue(homeDir) + vi.stubEnv('APPDATA', join(homeDir, '.config')) }) afterEach(() => { + vi.unstubAllEnvs() vi.clearAllMocks() rmSync(homeDir, { recursive: true, force: true }) }) @@ -57,7 +63,10 @@ describe('DevinHookService', () => { for (const eventName of ['PreToolUse', 'PostToolUse', 'PermissionRequest']) { expect(config.hooks[eventName][0].matcher).toBeUndefined() } - const script = readFileSync(join(homeDir, '.orca', 'agent-hooks', 'devin-hook.sh'), 'utf8') + const script = readFileSync( + join(homeDir, '.orca', 'agent-hooks', getDevinManagedScriptFileName()), + 'utf8' + ) expect(script).toContain('/hook/devin') }) @@ -155,7 +164,7 @@ describe('DevinHookService', () => { it('returns partial status when some managed hooks are missing', () => { const configPath = join(homeDir, '.config', 'devin', 'config.json') - const scriptPath = join(homeDir, '.orca', 'agent-hooks', 'devin-hook.sh') + const scriptPath = join(homeDir, '.orca', 'agent-hooks', getDevinManagedScriptFileName()) const command = getDevinManagedCommand(scriptPath) mkdirSync(dirname(configPath), { recursive: true }) mkdirSync(dirname(scriptPath), { recursive: true }) diff --git a/src/main/ipc/pty-startup-barrier-ordering.test.ts b/src/main/ipc/pty-startup-barrier-ordering.test.ts index 0de91770d..389269466 100644 --- a/src/main/ipc/pty-startup-barrier-ordering.test.ts +++ b/src/main/ipc/pty-startup-barrier-ordering.test.ts @@ -4,7 +4,10 @@ import { describe, expect, it } from 'vitest' describe('PTY startup barrier ordering', () => { it('waits for local startup before resolving the provider for runtime and renderer spawns', () => { - const source = readFileSync(join(process.cwd(), 'src/main/ipc/pty.ts'), 'utf8') + const source = readFileSync(join(process.cwd(), 'src/main/ipc/pty.ts'), 'utf8').replaceAll( + '\r\n', + '\n' + ) const runtimeSpawnStart = source.indexOf('spawn: async (args) => {') const runtimeSpawnEnd = source.indexOf(' write:', runtimeSpawnStart) const runtimeSpawn = source.slice(runtimeSpawnStart, runtimeSpawnEnd) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index d978cac64..e27685f05 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -6,11 +6,7 @@ import { delimiter, join } from 'node:path' const isWindowsHost = process.platform === 'win32' const posixOnlyIt = isWindowsHost ? it.skip : it -const expectedOmpStatusExtension = join( - '/tmp/default-omp-agent', - 'extensions', - 'orca-agent-status.ts' -) +const expectedOmpStatusExtension = '/tmp/default-omp-agent/extensions/orca-agent-status.ts' const expectedAttributionShimDir = join( '/tmp/orca-user-data', 'orca-terminal-attribution', diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 0b7da3deb..142548390 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -65,6 +65,9 @@ type ExitCallback = (payload: { id: string; code: number }) => void const dataListeners = new Set() const exitListeners = new Set() +/** + * Returns a stable default cwd for locally spawned PTYs. + */ function getDefaultCwd(): string { if (process.platform !== 'win32') { return process.env.HOME || '/' @@ -82,6 +85,9 @@ function getDefaultCwd(): string { return 'C:\\' } +/** + * Removes inherited pane identity unless this PTY explicitly supplies it. + */ function removeUnspecifiedPaneIdentityEnv( env: Record, explicitEnv: Record | undefined @@ -93,6 +99,9 @@ function removeUnspecifiedPaneIdentityEnv( } } +/** + * Promotes the agent-teams shim path ahead of inherited PATH entries. + */ function promoteAgentTeamsShimPath( env: Record, requestedPath: string | undefined @@ -108,6 +117,9 @@ function promoteAgentTeamsShimPath( env.PATH = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(delimiter) } +/** + * Disposes native node-pty listeners registered for a PTY id. + */ function disposePtyListeners(id: string): void { const disposables = ptyDisposables.get(id) if (disposables) { @@ -118,6 +130,9 @@ function disposePtyListeners(id: string): void { } } +/** + * Resolves a WSL context from a worktree id whose path is already a WSL path. + */ function getWslContextFromWorktreeId( worktreeId: string | undefined ): { distro: string; treatPosixCwdAsWsl: true } | undefined { @@ -126,6 +141,9 @@ function getWslContextFromWorktreeId( return wslInfo ? { distro: wslInfo.distro, treatPosixCwdAsWsl: true } : undefined } +/** + * Resolves a WSL launch context from a user-selected distro name. + */ function getWslContextFromPreferredDistro( distro: string | null | undefined ): { distro: string } | undefined { @@ -133,6 +151,9 @@ function getWslContextFromPreferredDistro( return trimmed ? { distro: trimmed } : undefined } +/** + * Removes all local tracking state for a PTY id after teardown. + */ function clearPtyState(id: string): void { disposePtyListeners(id) ptyProcesses.delete(id) @@ -141,6 +162,9 @@ function clearPtyState(id: string): void { ptyLoadGeneration.delete(id) } +/** + * Allocates either a stable caller-provided PTY id or a new numeric id. + */ function allocatePtyId(sessionId: string | undefined): string { const requested = normalizeLocalCallerSessionId(sessionId) if (requested) { @@ -153,6 +177,9 @@ function allocatePtyId(sessionId: string | undefined): string { return id } +/** + * Normalizes renderer session ids that should be reused for local PTY reattach. + */ function normalizeLocalCallerSessionId(sessionId: string | undefined): string | null { const requested = sessionId?.trim() if (!requested || /^\d+$/.test(requested)) { @@ -161,6 +188,9 @@ function normalizeLocalCallerSessionId(sessionId: string | undefined): string | return requested } +/** + * Normalizes node-pty foreground process strings to executable basenames. + */ function normalizeForegroundProcessName(processName: string | null | undefined): string | null { const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? '' if (!trimmed || trimmed === 'xterm-256color') { @@ -169,6 +199,9 @@ function normalizeForegroundProcessName(processName: string | null | undefined): return trimmed.split(/[\\/]/).pop() || null } +/** + * Falls back to the spawned Windows shell when node-pty reports a terminal name. + */ function resolveForegroundFallbackProcess( processName: string | null | undefined, shellName: string | undefined @@ -181,6 +214,9 @@ function resolveForegroundFallbackProcess( return shellName ?? processName ?? null } +/** + * Disposes the native PTY handle while avoiding recycled-pid signals on POSIX. + */ function destroyPtyProcess(proc: pty.IPty, options: { alreadyKilled?: boolean } = {}): void { // Why: node-pty's UnixTerminal.destroy() closes the master socket, which // releases the ptmx fd to the OS — without this call the fd leaks until GC @@ -204,6 +240,9 @@ function destroyPtyProcess(proc: pty.IPty, options: { alreadyKilled?: boolean } } } +/** + * Kills a local PTY and clears all associated local provider state. + */ function safeKillAndClean(id: string, proc: pty.IPty): void { disposePtyListeners(id) try { @@ -252,6 +291,12 @@ export class LocalPtyProvider implements IPtyProvider { this.opts = opts } + /** + * Spawns or reattaches a local PTY session for the renderer process. + * + * Windows shell launches can pre-deliver short startup commands in argv; this + * method preserves that state so the stdin fallback only runs when needed. + */ async spawn(args: PtySpawnOptions): Promise { const reattachId = normalizeLocalCallerSessionId(args.sessionId) if (reattachId) { @@ -281,6 +326,7 @@ export class LocalPtyProvider implements IPtyProvider { let shellArgs: string[] let effectiveCwd: string let validationCwd: string + let startupCommandDeliveredInShellArgs = false let shellReadyLaunch: ReturnType | null = null let getFallbackShellReadyConfig: | ((shell: string) => ReturnType) @@ -340,11 +386,13 @@ export class LocalPtyProvider implements IPtyProvider { shellPath, cwd, defaultCwd, - worktreeWslContext ?? preferredWslContext + worktreeWslContext ?? preferredWslContext, + args.command ) shellArgs = resolved.shellArgs effectiveCwd = resolved.effectiveCwd validationCwd = resolved.validationCwd + startupCommandDeliveredInShellArgs = resolved.startupCommandDeliveredInShellArgs === true } else { shellPath = args.env?.SHELL || process.env.SHELL || '/bin/zsh' shellArgs = ['-l'] @@ -441,6 +489,8 @@ export class LocalPtyProvider implements IPtyProvider { shellArgs = resolved.shellArgs effectiveCwd = resolved.effectiveCwd validationCwd = resolved.validationCwd + startupCommandDeliveredInShellArgs = + resolved.startupCommandDeliveredInShellArgs === true } } } else if (isHostCodexHomeForWsl(finalEnv.CODEX_HOME)) { @@ -651,7 +701,7 @@ export class LocalPtyProvider implements IPtyProvider { } ptyDisposables.set(id, disposables) - if (args.command) { + if (args.command && !startupCommandDeliveredInShellArgs) { writeStartupCommandWhenShellReady(shellReadyPromise, proc, args.command, (cleanup) => { startupCommandCleanup = cleanup }) diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 46d825dc7..0801f002e 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -19,10 +19,35 @@ describe('resolveWindowsShellLaunchArgs', () => { it('returns cmd.exe args with chcp 65001 for UTF-8 output', () => { const result = resolveWindowsShellLaunchArgs('cmd.exe', 'C:\\Users\\alice', 'C:\\Users\\alice') expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul']) + expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() expect(result.effectiveCwd).toBe('C:\\Users\\alice') expect(result.validationCwd).toBe('C:\\Users\\alice') }) + it('embeds short cmd.exe startup commands in shell args', () => { + const result = resolveWindowsShellLaunchArgs( + 'cmd.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice', + undefined, + 'codex --no-alt-screen' + ) + expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul & codex --no-alt-screen']) + expect(result.startupCommandDeliveredInShellArgs).toBe(true) + }) + + it('keeps large cmd.exe startup commands on stdin delivery', () => { + const result = resolveWindowsShellLaunchArgs( + 'cmd.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice', + undefined, + `codex ${'x'.repeat(7000)}` + ) + expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul']) + expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() + }) + it('returns PowerShell args that install OSC 133 bootstrap after normal profile loading', () => { const result = resolveWindowsShellLaunchArgs( 'powershell.exe', @@ -64,6 +89,56 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(command).not.toContain('`e]133') }) + it('embeds short PowerShell startup commands after the OSC 133 bootstrap', () => { + const result = resolveWindowsShellLaunchArgs( + 'powershell.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice', + undefined, + "& 'codex' '--no-alt-screen'" + ) + expect(result.startupCommandDeliveredInShellArgs).toBe(true) + + const command = Buffer.from(result.shellArgs[3] ?? '', 'base64').toString('utf16le') + expect(command).toContain('function Global:prompt') + expect(command.trimEnd().endsWith("& 'codex' '--no-alt-screen'")).toBe(true) + }) + + it('preserves complex PowerShell startup command text through EncodedCommand', () => { + const startupCommand = + '& "C:\\Program Files\\Orca CLI\\orca.exe" "--label" "quoted value"; $env:ORCA_VALUE = "nested"' + const result = resolveWindowsShellLaunchArgs( + 'powershell.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice', + undefined, + startupCommand + ) + + expect(result.startupCommandDeliveredInShellArgs).toBe(true) + const command = Buffer.from(result.shellArgs[3] ?? '', 'base64').toString('utf16le') + expect(command).toContain(`\n${startupCommand}`) + expect(command.trimEnd().endsWith(startupCommand)).toBe(true) + }) + + it('keeps large PowerShell startup commands on stdin delivery', () => { + const result = resolveWindowsShellLaunchArgs( + 'powershell.exe', + 'C:\\Users\\alice', + 'C:\\Users\\alice', + undefined, + `orca ${'x'.repeat(7000)}` + ) + + expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() + expect(result.shellArgs).toEqual([ + '-NoLogo', + '-NoExit', + '-EncodedCommand', + encodePowerShellCommand(getPowerShellOsc133Bootstrap()) + ]) + }) + it('handles pwsh.exe (PowerShell Core) the same as Windows PowerShell', () => { const result = resolveWindowsShellLaunchArgs('pwsh.exe', 'C:\\', 'C:\\Users\\alice') expect(result.shellArgs).toEqual([ @@ -102,9 +177,12 @@ describe('resolveWindowsShellLaunchArgs', () => { const result = resolveWindowsShellLaunchArgs( 'wsl.exe', 'C:\\Users\\alice\\code', - 'C:\\Users\\alice' + 'C:\\Users\\alice', + undefined, + 'codex' ) expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/code')) + expect(result.startupCommandDeliveredInShellArgs).toBeUndefined() // Why: WSL cannot cd into a Windows path, so node-pty must start from the // user's Windows home and we inject the Linux cd into the shellArgs above. expect(result.effectiveCwd).toBe('C:\\Users\\alice') diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index b41fbd557..9944b1277 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -11,6 +11,11 @@ import { getPowerShellOsc133Bootstrap } from '../powershell-osc133-bootstrap' +const CMD_EXE_COMMAND_LINE_MAX_CHARS = 8191 +const STARTUP_COMMAND_TEXT_MAX_CHARS = 6000 +const POWERSHELL_ENCODED_COMMAND_ARG_MAX_CHARS = 28_000 +const CMD_UTF8_SETUP_COMMAND = 'chcp 65001 > nul' + /** Result of resolving a Windows shell to its launch args + effective cwd. * * Why this module exists: both the in-process LocalPtyProvider and the @@ -21,6 +26,9 @@ import { * decision here keeps both paths honest. */ export type WindowsShellLaunchArgs = { shellArgs: string[] + /** True when the startup command was embedded in shellArgs and must not be + * written again through stdin. */ + startupCommandDeliveredInShellArgs?: boolean /** The cwd node-pty should be spawned with. WSL cannot cd into a Windows * path, so the wsl.exe branch returns the user's home as the effective cwd * and injects `cd ''` into shellArgs instead. */ @@ -36,6 +44,55 @@ export type WindowsShellWslContext = { treatPosixCwdAsWsl?: boolean } +/** + * Returns a startup command that is safe to embed in cmd.exe launch args. + * + * Commands that could exceed Windows cmd.exe limits return null so callers + * keep the older stdin delivery path. + */ +function getCmdShellArgStartupCommand(command?: string): string | null { + if (!command || command.length > STARTUP_COMMAND_TEXT_MAX_CHARS) { + return null + } + const commandArg = `${CMD_UTF8_SETUP_COMMAND} & ${command}` + if (commandArg.length > CMD_EXE_COMMAND_LINE_MAX_CHARS) { + return null + } + return command +} + +/** + * Builds the PowerShell -EncodedCommand payload for startup bootstrap. + * + * Short startup commands are appended to the bootstrap and marked as delivered; + * large payloads return the bootstrap alone so stdin delivery remains available. + */ +function getPowerShellEncodedCommand(startupCommand?: string): { + encodedCommand: string + startupCommandDeliveredInShellArgs?: boolean +} { + const bootstrap = getPowerShellOsc133Bootstrap() + if (!startupCommand || startupCommand.length > STARTUP_COMMAND_TEXT_MAX_CHARS) { + return { encodedCommand: encodePowerShellCommand(bootstrap) } + } + + const command = `${bootstrap}\n${startupCommand}` + const encodedCommand = encodePowerShellCommand(command) + // Why: -EncodedCommand expands UTF-16 text into base64; keep a conservative + // margin under Windows CreateProcess' 32,767-character command line limit. + if (encodedCommand.length > POWERSHELL_ENCODED_COMMAND_ARG_MAX_CHARS) { + return { encodedCommand: encodePowerShellCommand(bootstrap) } + } + + return { + encodedCommand, + startupCommandDeliveredInShellArgs: true + } +} + +/** + * Builds wsl.exe arguments that enter the target directory through the distro shell. + */ function buildWslShellArgs(linuxCwd: string, distro?: string): string[] { const setupCommand = [ `cd ${quotePosixShell(linuxCwd)}`, @@ -61,28 +118,35 @@ export function resolveWindowsShellLaunchArgs( shellPath: string, cwd: string, defaultCwd: string, - wslContext?: WindowsShellWslContext + wslContext?: WindowsShellWslContext, + startupCommand?: string ): WindowsShellLaunchArgs { const shellBasename = pathWin32.basename(shellPath).toLowerCase() if (shellBasename === 'cmd.exe') { + const shellArgStartupCommand = getCmdShellArgStartupCommand(startupCommand) return { - shellArgs: ['/K', 'chcp 65001 > nul'], + shellArgs: [ + '/K', + shellArgStartupCommand + ? `${CMD_UTF8_SETUP_COMMAND} & ${shellArgStartupCommand}` + : CMD_UTF8_SETUP_COMMAND + ], + ...(shellArgStartupCommand ? { startupCommandDeliveredInShellArgs: true } : {}), effectiveCwd: cwd, validationCwd: cwd } } if (shellBasename === 'powershell.exe' || shellBasename === 'pwsh.exe') { + const powerShellCommand = getPowerShellEncodedCommand(startupCommand) // Why: foreground-process status on Windows depends on OSC 133 C/D, and // PowerShell needs a prompt/readline bootstrap after profiles finish. return { - shellArgs: [ - '-NoLogo', - '-NoExit', - '-EncodedCommand', - encodePowerShellCommand(getPowerShellOsc133Bootstrap()) - ], + shellArgs: ['-NoLogo', '-NoExit', '-EncodedCommand', powerShellCommand.encodedCommand], + ...(powerShellCommand.startupCommandDeliveredInShellArgs + ? { startupCommandDeliveredInShellArgs: true } + : {}), effectiveCwd: cwd, validationCwd: cwd } diff --git a/src/relay/git-handler-branch-cleanup.test.ts b/src/relay/git-handler-branch-cleanup.test.ts index 54e497ca9..430076e84 100644 --- a/src/relay/git-handler-branch-cleanup.test.ts +++ b/src/relay/git-handler-branch-cleanup.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import * as path from 'path' import type { GitExec } from './git-handler-ops' import { removeWorktreeOp } from './git-handler-worktree-ops' @@ -16,7 +15,7 @@ function worktreeList(...entries: { path: string; branch?: string }[]): string { } function resolvedRepoPath(): string { - return path.resolve('/repo-feature', '/repo/.git', '..') + return '/repo' } describe('removeWorktreeOp branch cleanup', () => { diff --git a/src/relay/git-handler-pull-reconciliation.test.ts b/src/relay/git-handler-pull-reconciliation.test.ts index 2803301d5..7312d295d 100644 --- a/src/relay/git-handler-pull-reconciliation.test.ts +++ b/src/relay/git-handler-pull-reconciliation.test.ts @@ -94,7 +94,7 @@ describe('GitHandler pull reconciliation', () => { ) expect(execGit(consumerDir, ['status', '--short'])).toBe('') - }) + }, 15_000) it('preserves configured rebase pull semantics', async () => { const consumerDir = createDivergentFixture() @@ -108,7 +108,7 @@ describe('GitHandler pull reconciliation', () => { expect(parentRefs).toHaveLength(1) expect(existsSync(path.join(consumerDir, 'remote.txt'))).toBe(true) expect(execGit(consumerDir, ['status', '--short'])).toBe('') - }) + }, 15_000) }) function restoreGitEnv( diff --git a/src/relay/git-handler-worktree-ops.test.ts b/src/relay/git-handler-worktree-ops.test.ts index 177661f71..684bb5e2b 100644 --- a/src/relay/git-handler-worktree-ops.test.ts +++ b/src/relay/git-handler-worktree-ops.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import * as path from 'path' import type { GitExec } from './git-handler-ops' import { addWorktreeOp, removeWorktreeOp } from './git-handler-worktree-ops' @@ -16,7 +15,7 @@ function worktreeList(...entries: { path: string; branch?: string }[]): string { } function resolvedRepoPath(): string { - return path.resolve('/repo-feature', '/repo/.git', '..') + return '/repo' } describe('addWorktreeOp', () => { diff --git a/src/relay/git-handler-worktree-paths.test.ts b/src/relay/git-handler-worktree-paths.test.ts index d2f9ba9e4..d46d19183 100644 --- a/src/relay/git-handler-worktree-paths.test.ts +++ b/src/relay/git-handler-worktree-paths.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import * as path from 'path' import type { GitExec } from './git-handler-ops' import { removeWorktreeOp } from './git-handler-worktree-ops' @@ -29,7 +28,7 @@ function nulWorktreeList(...entries: { path: string; branch?: string }[]): strin } function resolvedRepoPath(): string { - return path.resolve('/repo-feature', '/repo/.git', '..') + return '/repo' } describe('relay worktree path parsing', () => { diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts index 50394129b..0aa391981 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField-source-boundaries.test.ts @@ -2,7 +2,10 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -const FIELD_SOURCE = readFileSync(join(__dirname, 'SmartWorkspaceNameField.tsx'), 'utf8') +const FIELD_SOURCE = readFileSync( + join(__dirname, 'SmartWorkspaceNameField.tsx'), + 'utf8' +).replaceAll('\r\n', '\n') function sourceBetween(source: string, startPattern: string, endPattern: string): string { const start = source.indexOf(startPattern) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx index c3e8870cb..3d7e25d73 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx @@ -226,7 +226,7 @@ describe('WorktreeCard compact hover details', () => { expect(markup).toContain('58941') expect(markup).not.toContain('data-worktree-card-meta-row=""') expect(markup).toContain('aria-label="1 live port"') - }, 20_000) + }, 30_000) it('shows hidden task, notes, and port details from the compact worktree card hover', async () => { settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true } @@ -277,7 +277,7 @@ describe('WorktreeCard compact hover details', () => { expect(markup).toContain('Live Ports') expect(markup).toContain('58941') expect(markup).not.toContain('data-worktree-card-meta-row=""') - }, 20_000) + }, 30_000) it('shows selected task and note metadata on the compact card title row', async () => { settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true } @@ -300,7 +300,7 @@ describe('WorktreeCard compact hover details', () => { expect(markup).toContain('Linked issue #123') expect(markup).toContain('Linked Linear ENG-123') expect(markup).toContain('Workspace notes') - }, 20_000) + }, 30_000) it('keeps selected task and note metadata above the compact branch row', async () => { settings = { compactWorktreeCards: true, experimentalNewWorktreeCardStyle: true } @@ -327,7 +327,7 @@ describe('WorktreeCard compact hover details', () => { expect(markup).toContain('Linked Linear ENG-123') expect(markup).toContain('Workspace notes') expect(markup).toContain('feature/local-branch') - }, 20_000) + }, 30_000) it('keeps branch identity visible on detailed cards by default', async () => { settings = { compactWorktreeCards: false } diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx index 1d289a0f1..c28bfe968 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.send-target.test.tsx @@ -157,7 +157,7 @@ describe('WorktreeCardAgents send targets', () => { expect(markup).toContain('data-disabled-reason="Agent is working"') expect(markup).toContain(`data-pane-key="${WORKING_PANE_KEY}"`) expect(markup).toContain('data-has-send-handler="true"') - }, 10_000) + }, 30_000) it('leaves other worktree rows in ordinary mode during target selection', async () => { mockStoreState = { diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx index 2c3cf8272..5981b049a 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx @@ -191,7 +191,7 @@ describe('WorktreeCardAgents', () => { expect(markup).toContain('data-testid="agent-row"') expect(markup).not.toContain(' { mockAgents = [mockAgent({ agentType: 'codex', startedAt: 1000, prompt: 'Run tests' })] diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx index 4c587e49a..7a5dfbb13 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx @@ -386,7 +386,7 @@ function expectBoundaryStep(args: { describe('WorktreeList real child WorktreeCard integration', () => { beforeAll(async () => { WorktreeList = (await import('./WorktreeList')).default as WorktreeListComponent - }, 20_000) + }, 60_000) beforeEach(() => { vi.clearAllMocks() diff --git a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx index e317d7c24..1c40881b6 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx @@ -195,7 +195,7 @@ async function renderExpandedBrowserTab(tab: BrowserTabState): Promise return expandNode(await renderBrowserTab(tab)) } -describe('BrowserTab favicon', { timeout: 10_000 }, () => { +describe('BrowserTab favicon', { timeout: 30_000 }, () => { beforeEach(() => { reactHookRuntime.states = [] reactHookRuntime.index = 0 diff --git a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts index a526711f0..4c8c34168 100644 --- a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts @@ -378,7 +378,7 @@ describe('TabBar PowerShell launch wiring', () => { onSelect?.() expect(onNewTerminalWithShell).toHaveBeenCalledWith('pwsh.exe') - }) + }, 30_000) it('hides the WSL terminal row for local host-runtime projects', async () => { appStoreSnapshot.activeRepoId = 'repo-1' diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index ba5a98d19..883544ed7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -596,7 +596,7 @@ describe('connectPanePty', () => { expect((globalThis as Record).__ptyConnectDiag).toBeUndefined() expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining('[pty-connect]')) logSpy.mockRestore() - }) + }, 30_000) it('threads the resolved local project runtime into IPC terminal transport options', async () => { const { connectPanePty } = await import('./pty-connection') diff --git a/src/shared/wsl-login-shell-command.test.ts b/src/shared/wsl-login-shell-command.test.ts index b24499e98..3ffde4202 100644 --- a/src/shared/wsl-login-shell-command.test.ts +++ b/src/shared/wsl-login-shell-command.test.ts @@ -7,9 +7,30 @@ import { quotePosixShell } from './wsl-login-shell-command' +const WSL_TEST_COMMAND_TIMEOUT_MS = 10_000 +let wslShAvailable: boolean | null = null + +function canRunWslSh(): boolean { + if (process.platform !== 'win32') { + return false + } + if (wslShAvailable !== null) { + return wslShAvailable + } + try { + execFileSync('wsl.exe', ['--', 'sh', '-lc', 'true'], { + timeout: WSL_TEST_COMMAND_TIMEOUT_MS + }) + wslShAvailable = true + } catch { + wslShAvailable = false + } + return wslShAvailable +} + function expectValidShSyntax(command: string): void { try { - execFileSync('sh', ['-n'], { input: command }) + execFileSync('sh', ['-n'], { input: command, timeout: WSL_TEST_COMMAND_TIMEOUT_MS }) return } catch (error) { if ( @@ -19,7 +40,13 @@ function expectValidShSyntax(command: string): void { throw error } } - execFileSync('wsl.exe', ['--', 'sh', '-n'], { input: command }) + if (!canRunWslSh()) { + return + } + execFileSync('wsl.exe', ['--', 'sh', '-n'], { + input: command, + timeout: WSL_TEST_COMMAND_TIMEOUT_MS + }) } describe('wsl login shell command helpers', () => { @@ -45,7 +72,7 @@ describe('wsl login shell command helpers', () => { expect(escaped).toContain('\\$(getent passwd "\\$(id -un)"') expect(escaped).toContain('\\$HISTFILE') expectValidShSyntax(command) - }, 15_000) + }, 30_000) it('does not double-escape wrapper shell variables', () => { const command = 'echo \\$_orca_wsl_shell "$_orca_wsl_shell"' @@ -65,23 +92,23 @@ describe('wsl login shell command helpers', () => { "'HISTFILE=/tmp/orca-history printf \"\\$HISTFILE\"; printf '\\''%s'\\'' \"\\$SHELL\"'" ) expectValidShSyntax(command) - }, 15_000) + }, 30_000) it('preserves user command variables across the Windows-to-WSL argv boundary', () => { - if (process.platform !== 'win32') { - return - } - try { - execFileSync('wsl.exe', ['--', 'true']) - } catch { + if (!canRunWslSh()) { return } const command = buildWslLoginShellCommand('orca_value=ok; printf "<%s>" "$orca_value"') const escaped = escapeWslShCommandForWindows(command) - expect(execFileSync('wsl.exe', ['--', 'sh', '-lc', escaped], { encoding: 'utf8' })).toBe('') - }, 15_000) + expect( + execFileSync('wsl.exe', ['--', 'sh', '-lc', escaped], { + encoding: 'utf8', + timeout: WSL_TEST_COMMAND_TIMEOUT_MS + }) + ).toBe('') + }, 30_000) it('starts an interactive login shell without assuming bash', () => { const command = buildWslInteractiveLoginShellCommand()