mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix(wsl): await Pi and OMP guest relay materialization (#21721)
* fix(wsl): await Pi and OMP guest relay materialization * fix(wsl): materialize Pi extension before guest launch * fix(wsl): keep relay state under lint limit * fix(wsl): preserve guest agent readiness across launches * test(wsl): expect guest status path translation * ci: rerun PR checks after rebase * ci: retrigger PR checks * ci: run final PR verification
This commit is contained in:
@@ -16,29 +16,39 @@ type GuestPluginInstallDeps = {
|
||||
* with `unavailable` (no handler / teardown): only `none` means the previously
|
||||
* recorded dir is now unusable and must stop being advertised to PTYs. */
|
||||
export type GuestOverlayResult =
|
||||
| { kind: 'dir'; dir?: string; dir2?: string }
|
||||
| { kind: 'dir'; dir?: string; dir2?: string; piDir?: string; ompDir?: string }
|
||||
| { kind: 'none' }
|
||||
| { kind: 'unavailable' }
|
||||
|
||||
export async function requestGuestOpenCodeOverlayDir(
|
||||
mux: SshChannelMultiplexer,
|
||||
deps: GuestPluginInstallDeps,
|
||||
distro: string
|
||||
distro: string,
|
||||
launchKind?: 'pi' | 'omp'
|
||||
): Promise<GuestOverlayResult> {
|
||||
try {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape.
|
||||
const res = (await mux.request(AGENT_HOOK_INSTALL_PLUGINS_METHOD, deps.pluginSources())) as {
|
||||
overlayDirs?: { opencode?: unknown; opencode2?: unknown }
|
||||
const res = (await mux.request(AGENT_HOOK_INSTALL_PLUGINS_METHOD, {
|
||||
...deps.pluginSources(),
|
||||
...(launchKind ? { launchKind } : {})
|
||||
})) as {
|
||||
overlayDirs?: { opencode?: unknown; opencode2?: unknown; pi?: unknown; omp?: unknown }
|
||||
}
|
||||
const dir = res?.overlayDirs?.opencode
|
||||
const dir2 = res?.overlayDirs?.opencode2
|
||||
const piDir = res?.overlayDirs?.pi
|
||||
const ompDir = res?.overlayDirs?.omp
|
||||
const opencodeDir = typeof dir === 'string' && dir.length > 0 ? dir : undefined
|
||||
const opencode2Dir = typeof dir2 === 'string' && dir2.length > 0 ? dir2 : undefined
|
||||
return opencodeDir || opencode2Dir
|
||||
const guestPiDir = typeof piDir === 'string' && piDir.length > 0 ? piDir : undefined
|
||||
const guestOmpDir = typeof ompDir === 'string' && ompDir.length > 0 ? ompDir : undefined
|
||||
return opencodeDir || opencode2Dir || guestPiDir || guestOmpDir
|
||||
? {
|
||||
kind: 'dir',
|
||||
...(opencodeDir ? { dir: opencodeDir } : {}),
|
||||
...(opencode2Dir ? { dir2: opencode2Dir } : {})
|
||||
...(opencode2Dir ? { dir2: opencode2Dir } : {}),
|
||||
...(guestPiDir ? { piDir: guestPiDir } : {}),
|
||||
...(guestOmpDir ? { ompDir: guestOmpDir } : {})
|
||||
}
|
||||
: { kind: 'none' }
|
||||
} catch (err) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { agentHookServer } from './server'
|
||||
import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands'
|
||||
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
|
||||
import { getOpenCode2PluginSource, getOpenCodePluginSource } from '../opencode/hook-service'
|
||||
import { getPiAgentStatusExtensionSource } from '../pi/agent-status-extension-source'
|
||||
import { codexHookService } from '../codex/hook-service'
|
||||
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
import type { PluginSources } from '../../relay/plugin-overlay'
|
||||
@@ -121,7 +122,9 @@ export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = {
|
||||
// Why: only OpenCode is in scope for WSL now; the payload shape stays identical to SSH so Pi/OMP are additive later.
|
||||
pluginSources: () => ({
|
||||
opencodePluginSource: getOpenCodePluginSource(),
|
||||
opencode2PluginSource: getOpenCode2PluginSource()
|
||||
opencode2PluginSource: getOpenCode2PluginSource(),
|
||||
piExtensionSource: getPiAgentStatusExtensionSource('pi'),
|
||||
ompExtensionSource: getPiAgentStatusExtensionSource('omp')
|
||||
}),
|
||||
warn: (message) => console.warn(message),
|
||||
transientRetryDelayMs: WSL_RELAY_TRANSIENT_RETRY_DELAY_MS
|
||||
|
||||
@@ -27,7 +27,20 @@ type GuestInstallState = {
|
||||
codexHomePath?: string
|
||||
opencodeOverlayDir?: string
|
||||
opencode2OverlayDir?: string
|
||||
piAgentDir?: string
|
||||
ompStatusExtension?: string
|
||||
lastInstallAt?: number
|
||||
launchKinds?: Set<'pi' | 'omp'>
|
||||
installation?: Promise<void>
|
||||
}
|
||||
|
||||
function* requestedKinds(state: GuestInstallState): Generator<'pi' | 'omp' | undefined> {
|
||||
if (!state.launchKinds?.size) {
|
||||
yield undefined
|
||||
}
|
||||
if (state.launchKinds) {
|
||||
yield* state.launchKinds
|
||||
}
|
||||
}
|
||||
|
||||
export async function runWslRelayGuestInstall(
|
||||
@@ -35,6 +48,23 @@ export async function runWslRelayGuestInstall(
|
||||
state: GuestInstallState,
|
||||
mux: SshChannelMultiplexer,
|
||||
guestHome: string
|
||||
): Promise<void> {
|
||||
if (state.installation) {
|
||||
return state.installation
|
||||
}
|
||||
state.installation = installGuestHooksAndPlugins(deps, state, mux, guestHome)
|
||||
try {
|
||||
await state.installation
|
||||
} finally {
|
||||
state.installation = undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function installGuestHooksAndPlugins(
|
||||
deps: GuestInstallDeps,
|
||||
state: GuestInstallState,
|
||||
mux: SshChannelMultiplexer,
|
||||
guestHome: string
|
||||
): Promise<void> {
|
||||
state.lastInstallAt = Date.now()
|
||||
await installWslGuestHooks({
|
||||
@@ -49,12 +79,21 @@ export async function runWslRelayGuestInstall(
|
||||
})
|
||||
// Why: ship OpenCode's status plugin and record the guest overlay dir the
|
||||
// PTY env points OPENCODE_CONFIG_DIR at; identity-guarded against teardown.
|
||||
const overlay = await requestGuestOpenCodeOverlayDir(mux, deps, state.distro)
|
||||
if (state.mux === mux && overlay.kind !== 'unavailable') {
|
||||
// Clearing on 'none' matters: a rebuild that failed after wiping leaves the dir
|
||||
// present but plugin-less, and advertising it would hide the user's own config.
|
||||
state.opencodeOverlayDir = overlay.kind === 'dir' ? overlay.dir : undefined
|
||||
state.opencode2OverlayDir = overlay.kind === 'dir' ? overlay.dir2 : undefined
|
||||
const kinds = requestedKinds(state)
|
||||
for (const kind of kinds) {
|
||||
const overlay = await requestGuestOpenCodeOverlayDir(mux, deps, state.distro, kind)
|
||||
if (state.mux !== mux) {
|
||||
return
|
||||
}
|
||||
if (overlay.kind !== 'unavailable') {
|
||||
state.opencodeOverlayDir = overlay.kind === 'dir' ? overlay.dir : undefined
|
||||
state.opencode2OverlayDir = overlay.kind === 'dir' ? overlay.dir2 : undefined
|
||||
if (kind === 'pi') {
|
||||
state.piAgentDir = overlay.kind === 'dir' ? overlay.piDir : undefined
|
||||
} else if (kind === 'omp') {
|
||||
state.ompStatusExtension = overlay.kind === 'dir' ? overlay.ompDir : undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +102,10 @@ export async function maybeRerunWslRelayGuestInstall(
|
||||
deps: GuestInstallDeps,
|
||||
state: GuestInstallState
|
||||
): Promise<void> {
|
||||
if (state.installation) {
|
||||
await state.installation
|
||||
return
|
||||
}
|
||||
const mux = state.mux
|
||||
const guestHome = state.guestHome
|
||||
if (
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
REMOTE_MANAGED_HOOK_INSTALLER_AGENTS
|
||||
} from './remote-managed-hook-installers'
|
||||
import { WslHookRelayManager } from './wsl-hook-relay-manager'
|
||||
import { awaitExplicitPiOmpGuestReadiness } from './wsl-pi-omp-guest-readiness'
|
||||
import { FAILURE_COOLDOWN_BASE_MS, type WslHookRelayManagerDeps } from './wsl-hook-relay-deps'
|
||||
import {
|
||||
AGENT_HOOK_INSTALL_PLUGINS_METHOD,
|
||||
@@ -202,7 +203,12 @@ describe('WslHookRelayManager', () => {
|
||||
if (registerInstallPlugins) {
|
||||
harness.guestDispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async () => ({
|
||||
installed: { opencode: true, opencode2: true, pi: false, omp: false },
|
||||
overlayDirs: { opencode: opencodeOverlayDir, opencode2: opencode2OverlayDir }
|
||||
overlayDirs: {
|
||||
opencode: opencodeOverlayDir,
|
||||
opencode2: opencode2OverlayDir,
|
||||
pi: `${home}/.pi/agent`,
|
||||
omp: `${home}/.omp/agent/extensions/orca-agent-status.ts`
|
||||
}
|
||||
}))
|
||||
}
|
||||
return harness.transport
|
||||
@@ -287,6 +293,89 @@ describe('WslHookRelayManager', () => {
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
it('waits for guest materialization when an explicit Pi or OMP launch needs it', async () => {
|
||||
const { manager } = createManager({})
|
||||
await expect(
|
||||
awaitExplicitPiOmpGuestReadiness({
|
||||
isWsl: true,
|
||||
distro: 'Ubuntu',
|
||||
codexHomePath: codexHome,
|
||||
launchAgent: 'pi',
|
||||
manager
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
expect(manager.getOpenCodeOverlayDir('Ubuntu')).toBe(opencodeOverlayDir)
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
it('does not treat the endpoint as ready before guest install completes', async () => {
|
||||
let releaseInstall!: () => void
|
||||
const installGate = new Promise<void>((resolve) => {
|
||||
releaseInstall = resolve
|
||||
})
|
||||
const { manager } = createManager({
|
||||
installHooks: vi.fn(async () => installGate.then(() => []))
|
||||
})
|
||||
const readiness = awaitExplicitPiOmpGuestReadiness({
|
||||
isWsl: true,
|
||||
distro: 'Ubuntu',
|
||||
launchAgent: 'pi',
|
||||
timeoutMs: 50,
|
||||
manager
|
||||
})
|
||||
await vi.waitFor(() => expect(manager.getGuestEndpointFilePath('Ubuntu')).toBeNull())
|
||||
releaseInstall()
|
||||
await expect(readiness).resolves.toBe(true)
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
it('reports relay startup failure without blocking the explicit launch forever', async () => {
|
||||
const { manager } = createManager({
|
||||
waitForSentinel: vi.fn(async () => {
|
||||
throw startupError(17, 'guest unavailable')
|
||||
})
|
||||
})
|
||||
await expect(
|
||||
awaitExplicitPiOmpGuestReadiness({
|
||||
isWsl: true,
|
||||
distro: 'Ubuntu',
|
||||
launchAgent: 'omp',
|
||||
manager,
|
||||
timeoutMs: 50
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
it('times out a relay that never reaches guest materialization', async () => {
|
||||
const { manager } = createManager({
|
||||
waitForSentinel: vi.fn(() => new Promise<MultiplexerTransport>(() => {}))
|
||||
})
|
||||
await expect(
|
||||
awaitExplicitPiOmpGuestReadiness({
|
||||
isWsl: true,
|
||||
distro: 'Ubuntu',
|
||||
launchAgent: 'pi',
|
||||
timeoutMs: 5,
|
||||
manager
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
it('does not wait or start a relay for a bare shell', async () => {
|
||||
const { manager } = createManager({})
|
||||
await expect(
|
||||
awaitExplicitPiOmpGuestReadiness({
|
||||
isWsl: true,
|
||||
distro: 'Ubuntu',
|
||||
launchCommand: 'bash',
|
||||
manager
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
expect(manager.getGuestEndpointFilePath('Ubuntu')).toBeNull()
|
||||
})
|
||||
|
||||
it('forwards the WSL guest Claude version to the shared remote installer', async () => {
|
||||
const waitForSentinel = vi.fn(async () =>
|
||||
guestTransport({ detectedAgents: ['claude'], claudeVersion: '2.1.261 (Claude Code)' })
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
|
||||
import {
|
||||
runWslRelayGuestInstall,
|
||||
maybeRerunWslRelayGuestInstall
|
||||
@@ -9,7 +8,6 @@ import {
|
||||
defaultWslHookRelayDeps,
|
||||
isWslHookRelayAllowed,
|
||||
FAILURE_COOLDOWN_BASE_MS,
|
||||
FAILURE_COOLDOWN_MAX_MS,
|
||||
NO_NODE_COOLDOWN_MS,
|
||||
REINSTALL_ONE_SHOT_DELAY_MS,
|
||||
RUNNING_TEARDOWN_COOLDOWN_MS,
|
||||
@@ -30,37 +28,20 @@ import {
|
||||
recordManagedWslCodexHome,
|
||||
wslRuntimeHomePathsEqual
|
||||
} from '../codex/managed-wsl-codex-home-registry'
|
||||
import { resolveWslHookDefaultDistro } from './wsl-hook-default-distro'
|
||||
import { resumeStoppedWslHookRelays } from './wsl-hook-relay-resume'
|
||||
|
||||
type DistroState = {
|
||||
/** Original casing for wsl.exe argv and breadcrumbs; map keys are lowercased. */
|
||||
distro: string
|
||||
phase: 'starting' | 'running' | 'failed'
|
||||
child?: ChildProcessWithoutNullStreams
|
||||
mux?: SshChannelMultiplexer
|
||||
guestHome?: string
|
||||
codexHomePath?: string
|
||||
guestEndpointFilePath?: string
|
||||
opencodeOverlayDir?: string
|
||||
opencode2OverlayDir?: string
|
||||
failures: number
|
||||
cooldownUntil: number
|
||||
connectedAt?: number
|
||||
restartTimer?: ReturnType<typeof setTimeout>
|
||||
reinstallTimer?: ReturnType<typeof setTimeout>
|
||||
lastInstallAt?: number
|
||||
}
|
||||
|
||||
import {
|
||||
markWslRelayFailed,
|
||||
resolveWslDefaultDistro,
|
||||
resumeWslStoppedRelays
|
||||
} from './wsl-hook-relay-state-machine'
|
||||
import type { WslRelayDistroState } from './wsl-hook-relay-state'
|
||||
export class WslHookRelayManager {
|
||||
private deps: WslHookRelayManagerDeps
|
||||
private recovery: WslRelayRecovery
|
||||
private states = new Map<string, DistroState>()
|
||||
private states = new Map<string, WslRelayDistroState>()
|
||||
private stoppedByHooksOff = new Map<string, string | undefined>()
|
||||
private defaultDistro: string | null = null
|
||||
private disposed = false
|
||||
private warnedBundleMissing = false
|
||||
|
||||
constructor(deps: Partial<WslHookRelayManagerDeps> = {}) {
|
||||
this.deps = { ...defaultWslHookRelayDeps, ...deps }
|
||||
this.recovery = new WslRelayRecovery({
|
||||
@@ -70,8 +51,6 @@ export class WslHookRelayManager {
|
||||
isCurrent: (state) => this.states.get(wslHookRelayStateKey(state.distro)) === state,
|
||||
restart: (distro) => this.ensureForDistro(distro, this.stateFor(distro)?.codexHomePath),
|
||||
dropState: (state) => {
|
||||
// Why: identity-guarded — a fresh ensure() may own this key by now;
|
||||
// deleting by key alone would orphan its live relay child.
|
||||
const key = wslHookRelayStateKey(state.distro)
|
||||
if (this.states.get(key) === state) {
|
||||
this.states.delete(key)
|
||||
@@ -79,31 +58,32 @@ export class WslHookRelayManager {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setManagedHookSettingsResolver(resolve: WslHookRelayManagerDeps['managedHookSettings']): void {
|
||||
this.deps.managedHookSettings = resolve
|
||||
}
|
||||
|
||||
/** Fire-and-forget from every WSL PTY spawn-env build; errors breadcrumb. */
|
||||
ensureForDistro(distro: string | null, codexHomePath?: string | null): void {
|
||||
async ensureForDistro(
|
||||
distro: string | null,
|
||||
codexHomePath?: string | null,
|
||||
launchKind?: 'pi' | 'omp'
|
||||
): Promise<void> {
|
||||
if (this.disposed || !isWslHookRelayAllowed(this.deps)) {
|
||||
return
|
||||
}
|
||||
void this.ensureInternal(distro, codexHomePath ?? undefined).catch((err) => {
|
||||
await this.ensureInternal(distro, codexHomePath ?? undefined, launchKind).catch((err) => {
|
||||
const detail = err instanceof Error ? err.message : String(err)
|
||||
this.deps.warn(`[agent-hooks] WSL hook relay ensure failed: ${detail}`)
|
||||
})
|
||||
}
|
||||
|
||||
private stateFor(distro: string | null): DistroState | undefined {
|
||||
// Empty key never matches a real (non-empty) distro state.
|
||||
private stateFor(distro: string | null): WslRelayDistroState | undefined {
|
||||
return this.states.get(wslHookRelayStateKey(distro ?? this.defaultDistro ?? ''))
|
||||
}
|
||||
|
||||
/** Guest endpoint path once install completes. */
|
||||
getGuestEndpointFilePath(distro: string | null): string | null {
|
||||
return this.stateFor(distro)?.guestEndpointFilePath ?? null
|
||||
return this.stateFor(distro)?.connectedAt
|
||||
? (this.stateFor(distro)?.guestEndpointFilePath ?? null)
|
||||
: null
|
||||
}
|
||||
|
||||
getOpenCodeOverlayDir(
|
||||
distro: string | null,
|
||||
agent: 'opencode' | 'opencode2' = 'opencode'
|
||||
@@ -113,7 +93,10 @@ export class WslHookRelayManager {
|
||||
? (state?.opencode2OverlayDir ?? null)
|
||||
: (state?.opencodeOverlayDir ?? null)
|
||||
}
|
||||
|
||||
getGuestAgentPath(distro: string | null, kind: 'pi' | 'omp'): string | null {
|
||||
const state = this.stateFor(distro)
|
||||
return kind === 'pi' ? (state?.piAgentDir ?? null) : (state?.ompStatusExtension ?? null)
|
||||
}
|
||||
/** Kills every live relay. Non-permanent (hooks switched off mid-session) leaves the
|
||||
* manager reusable, so re-enabling hooks can start relays again without an app restart. */
|
||||
disposeAll({ permanent = true }: { permanent?: boolean } = {}): void {
|
||||
@@ -128,20 +111,17 @@ export class WslHookRelayManager {
|
||||
}
|
||||
this.states.clear()
|
||||
}
|
||||
|
||||
/** Restarts what a hooks-off teardown stopped. Skips distros the user has since shut
|
||||
* down: `wsl -d` BOOTS a stopped distro, and nothing in it is waiting on status. */
|
||||
resumeStoppedRelays(): void {
|
||||
resumeStoppedWslHookRelays(
|
||||
this.stoppedByHooksOff,
|
||||
this.deps.isDistroRunning,
|
||||
(distro, codexHomePath) => this.ensureForDistro(distro, codexHomePath)
|
||||
resumeWslStoppedRelays(this.stoppedByHooksOff, this.deps.isDistroRunning, (distro, home) =>
|
||||
this.ensureForDistro(distro, home)
|
||||
)
|
||||
}
|
||||
|
||||
private async ensureInternal(
|
||||
requestedDistro: string | null,
|
||||
requestedCodexHomePath?: string
|
||||
requestedCodexHomePath?: string,
|
||||
launchKind?: 'pi' | 'omp'
|
||||
): Promise<void> {
|
||||
const distro = requestedDistro ?? (await this.resolveDefaultDistro())
|
||||
if (!distro || this.disposed) {
|
||||
@@ -153,6 +133,10 @@ export class WslHookRelayManager {
|
||||
recordManagedWslCodexHome(distro, requestedCodexHomePath)
|
||||
}
|
||||
if (existing) {
|
||||
if (launchKind && !existing.launchKinds.has(launchKind)) {
|
||||
existing.launchKinds.add(launchKind)
|
||||
existing.lastInstallAt = 0
|
||||
}
|
||||
if (
|
||||
requestedCodexHomePath &&
|
||||
!wslRuntimeHomePathsEqual(existing.codexHomePath, requestedCodexHomePath)
|
||||
@@ -161,10 +145,11 @@ export class WslHookRelayManager {
|
||||
existing.lastInstallAt = 0
|
||||
}
|
||||
if (existing.phase === 'running') {
|
||||
void maybeRerunWslRelayGuestInstall(this.deps, existing)
|
||||
await maybeRerunWslRelayGuestInstall(this.deps, existing)
|
||||
return
|
||||
}
|
||||
if (existing.phase !== 'failed' || Date.now() < existing.cooldownUntil) {
|
||||
await existing.startup
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -181,56 +166,47 @@ export class WslHookRelayManager {
|
||||
}
|
||||
return
|
||||
}
|
||||
// Why: restart-stable instance identity keeps the guest endpoint file at
|
||||
// ONE path across restarts so daemon-surviving agents re-coordinate.
|
||||
const instanceKey =
|
||||
sanitizeWslHookInstanceKey(this.deps.instanceKey() ?? undefined) ?? `port${port}`
|
||||
if (existing) {
|
||||
this.recovery.clearTimers(existing)
|
||||
}
|
||||
const state: DistroState = {
|
||||
const state: WslRelayDistroState = {
|
||||
distro,
|
||||
phase: 'starting',
|
||||
failures: existing?.failures ?? 0,
|
||||
// Why: instance-keyed and on the distro's persistent fs, so it outlives a relay
|
||||
// crash — dropping it would blank status on panes spawned mid-relaunch.
|
||||
opencodeOverlayDir: existing?.opencodeOverlayDir,
|
||||
opencode2OverlayDir: existing?.opencode2OverlayDir,
|
||||
piAgentDir: existing?.piAgentDir,
|
||||
ompStatusExtension: existing?.ompStatusExtension,
|
||||
launchKinds: new Set(existing?.launchKinds ?? (launchKind ? [launchKind] : [])),
|
||||
codexHomePath: requestedCodexHomePath ?? existing?.codexHomePath,
|
||||
cooldownUntil: 0
|
||||
}
|
||||
this.states.set(key, state)
|
||||
|
||||
const env = buildWslRelaySpawnEnv(coords, bundle.version, instanceKey)
|
||||
|
||||
try {
|
||||
await launchWslRelayWithInstall({
|
||||
distro: state.distro,
|
||||
env,
|
||||
bundleJsPath: bundle.jsPath,
|
||||
version: bundle.version,
|
||||
io: this.deps,
|
||||
// Why the identity half: a hooks-off teardown drops this state and kills its child, but
|
||||
// that kill reads as a startup failure and the retry loop would respawn an untracked relay.
|
||||
isDisposed: () => this.disposed || this.states.get(key) !== state,
|
||||
onChild: (child) => {
|
||||
state.child = child
|
||||
},
|
||||
onNoNode: () =>
|
||||
this.markFailed(
|
||||
state,
|
||||
`no node >= 18 found in distro '${state.distro}'; agent hooks stay degraded there`,
|
||||
{ cooldownBaseMs: NO_NODE_COOLDOWN_MS }
|
||||
),
|
||||
onFailure: (message) =>
|
||||
this.markFailed(state, message, {
|
||||
cooldownBaseMs: FAILURE_COOLDOWN_BASE_MS
|
||||
}),
|
||||
connect: (transport, child) => this.connect(state, transport, child, instanceKey)
|
||||
})
|
||||
} catch (err) {
|
||||
// Why: teardown may have already recorded this failure; don't double-
|
||||
// count. A request-level error can leave a live child — never leak it.
|
||||
state.startup = launchWslRelayWithInstall({
|
||||
distro: state.distro,
|
||||
env,
|
||||
bundleJsPath: bundle.jsPath,
|
||||
version: bundle.version,
|
||||
io: this.deps,
|
||||
isDisposed: () => this.disposed || this.states.get(key) !== state,
|
||||
onChild: (child) => {
|
||||
state.child = child
|
||||
},
|
||||
onNoNode: () =>
|
||||
this.markFailed(
|
||||
state,
|
||||
`no node >= 18 found in distro '${state.distro}'; agent hooks stay degraded there`,
|
||||
{ cooldownBaseMs: NO_NODE_COOLDOWN_MS }
|
||||
),
|
||||
onFailure: (message) =>
|
||||
this.markFailed(state, message, {
|
||||
cooldownBaseMs: FAILURE_COOLDOWN_BASE_MS
|
||||
}),
|
||||
connect: (transport, child) => this.connect(state, transport, child, instanceKey)
|
||||
}).catch((err) => {
|
||||
state.child?.kill()
|
||||
state.mux?.dispose()
|
||||
if (state.phase !== 'failed') {
|
||||
@@ -238,11 +214,11 @@ export class WslHookRelayManager {
|
||||
cooldownBaseMs: FAILURE_COOLDOWN_BASE_MS
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
await state.startup
|
||||
}
|
||||
|
||||
private async connect(
|
||||
state: DistroState,
|
||||
state: WslRelayDistroState,
|
||||
transport: MultiplexerTransport,
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
instanceKey: string
|
||||
@@ -261,8 +237,6 @@ export class WslHookRelayManager {
|
||||
}
|
||||
state.mux = undefined
|
||||
const wasRunning = state.phase === 'running'
|
||||
// Why: only a stable run forgives past failures — a connect-then-die
|
||||
// loop must escalate, not retry every 10s.
|
||||
if (
|
||||
wasRunning &&
|
||||
state.connectedAt !== undefined &&
|
||||
@@ -275,7 +249,6 @@ export class WslHookRelayManager {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const homeResult = (await mux.request(WSL_HOOK_FS_METHODS.home)) as {
|
||||
ok?: boolean
|
||||
home?: string
|
||||
@@ -293,52 +266,30 @@ export class WslHookRelayManager {
|
||||
state.guestHome = homeResult.home
|
||||
state.guestEndpointFilePath = wslHookRelayEndpointFilePath(homeResult.home, instanceKey)
|
||||
await runWslRelayGuestInstall(this.deps, state, mux, homeResult.home)
|
||||
|
||||
if (state.phase === 'failed' || state.mux !== mux) {
|
||||
// Child died while installing — already recorded; don't revive.
|
||||
return
|
||||
}
|
||||
state.phase = 'running'
|
||||
state.connectedAt = Date.now()
|
||||
// Why: one-shot catch-up so a single-spawn session (no later ensure)
|
||||
// still writes Codex's deferred trust after the launch path seeds config.toml.
|
||||
this.recovery.scheduleOneShotReinstall(state, REINSTALL_ONE_SHOT_DELAY_MS, () => {
|
||||
void maybeRerunWslRelayGuestInstall(this.deps, state)
|
||||
})
|
||||
void mux.request(AGENT_HOOK_REQUEST_REPLAY_METHOD).catch(() => {
|
||||
// Fresh relays have nothing to replay; tolerate.
|
||||
})
|
||||
void mux.request(AGENT_HOOK_REQUEST_REPLAY_METHOD).catch(() => {})
|
||||
}
|
||||
|
||||
/** Records + breadcrumbs the failure and always arms the restart timer —
|
||||
* one failed relaunch must not end self-recovery; the timer's
|
||||
* distro-running probe keeps this from booting stopped distros. */
|
||||
private markFailed(
|
||||
state: DistroState,
|
||||
state: WslRelayDistroState,
|
||||
message: string,
|
||||
options: { cooldownBaseMs: number }
|
||||
): void {
|
||||
state.phase = 'failed'
|
||||
state.failures++
|
||||
state.child = undefined
|
||||
state.mux = undefined
|
||||
if (state.reinstallTimer) {
|
||||
clearTimeout(state.reinstallTimer)
|
||||
state.reinstallTimer = undefined
|
||||
}
|
||||
state.cooldownUntil =
|
||||
Date.now() + Math.min(options.cooldownBaseMs * state.failures, FAILURE_COOLDOWN_MAX_MS)
|
||||
this.deps.warn(`[agent-hooks] WSL hook relay (${state.distro}): ${message}`)
|
||||
this.recovery.scheduleRestart(state)
|
||||
markWslRelayFailed(state, message, options, this.deps, this.recovery)
|
||||
}
|
||||
|
||||
private async resolveDefaultDistro(): Promise<string | null> {
|
||||
this.defaultDistro = await resolveWslHookDefaultDistro(
|
||||
this.defaultDistro,
|
||||
this.deps.listDistros
|
||||
)
|
||||
return this.defaultDistro
|
||||
const distro = await resolveWslDefaultDistro(this.defaultDistro, this.deps.listDistros)
|
||||
this.defaultDistro = distro
|
||||
return distro
|
||||
}
|
||||
}
|
||||
|
||||
export const wslHookRelayManager = new WslHookRelayManager()
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { FAILURE_COOLDOWN_MAX_MS } from './wsl-hook-relay-deps'
|
||||
|
||||
type RelayFailureState = {
|
||||
distro: string
|
||||
phase: 'starting' | 'running' | 'failed'
|
||||
failures: number
|
||||
cooldownUntil: number
|
||||
child?: unknown
|
||||
mux?: { dispose(): void }
|
||||
reinstallTimer?: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
type RelayFailureDeps = {
|
||||
warn(message: string): void
|
||||
}
|
||||
|
||||
type RelayRecovery = {
|
||||
scheduleRestart(state: RelayFailureState): void
|
||||
}
|
||||
|
||||
export function markWslRelayFailed(
|
||||
state: RelayFailureState,
|
||||
message: string,
|
||||
options: { cooldownBaseMs: number },
|
||||
deps: RelayFailureDeps,
|
||||
recovery: RelayRecovery
|
||||
): void {
|
||||
state.phase = 'failed'
|
||||
state.failures++
|
||||
state.child = undefined
|
||||
state.mux = undefined
|
||||
if (state.reinstallTimer) {
|
||||
clearTimeout(state.reinstallTimer)
|
||||
state.reinstallTimer = undefined
|
||||
}
|
||||
state.cooldownUntil =
|
||||
Date.now() + Math.min(options.cooldownBaseMs * state.failures, FAILURE_COOLDOWN_MAX_MS)
|
||||
deps.warn(`[agent-hooks] WSL hook relay: ${message}`)
|
||||
recovery.scheduleRestart(state)
|
||||
}
|
||||
|
||||
export function resumeWslStoppedRelays(
|
||||
stopped: Map<string, string | undefined>,
|
||||
isDistroRunning: (distro: string) => Promise<boolean>,
|
||||
ensure: (distro: string, home: string | undefined) => void
|
||||
): void {
|
||||
const distros = [...stopped]
|
||||
stopped.clear()
|
||||
for (const [distro, home] of distros) {
|
||||
void isDistroRunning(distro)
|
||||
.then((running) => {
|
||||
if (running) {
|
||||
ensure(distro, home)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveWslDefaultDistro(
|
||||
current: string | null,
|
||||
listDistros: () => Promise<string[]>
|
||||
): Promise<string | null> {
|
||||
if (current) {
|
||||
return current
|
||||
}
|
||||
try {
|
||||
return (await listDistros())[0] ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
|
||||
export type WslRelayDistroState = {
|
||||
distro: string
|
||||
phase: 'starting' | 'running' | 'failed'
|
||||
child?: { kill: () => void }
|
||||
mux?: SshChannelMultiplexer
|
||||
guestHome?: string
|
||||
codexHomePath?: string
|
||||
guestEndpointFilePath?: string
|
||||
opencodeOverlayDir?: string
|
||||
opencode2OverlayDir?: string
|
||||
piAgentDir?: string
|
||||
ompStatusExtension?: string
|
||||
launchKinds: Set<'pi' | 'omp'>
|
||||
startup?: Promise<void>
|
||||
installation?: Promise<void>
|
||||
failures: number
|
||||
cooldownUntil: number
|
||||
connectedAt?: number
|
||||
restartTimer?: ReturnType<typeof setTimeout>
|
||||
reinstallTimer?: ReturnType<typeof setTimeout>
|
||||
lastInstallAt?: number
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
detectExplicitPiAgentKindFromCommand,
|
||||
isPiCompatibleAgentType,
|
||||
type PiAgentKind
|
||||
} from '../../shared/pi-agent-kind'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import { wslHookRelayManager, type WslHookRelayManager } from './wsl-hook-relay-manager'
|
||||
|
||||
export async function awaitExplicitPiOmpGuestReadiness(args: {
|
||||
isWsl: boolean
|
||||
distro: string | null | undefined
|
||||
codexHomePath?: string | null
|
||||
launchAgent?: TuiAgent
|
||||
launchCommand?: string
|
||||
timeoutMs?: number
|
||||
manager?: Pick<
|
||||
WslHookRelayManager,
|
||||
'ensureForDistro' | 'getGuestEndpointFilePath' | 'getGuestAgentPath'
|
||||
>
|
||||
}): Promise<boolean> {
|
||||
if (!args.isWsl) {
|
||||
return true
|
||||
}
|
||||
const kind: PiAgentKind | null = isPiCompatibleAgentType(args.launchAgent)
|
||||
? args.launchAgent
|
||||
: detectExplicitPiAgentKindFromCommand(args.launchCommand)
|
||||
if (kind !== 'pi' && kind !== 'omp') {
|
||||
return true
|
||||
}
|
||||
const distro = args.distro ?? null
|
||||
const manager = args.manager ?? wslHookRelayManager
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
manager
|
||||
.ensureForDistro(distro, args.codexHomePath, kind)
|
||||
.then(() =>
|
||||
Boolean(
|
||||
manager.getGuestEndpointFilePath(distro) && manager.getGuestAgentPath(distro, kind)
|
||||
)
|
||||
),
|
||||
new Promise<boolean>((resolve) => {
|
||||
timer = setTimeout(() => resolve(false), args.timeoutMs ?? 10_000)
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
@@ -505,7 +505,7 @@ describe('registerPtyHandlers', () => {
|
||||
'ORCA_AGENT_HOOK_PORT/u',
|
||||
'ORCA_AGENT_HOOK_TOKEN/u',
|
||||
// Why: bare WSL shells no longer create ~/.omp; only status extension is exported (#10196).
|
||||
'ORCA_OMP_STATUS_EXTENSION/p',
|
||||
'ORCA_OMP_STATUS_EXTENSION/u',
|
||||
'POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD'
|
||||
])
|
||||
)
|
||||
|
||||
@@ -198,7 +198,7 @@ describe('registerPtyHandlers', () => {
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
const ensureForDistro = vi
|
||||
.spyOn(wslHookRelayManager, 'ensureForDistro')
|
||||
.mockImplementation(() => {})
|
||||
.mockImplementation(async () => {})
|
||||
|
||||
try {
|
||||
buildPtyHostEnv(
|
||||
@@ -213,7 +213,7 @@ describe('registerPtyHandlers', () => {
|
||||
agentStatusHooksEnabled: true
|
||||
}
|
||||
)
|
||||
expect(ensureForDistro).toHaveBeenCalledExactlyOnceWith('Ubuntu', runtimeHome)
|
||||
expect(ensureForDistro).toHaveBeenCalledExactlyOnceWith('Ubuntu', runtimeHome, undefined)
|
||||
} finally {
|
||||
ensureForDistro.mockRestore()
|
||||
}
|
||||
|
||||
@@ -373,7 +373,7 @@ describe('registerPtyHandlers', () => {
|
||||
// reattach call — the manager owns the hooks/platform gating this spy stands in for.
|
||||
const ensureForDistro = vi
|
||||
.spyOn(wslHookRelayManager, 'ensureForDistro')
|
||||
.mockImplementation(() => {})
|
||||
.mockImplementation(async () => {})
|
||||
setLocalPtyProvider({
|
||||
spawn: vi.fn(async () => ({ id: 'pty-wsl', isReattach: true, wslDistro: 'Ubuntu-24.04' })),
|
||||
write: vi.fn(),
|
||||
|
||||
@@ -136,7 +136,11 @@ export function buildPtyHostEnv(
|
||||
if (opts.isWsl === true) {
|
||||
// Why: hook POSTs to 127.0.0.1 die inside WSL's NAT namespace; use the guest-resident relay's endpoint instead of the Windows one.
|
||||
const distro = opts.wslDistro ?? null
|
||||
wslHookRelayManager.ensureForDistro(distro, opts.selectedCodexHomePath)
|
||||
const wslLaunchKind =
|
||||
explicitPiAgentKind === 'pi' || explicitPiAgentKind === 'omp'
|
||||
? explicitPiAgentKind
|
||||
: undefined
|
||||
wslHookRelayManager.ensureForDistro(distro, opts.selectedCodexHomePath, wslLaunchKind)
|
||||
const guestEndpoint = wslHookRelayManager.getGuestEndpointFilePath(distro)
|
||||
if (guestEndpoint) {
|
||||
baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint
|
||||
@@ -222,6 +226,21 @@ export function buildPtyHostEnv(
|
||||
delete baseEnv.ORCA_PRIME_AGENT_STATUS_EXTENSION
|
||||
}
|
||||
|
||||
if (opts.isWsl && opts.agentStatusHooksEnabled) {
|
||||
const distro = opts.wslDistro ?? null
|
||||
if (explicitPiAgentKind === 'pi') {
|
||||
const guestPiDir = wslHookRelayManager.getGuestAgentPath(distro, 'pi')
|
||||
if (guestPiDir) {
|
||||
baseEnv.ORCA_PI_SOURCE_AGENT_DIR = guestPiDir
|
||||
}
|
||||
} else if (explicitPiAgentKind === 'omp') {
|
||||
const guestOmpExtension = wslHookRelayManager.getGuestAgentPath(distro, 'omp')
|
||||
if (guestOmpExtension) {
|
||||
baseEnv.ORCA_OMP_STATUS_EXTENSION = guestOmpExtension
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: keep the Codex home override PTY-scoped so dev/prod Orcas don't share hooks through ~/.codex.
|
||||
if (opts.skipCodexHomeEnv) {
|
||||
delete baseEnv.CODEX_HOME
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { GetSelectedCodexHomePath } from '../host-env/types'
|
||||
import { isCurrentPtyExit, ptyOwnership } from './ownership-state'
|
||||
import { localProvider } from './registry'
|
||||
import { clearProviderPtyState } from './state-cleanup'
|
||||
import { awaitExplicitPiOmpGuestReadiness } from '../../../agent-hooks/wsl-pi-omp-guest-readiness'
|
||||
|
||||
export function configureLocalPtyProvider(args: {
|
||||
runtime?: OrcaRuntimeService
|
||||
@@ -63,6 +64,13 @@ export function configureLocalPtyProvider(args: {
|
||||
launchAgent: ctx?.launchAgent,
|
||||
launchCommand: ctx?.command
|
||||
})
|
||||
await awaitExplicitPiOmpGuestReadiness({
|
||||
isWsl: ctx?.isWsl === true,
|
||||
distro: ctx?.wslDistro,
|
||||
codexHomePath: selectedCodexHomePath,
|
||||
launchAgent: ctx?.launchAgent,
|
||||
launchCommand: ctx?.command
|
||||
})
|
||||
const env = buildPtyHostEnv(id, baseEnv, {
|
||||
isPackaged: getAppEnvironment().isPackaged(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
|
||||
@@ -42,6 +42,7 @@ import { resolvePathEnvKey } from '../../../pty/windows-environment-path'
|
||||
import { stampWslOrchestrationCompatibilityHost } from '../../../pty/wsl-orca-env'
|
||||
import { ensureCodexStateDbBackfillRecoveryStarted } from '../../../codex/codex-state-db-backfill-recovery'
|
||||
import { clearProviderPtyState } from '../provider/state-cleanup'
|
||||
import { awaitExplicitPiOmpGuestReadiness } from '../../../agent-hooks/wsl-pi-omp-guest-readiness'
|
||||
import type { RuntimePtySpawnState } from './spawn-state'
|
||||
|
||||
export async function prepareRuntimePtySpawn(
|
||||
@@ -265,6 +266,13 @@ export async function prepareRuntimePtySpawn(
|
||||
launchAgent: args.launchAgent,
|
||||
launchCommand: ctx.launchCommand
|
||||
})
|
||||
await awaitExplicitPiOmpGuestReadiness({
|
||||
isWsl: shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd),
|
||||
distro: ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null,
|
||||
codexHomePath: ctx.selectedCodexHomePath,
|
||||
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
|
||||
launchCommand: ctx.launchCommand
|
||||
})
|
||||
ctx.env = buildPtyHostEnv(ctx.sessionId, ctx.env ?? {}, {
|
||||
isPackaged: getAppEnvironment().isPackaged(),
|
||||
resourcesPath: process.resourcesPath,
|
||||
|
||||
@@ -98,7 +98,8 @@ export function addOrcaWslInteropEnv(env: Record<string, string>): void {
|
||||
'ORCA_WSL_HOOK_RELAY_VERSION/u',
|
||||
'ORCA_WSL_HOOK_INSTANCE/u',
|
||||
'ORCA_OMP_SOURCE_AGENT_DIR/p',
|
||||
'ORCA_OMP_STATUS_EXTENSION/p',
|
||||
`ORCA_OMP_STATUS_EXTENSION/${env.ORCA_OMP_STATUS_EXTENSION?.startsWith('/') ? 'u' : 'p'}`,
|
||||
...(env.ORCA_PI_SOURCE_AGENT_DIR?.startsWith('/') ? ['ORCA_PI_SOURCE_AGENT_DIR/u'] : []),
|
||||
`${ORCA_IMAGE_PROTOCOL_ENV}/u`,
|
||||
'ORCA_OMP_FRESH_CONFIG/p',
|
||||
...worktreeSetupWslenvEntries(env)
|
||||
|
||||
@@ -43,6 +43,20 @@ describe.skipIf(process.platform === 'win32')('createInstallPluginsHandler (gues
|
||||
})
|
||||
})
|
||||
|
||||
it('materializes the requested Pi extension in the guest home', () => {
|
||||
withHome((home) => {
|
||||
const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), {
|
||||
HOME: home,
|
||||
ORCA_WSL_HOOK_INSTANCE: 'inst-pi'
|
||||
})
|
||||
const source = '// @orca-managed-pi-extension\nexport default {}\n'
|
||||
const res = install({ piExtensionSource: source, launchKind: 'pi' })
|
||||
expect(res.overlayDirs.pi).toBe(join(home, '.pi', 'agent'))
|
||||
const extension = join(home, '.pi', 'agent', 'extensions', 'orca-agent-status.ts')
|
||||
expect(readFileSync(extension, 'utf8')).toContain(source)
|
||||
})
|
||||
})
|
||||
|
||||
it('writes the OpenCode 2 plugin to its separate overlay', () => {
|
||||
withHome((home) => {
|
||||
const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), {
|
||||
|
||||
@@ -21,7 +21,7 @@ export type InstallPluginsResult = {
|
||||
omp: boolean
|
||||
primeAgent: boolean
|
||||
}
|
||||
overlayDirs: { opencode?: string; opencode2?: string }
|
||||
overlayDirs: { opencode?: string; opencode2?: string; pi?: string; omp?: string }
|
||||
}
|
||||
|
||||
export type InstallPluginsHandler = (params: Record<string, unknown>) => InstallPluginsResult
|
||||
@@ -64,6 +64,10 @@ export function createInstallPluginsHandler(
|
||||
primeAgentExtensionSource: typeof primeAgent === 'string' ? primeAgent : undefined
|
||||
})
|
||||
let opencodeDir: string | undefined
|
||||
const launchKind =
|
||||
params.launchKind === 'pi' || params.launchKind === 'omp' ? params.launchKind : undefined
|
||||
let piDir: string | undefined
|
||||
let ompDir: string | undefined
|
||||
if (pluginOverlay.hasOpenCodeSource()) {
|
||||
// An omitted source leaves the manager's cache untouched, so it counts as unchanged.
|
||||
const incoming = typeof opencode === 'string' ? opencode : null
|
||||
@@ -112,6 +116,21 @@ export function createInstallPluginsHandler(
|
||||
: null
|
||||
}
|
||||
}
|
||||
// Materialize only the explicitly requested agent. Bare shells must not create
|
||||
// ~/.pi/agent or ~/.omp/agent (#10196).
|
||||
if (launchKind === 'pi' || launchKind === 'omp') {
|
||||
const source = launchKind === 'pi' ? pi : omp
|
||||
if (typeof source === 'string') {
|
||||
const result = pluginOverlay.materializePi(`wsl-${launchKind}`, undefined, launchKind, {
|
||||
materializeDefaultHome: true
|
||||
})
|
||||
if (launchKind === 'pi') {
|
||||
piDir = result?.sourceAgentDir
|
||||
} else {
|
||||
ompDir = result?.statusExtensionPath
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
installed: {
|
||||
opencode: pluginOverlay.hasOpenCodeSource(),
|
||||
@@ -122,7 +141,9 @@ export function createInstallPluginsHandler(
|
||||
},
|
||||
overlayDirs: {
|
||||
...(opencodeDir ? { opencode: opencodeDir } : {}),
|
||||
...(opencode2Dir ? { opencode2: opencode2Dir } : {})
|
||||
...(opencode2Dir ? { opencode2: opencode2Dir } : {}),
|
||||
...(piDir ? { pi: piDir } : {}),
|
||||
...(ompDir ? { omp: ompDir } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user