[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.
This commit is contained in:
parent
f19390a8a2
commit
f80704cc60
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, string>()
|
||||
}
|
||||
}))
|
||||
|
||||
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<typeof NodeFs>('node:fs')
|
||||
return {
|
||||
|
|
@ -33,11 +45,52 @@ vi.mock('node:fs', async () => {
|
|||
}
|
||||
return actual.linkSync(...args)
|
||||
},
|
||||
lstatSync: ((path: Parameters<typeof actual.lstatSync>[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<typeof actual.readlinkSync>[0]) => {
|
||||
const fakeTarget = fsMockState.fakeSymlinks.get(String(path))
|
||||
if (fakeTarget !== undefined) {
|
||||
return fakeTarget
|
||||
}
|
||||
return actual.readlinkSync(path)
|
||||
}) as typeof actual.readlinkSync,
|
||||
renameSync: (...args: Parameters<typeof actual.renameSync>) => {
|
||||
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<typeof actual.rmSync>) => {
|
||||
fsMockState.fakeSymlinks.delete(String(args[0]))
|
||||
return actual.rmSync(...args)
|
||||
},
|
||||
symlinkSync: (...args: Parameters<typeof actual.symlinkSync>) => {
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<void> | 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<void> {
|
||||
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<CodexSessionBridgeSummary> {
|
||||
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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
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<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
|
@ -758,7 +758,7 @@ describe('CodexHookService', () => {
|
|||
hooks: Record<string, unknown>
|
||||
}
|
||||
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')
|
||||
|
|
|
|||
|
|
@ -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<typeof createPtySubprocess>
|
||||
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<typeof createPtySubprocess>
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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<string, string>,
|
||||
explicitEnv: Record<string, string> | undefined
|
||||
|
|
@ -92,6 +98,9 @@ function removeUnspecifiedPaneIdentityEnv(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotes the agent-teams shim path ahead of inherited PATH entries.
|
||||
*/
|
||||
function promoteAgentTeamsShimPath(
|
||||
env: Record<string, string>,
|
||||
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<string, string>,
|
||||
explicitEnv: Record<string, string> | 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<string, string>): 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 = '<unavailable>'
|
||||
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] ?? '<unresolved>')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
if (process.platform !== 'darwin') {
|
||||
return
|
||||
|
|
@ -318,6 +363,9 @@ export async function checkPtySpawnHealth(): Promise<void> {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, string> = {
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<typeof createMockSubprocess> & {
|
||||
_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', () => {
|
||||
|
|
|
|||
|
|
@ -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<CreateOrAttachResult> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -65,6 +65,9 @@ type ExitCallback = (payload: { id: string; code: number }) => void
|
|||
const dataListeners = new Set<DataCallback>()
|
||||
const exitListeners = new Set<ExitCallback>()
|
||||
|
||||
/**
|
||||
* 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<string, string>,
|
||||
explicitEnv: Record<string, string> | undefined
|
||||
|
|
@ -93,6 +99,9 @@ function removeUnspecifiedPaneIdentityEnv(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotes the agent-teams shim path ahead of inherited PATH entries.
|
||||
*/
|
||||
function promoteAgentTeamsShimPath(
|
||||
env: Record<string, string>,
|
||||
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<PtySpawnResult> {
|
||||
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<typeof getShellReadyLaunchConfig> | null = null
|
||||
let getFallbackShellReadyConfig:
|
||||
| ((shell: string) => ReturnType<typeof getShellReadyLaunchConfig>)
|
||||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 '<linux path>'` 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ describe('WorktreeCardAgents', () => {
|
|||
expect(markup).toContain('data-testid="agent-row"')
|
||||
expect(markup).not.toContain('<button')
|
||||
expect(markup).not.toContain('aria-expanded')
|
||||
}, 10_000)
|
||||
}, 30_000)
|
||||
|
||||
it('uses compact mode when the display preference is absent', async () => {
|
||||
mockAgents = [mockAgent({ agentType: 'codex', startedAt: 1000, prompt: 'Run tests' })]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ async function renderExpandedBrowserTab(tab: BrowserTabState): Promise<unknown>
|
|||
return expandNode(await renderBrowserTab(tab))
|
||||
}
|
||||
|
||||
describe('BrowserTab favicon', { timeout: 10_000 }, () => {
|
||||
describe('BrowserTab favicon', { timeout: 30_000 }, () => {
|
||||
beforeEach(() => {
|
||||
reactHookRuntime.states = []
|
||||
reactHookRuntime.index = 0
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -596,7 +596,7 @@ describe('connectPanePty', () => {
|
|||
expect((globalThis as Record<string, unknown>).__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')
|
||||
|
|
|
|||
|
|
@ -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('<ok>')
|
||||
}, 15_000)
|
||||
expect(
|
||||
execFileSync('wsl.exe', ['--', 'sh', '-lc', escaped], {
|
||||
encoding: 'utf8',
|
||||
timeout: WSL_TEST_COMMAND_TIMEOUT_MS
|
||||
})
|
||||
).toBe('<ok>')
|
||||
}, 30_000)
|
||||
|
||||
it('starts an interactive login shell without assuming bash', () => {
|
||||
const command = buildWslInteractiveLoginShellCommand()
|
||||
|
|
|
|||
Loading…
Reference in New Issue