diff --git a/src/main/index.ts b/src/main/index.ts index d11b4bd8..c7457eef 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -57,6 +57,7 @@ import { type RuntimeProviderManagementFeatureFacade, } from '@features/runtime-provider-management/main'; import { createWorkspaceTrustCoordinator } from '@features/workspace-trust/main'; +import { ensureOpenCodeBridgeRuntimeBinaryEnv } from '@main/services/runtime/openCodeBridgeRuntimeEnv'; import { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService'; import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy'; import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService'; @@ -411,18 +412,15 @@ async function createOpenCodeRuntimeAdapterRegistry( copyOpenCodeLocalMcpLaunchEnv(targetEnv, bridgeEnv); } }; - try { - const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath(); - if (appManagedOpenCodeBinary && !bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH) { - bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH = appManagedOpenCodeBinary; - } - } catch (error) { - logger.warn( - `[OpenCode] Runtime adapter bundled OpenCode binary unresolved: ${ - error instanceof Error ? error.message : String(error) - }` - ); - } + const ensureOpenCodeRuntimeBinaryEnv = async (targetEnv: NodeJS.ProcessEnv): Promise => { + await ensureOpenCodeBridgeRuntimeBinaryEnv({ + targetEnv, + bridgeEnv, + resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath, + onWarning: (message) => logger.warn(message), + }); + }; + await ensureOpenCodeRuntimeBinaryEnv(bridgeEnv); try { reportProgress('runtime-work-sync', 'Preparing runtime work sync hooks...'); const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({ @@ -465,6 +463,7 @@ async function createOpenCodeRuntimeAdapterRegistry( reportProgress('runtime-bridge', 'Preparing OpenCode bridge...'); const resolveBridgeCommandEnv = async (): Promise => { const nextEnv = { ...bridgeEnv }; + await ensureOpenCodeRuntimeBinaryEnv(nextEnv); if (!useHttpMcpBridge) { return nextEnv; } @@ -897,6 +896,23 @@ function isShutdownStarted(): boolean { return shutdownComplete || shutdownPromise !== null; } +function hasActiveTeamRuntimesForWindowClose(): boolean { + if (!servicesReady || !teamProvisioningService) { + return false; + } + + try { + return teamProvisioningService.hasActiveTeamRuntimes(); + } catch (error) { + logger.warn( + `Failed to check active team runtimes before closing last window: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return false; + } +} + function scheduleStartupTask(action: () => void, delayMs: number): void { const timer = setTimeout(() => { startupTimers.delete(timer); @@ -2748,10 +2764,16 @@ void app.whenReady().then(async () => { * All windows closed handler. */ app.on('window-all-closed', () => { + const hasActiveTeamRuntimes = hasActiveTeamRuntimesForWindowClose(); const shouldQuitWhenAllWindowsClosed = - process.platform !== 'darwin' || !configManager.getConfig().general.showDockIcon; + hasActiveTeamRuntimes || + process.platform !== 'darwin' || + !configManager.getConfig().general.showDockIcon; if (shouldQuitWhenAllWindowsClosed) { + if (hasActiveTeamRuntimes) { + logger.info('Quitting after last window closed because active team runtimes are running'); + } app.quit(); } }); diff --git a/src/main/services/runtime/openCodeBridgeRuntimeEnv.ts b/src/main/services/runtime/openCodeBridgeRuntimeEnv.ts new file mode 100644 index 00000000..c2c9eb97 --- /dev/null +++ b/src/main/services/runtime/openCodeBridgeRuntimeEnv.ts @@ -0,0 +1,38 @@ +import { getErrorMessage } from '@shared/utils/errorHandling'; + +import { applyOpenCodeRuntimeBinaryEnv } from './openCodeRuntimeBinaryEnv'; + +export interface EnsureOpenCodeBridgeRuntimeBinaryEnvOptions { + targetEnv: NodeJS.ProcessEnv; + bridgeEnv?: NodeJS.ProcessEnv; + resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: () => Promise; + onWarning?: (message: string) => void; +} + +export async function ensureOpenCodeBridgeRuntimeBinaryEnv({ + targetEnv, + bridgeEnv = targetEnv, + resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath, + onWarning, +}: EnsureOpenCodeBridgeRuntimeBinaryEnvOptions): Promise { + if (targetEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH?.trim()) { + applyOpenCodeRuntimeBinaryEnv(targetEnv, null); + return; + } + + try { + const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath(); + applyOpenCodeRuntimeBinaryEnv(targetEnv, appManagedOpenCodeBinary); + if ( + targetEnv !== bridgeEnv && + targetEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH && + !bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH + ) { + applyOpenCodeRuntimeBinaryEnv(bridgeEnv, targetEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH); + } + } catch (error) { + onWarning?.( + `[OpenCode] Runtime adapter bundled OpenCode binary unresolved: ${getErrorMessage(error)}` + ); + } +} diff --git a/src/main/services/runtime/openCodeRuntimeBinaryEnv.ts b/src/main/services/runtime/openCodeRuntimeBinaryEnv.ts new file mode 100644 index 00000000..0e668903 --- /dev/null +++ b/src/main/services/runtime/openCodeRuntimeBinaryEnv.ts @@ -0,0 +1,54 @@ +import path from 'node:path'; + +export const OPENCODE_RUNTIME_BINARY_PATH_ENV = 'CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH'; + +function normalizePathEntryForCompare(value: string): string { + const normalized = path.resolve(value.trim()); + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} + +function prependPathEntry(env: NodeJS.ProcessEnv, directory: string): void { + const trimmedDirectory = directory.trim(); + if (!trimmedDirectory) { + return; + } + + const currentPath = env.PATH ?? ''; + const currentEntries = currentPath.split(path.delimiter).filter(Boolean); + const normalizedDirectory = normalizePathEntryForCompare(trimmedDirectory); + const alreadyPresent = currentEntries.some( + (entry) => normalizePathEntryForCompare(entry) === normalizedDirectory + ); + + if (alreadyPresent) { + env.PATH = currentEntries.join(path.delimiter); + return; + } + + env.PATH = [trimmedDirectory, ...currentEntries].join(path.delimiter); +} + +export function applyOpenCodeRuntimeBinaryEnv( + env: NodeJS.ProcessEnv, + discoveredBinaryPath: string | null | undefined +): void { + const existingBinaryPath = env[OPENCODE_RUNTIME_BINARY_PATH_ENV]?.trim(); + const nextBinaryPath = existingBinaryPath || discoveredBinaryPath?.trim() || ''; + if (!nextBinaryPath) { + return; + } + + if (!existingBinaryPath) { + env[OPENCODE_RUNTIME_BINARY_PATH_ENV] = nextBinaryPath; + } + + if (!path.isAbsolute(nextBinaryPath)) { + return; + } + + // Facts: + // - The app-managed OpenCode status is resolved from the app runtime manifest. + // - Older claude-multimodel readiness inventory still resolves "opencode" through PATH. + // - Exposing the selected binary directory keeps both checks on the same runtime. + prependPathEntry(env, path.dirname(nextBinaryPath)); +} diff --git a/src/main/services/runtime/providerAwareCliEnv.ts b/src/main/services/runtime/providerAwareCliEnv.ts index d5f7b0bf..551c3a27 100644 --- a/src/main/services/runtime/providerAwareCliEnv.ts +++ b/src/main/services/runtime/providerAwareCliEnv.ts @@ -5,6 +5,7 @@ import { resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath } from '../infrastru import { ensureAgentTeamsMcpLocalLaunchEnv } from './agentTeamsMcpLaunchEnv'; import { buildRuntimeBaseEnv } from './buildRuntimeBaseEnv'; +import { applyOpenCodeRuntimeBinaryEnv } from './openCodeRuntimeBinaryEnv'; import { providerConnectionService } from './ProviderConnectionService'; import type { CliProviderId, TeamProviderId } from '@shared/types'; @@ -43,13 +44,9 @@ export async function buildProviderAwareCliEnv( shellEnv, env: options.env, }); - const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath(); - if ( - appManagedOpenCodeBinary && - !env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH && - (!resolvedProviderId || resolvedProviderId === 'opencode') - ) { - env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH = appManagedOpenCodeBinary; + if (!resolvedProviderId || resolvedProviderId === 'opencode') { + const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath(); + applyOpenCodeRuntimeBinaryEnv(env, appManagedOpenCodeBinary); } const appManagedCodexBinary = await resolveVerifiedAppManagedCodexRuntimeBinaryPath(); if ( diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 4d62ad27..57a8ce60 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -24376,6 +24376,15 @@ export class TeamProvisioningService { return Array.from(this.aliveRunByTeam.keys()).filter((name) => this.isTeamAlive(name)); } + /** + * True when shutdown has team runtime state that must not be left headless. + * Includes active leads, provisioning runs, runtime-adapter runs, secondary lanes, + * and in-flight team operations that may expose a runtime shortly. + */ + hasActiveTeamRuntimes(): boolean { + return this.getShutdownTrackedTeamNames().length > 0; + } + async getRuntimeState(teamName: string): Promise { const runId = this.getTrackedRunId(teamName); const run = runId ? (this.runs.get(runId) ?? null) : null; diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index fbbc4ff2..970773cf 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -780,7 +780,10 @@ export const MemberList = memo(function MemberList({ ) { return false; } - if (spawnEntry?.runtimeAlive === false && spawnEntry.status !== 'online') { + if (spawnEntry?.runtimeAlive === false) { + return false; + } + if (runtimeEntry?.alive === false) { return false; } if ( diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index 00a075d3..4911321e 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -797,7 +797,7 @@ function getLaunchVisualStateDotClass(visualState: MemberLaunchVisualState): str case 'starting_stale': return 'bg-amber-400'; case 'registered_only': - return SPAWN_DOT_COLORS.waiting; + return STATUS_DOT_COLORS.terminated; case 'shell_only': return 'bg-amber-400'; case 'stale_runtime': @@ -807,6 +807,38 @@ function getLaunchVisualStateDotClass(visualState: MemberLaunchVisualState): str } } +function getCurrentRuntimeOfflineVisualState( + runtimeEntry: TeamAgentRuntimeEntry | undefined, + spawnStatus: MemberSpawnStatus | undefined, + spawnLaunchState: MemberLaunchState | undefined, + spawnRuntimeAlive: boolean | undefined +): MemberLaunchVisualState { + if (runtimeEntry?.livenessKind === 'registered_only') { + return 'registered_only'; + } + if ( + runtimeEntry?.livenessKind === 'stale_metadata' || + runtimeEntry?.livenessKind === 'not_found' + ) { + return 'stale_runtime'; + } + if ( + runtimeEntry?.alive === false && + (runtimeEntry.livenessKind == null || + runtimeEntry.livenessKind === 'runtime_process' || + runtimeEntry.livenessKind === 'confirmed_bootstrap') + ) { + return 'stale_runtime'; + } + if ( + spawnRuntimeAlive === false && + (spawnStatus === 'online' || spawnLaunchState === 'confirmed_alive') + ) { + return 'stale_runtime'; + } + return null; +} + export function shouldDisplayMemberCurrentTask({ member, isTeamAlive, @@ -846,10 +878,10 @@ export function shouldDisplayMemberCurrentTask({ ) { return false; } - if (runtimeEntry?.alive === false && spawnStatus !== 'online') { + if (runtimeEntry?.alive === false) { return false; } - if (spawnRuntimeAlive === false && spawnStatus !== 'online') { + if (spawnRuntimeAlive === false) { return false; } return true; @@ -1039,13 +1071,26 @@ export function buildMemberLaunchPresentation({ leadActivity?: LeadActivityState; nowMs?: number; }): MemberLaunchPresentation { + const currentRuntimeOfflineVisualState = getCurrentRuntimeOfflineVisualState( + runtimeEntry, + spawnStatus, + spawnLaunchState, + spawnRuntimeAlive + ); const hasConfirmedSpawnLaunch = spawnLaunchState === 'confirmed_alive' && spawnBootstrapConfirmed === true; const effectiveSpawnStatus = - hasConfirmedSpawnLaunch && (spawnStatus === 'waiting' || spawnStatus === 'spawning') + hasConfirmedSpawnLaunch && + currentRuntimeOfflineVisualState == null && + (spawnStatus === 'waiting' || spawnStatus === 'spawning') ? 'online' : spawnStatus; - const effectiveSpawnRuntimeAlive = hasConfirmedSpawnLaunch ? true : spawnRuntimeAlive; + const effectiveSpawnRuntimeAlive = + currentRuntimeOfflineVisualState != null + ? false + : hasConfirmedSpawnLaunch + ? true + : spawnRuntimeAlive; const presenceLabel = getLaunchAwarePresenceLabel( member, effectiveSpawnStatus, @@ -1100,21 +1145,12 @@ export function buildMemberLaunchPresentation({ launchVisualState = 'permission_pending'; } else if (spawnBootstrapStalled === true) { launchVisualState = 'bootstrap_stalled'; - } else if (!hasConfirmedSpawnLaunch && runtimeEntry?.livenessKind === 'shell_only') { + } else if (currentRuntimeOfflineVisualState != null) { + launchVisualState = currentRuntimeOfflineVisualState; + } else if (runtimeEntry?.livenessKind === 'shell_only') { launchVisualState = 'shell_only'; - } else if ( - !hasConfirmedSpawnLaunch && - runtimeEntry?.livenessKind === 'runtime_process_candidate' - ) { + } else if (runtimeEntry?.livenessKind === 'runtime_process_candidate') { launchVisualState = 'runtime_candidate'; - } else if (!hasConfirmedSpawnLaunch && runtimeEntry?.livenessKind === 'registered_only') { - launchVisualState = 'registered_only'; - } else if ( - !hasConfirmedSpawnLaunch && - (runtimeEntry?.livenessKind === 'stale_metadata' || - runtimeEntry?.livenessKind === 'not_found') - ) { - launchVisualState = 'stale_runtime'; } else if (!hasConfirmedSpawnLaunch && startingIsStale) { launchVisualState = 'starting_stale'; } else if ( diff --git a/test/main/services/runtime/openCodeBridgeRuntimeEnv.test.ts b/test/main/services/runtime/openCodeBridgeRuntimeEnv.test.ts new file mode 100644 index 00000000..7af85235 --- /dev/null +++ b/test/main/services/runtime/openCodeBridgeRuntimeEnv.test.ts @@ -0,0 +1,77 @@ +import path from 'node:path'; + +import { describe, expect, it, vi } from 'vitest'; + +import { ensureOpenCodeBridgeRuntimeBinaryEnv } from '../../../../src/main/services/runtime/openCodeBridgeRuntimeEnv'; + +describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => { + it('makes an app-managed OpenCode binary visible to PATH-based bridge inventory', async () => { + const binaryPath = path.join(process.cwd(), 'managed opencode', 'bin', 'opencode'); + const env: NodeJS.ProcessEnv = { + PATH: ['/usr/bin', '/bin'].join(path.delimiter), + }; + + await ensureOpenCodeBridgeRuntimeBinaryEnv({ + targetEnv: env, + resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: () => Promise.resolve(binaryPath), + }); + + expect(env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath); + expect(env.PATH?.split(path.delimiter)).toEqual([ + path.dirname(binaryPath), + '/usr/bin', + '/bin', + ]); + }); + + it('recovers when managed OpenCode is installed after the bridge base env was created', async () => { + const binaryPath = path.join(process.cwd(), 'late managed opencode', 'opencode'); + const bridgeEnv: NodeJS.ProcessEnv = { + PATH: ['/usr/bin', '/bin'].join(path.delimiter), + }; + const resolver = vi.fn<() => Promise>().mockResolvedValueOnce(null); + + await ensureOpenCodeBridgeRuntimeBinaryEnv({ + targetEnv: bridgeEnv, + bridgeEnv, + resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: resolver, + }); + + expect(bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBeUndefined(); + + resolver.mockResolvedValueOnce(binaryPath); + const commandEnv = { ...bridgeEnv }; + await ensureOpenCodeBridgeRuntimeBinaryEnv({ + targetEnv: commandEnv, + bridgeEnv, + resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: resolver, + }); + + expect(commandEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath); + expect(commandEnv.PATH?.split(path.delimiter)[0]).toBe(path.dirname(binaryPath)); + expect(bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath); + expect(bridgeEnv.PATH?.split(path.delimiter)[0]).toBe(path.dirname(binaryPath)); + }); + + it('keeps bridge startup non-fatal when the managed resolver fails', async () => { + const onWarning = vi.fn(); + const env: NodeJS.ProcessEnv = { + PATH: '/usr/bin', + }; + + await expect( + ensureOpenCodeBridgeRuntimeBinaryEnv({ + targetEnv: env, + resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: () => + Promise.reject(new Error('manifest unreadable')), + onWarning, + }) + ).resolves.toBeUndefined(); + + expect(onWarning).toHaveBeenCalledWith( + '[OpenCode] Runtime adapter bundled OpenCode binary unresolved: manifest unreadable' + ); + expect(env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBeUndefined(); + expect(env.PATH).toBe('/usr/bin'); + }); +}); diff --git a/test/main/services/runtime/openCodeRuntimeBinaryEnv.test.ts b/test/main/services/runtime/openCodeRuntimeBinaryEnv.test.ts new file mode 100644 index 00000000..75e19135 --- /dev/null +++ b/test/main/services/runtime/openCodeRuntimeBinaryEnv.test.ts @@ -0,0 +1,49 @@ +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { applyOpenCodeRuntimeBinaryEnv } from '../../../../src/main/services/runtime/openCodeRuntimeBinaryEnv'; + +describe('applyOpenCodeRuntimeBinaryEnv', () => { + it('sets the OpenCode binary env var and prepends its directory to PATH', () => { + const binaryPath = path.join(process.cwd(), 'mock app data', 'opencode', 'opencode'); + const env: NodeJS.ProcessEnv = { + PATH: ['/usr/bin', '/bin'].join(path.delimiter), + }; + + applyOpenCodeRuntimeBinaryEnv(env, binaryPath); + + expect(env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath); + expect(env.PATH?.split(path.delimiter)).toEqual([ + path.dirname(binaryPath), + '/usr/bin', + '/bin', + ]); + }); + + it('keeps an explicit OpenCode binary override but still exposes it on PATH', () => { + const explicitBinaryPath = path.join(process.cwd(), 'custom opencode', 'opencode'); + const discoveredBinaryPath = path.join(process.cwd(), 'managed opencode', 'opencode'); + const env: NodeJS.ProcessEnv = { + CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH: explicitBinaryPath, + PATH: '/usr/bin', + }; + + applyOpenCodeRuntimeBinaryEnv(env, discoveredBinaryPath); + + expect(env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(explicitBinaryPath); + expect(env.PATH?.split(path.delimiter)[0]).toBe(path.dirname(explicitBinaryPath)); + }); + + it('does not duplicate the binary directory in PATH on repeated application', () => { + const binaryPath = path.join(process.cwd(), 'mock app data', 'opencode', 'opencode'); + const env: NodeJS.ProcessEnv = { + PATH: [path.dirname(binaryPath), '/usr/bin'].join(path.delimiter), + }; + + applyOpenCodeRuntimeBinaryEnv(env, binaryPath); + applyOpenCodeRuntimeBinaryEnv(env, binaryPath); + + expect(env.PATH?.split(path.delimiter)).toEqual([path.dirname(binaryPath), '/usr/bin']); + }); +}); diff --git a/test/main/services/runtime/providerAwareCliEnv.test.ts b/test/main/services/runtime/providerAwareCliEnv.test.ts index 60b12711..66355dda 100644 --- a/test/main/services/runtime/providerAwareCliEnv.test.ts +++ b/test/main/services/runtime/providerAwareCliEnv.test.ts @@ -1,4 +1,6 @@ // @vitest-environment node +import path from 'node:path'; + import { beforeEach, describe, expect, it, vi } from 'vitest'; const buildEnrichedEnvMock = vi.fn(); @@ -350,9 +352,15 @@ describe('buildProviderAwareCliEnv', () => { }); it('injects the verified app-managed OpenCode binary for OpenCode launches', async () => { - resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock.mockResolvedValue( - '/Users/tester/App Support/runtimes/opencode/current/opencode' + const appManagedBinaryPath = path.join( + process.cwd(), + 'App Support', + 'runtimes', + 'opencode', + 'current', + 'opencode' ); + resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock.mockResolvedValue(appManagedBinaryPath); const { buildProviderAwareCliEnv } = await import('../../../../src/main/services/runtime/providerAwareCliEnv'); @@ -362,15 +370,29 @@ describe('buildProviderAwareCliEnv', () => { expect(applyConfiguredConnectionEnvMock).toHaveBeenCalledWith( expect.objectContaining({ - CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH: - '/Users/tester/App Support/runtimes/opencode/current/opencode', + CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH: appManagedBinaryPath, }), 'opencode', undefined ); - expect(result.env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe( - '/Users/tester/App Support/runtimes/opencode/current/opencode' - ); + expect(result.env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(appManagedBinaryPath); + expect(result.env.PATH?.split(path.delimiter)[0]).toBe(path.dirname(appManagedBinaryPath)); + }); + + it('exposes an explicit OpenCode binary override on PATH when the app-managed resolver is cold', async () => { + const explicitBinaryPath = path.join(process.cwd(), 'custom opencode', 'opencode'); + + const { buildProviderAwareCliEnv } = + await import('../../../../src/main/services/runtime/providerAwareCliEnv'); + const result = await buildProviderAwareCliEnv({ + providerId: 'opencode', + env: { + CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH: explicitBinaryPath, + }, + }); + + expect(result.env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(explicitBinaryPath); + expect(result.env.PATH?.split(path.delimiter)[0]).toBe(path.dirname(explicitBinaryPath)); }); it('does not inject the app-managed OpenCode binary into non-OpenCode provider launches', async () => { diff --git a/test/renderer/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts index 643cf119..455a38a9 100644 --- a/test/renderer/utils/memberHelpers.test.ts +++ b/test/renderer/utils/memberHelpers.test.ts @@ -46,6 +46,16 @@ describe('memberHelpers spawn-aware presence', () => { }) ).toBe(false); + expect( + shouldDisplayMemberCurrentTask({ + member: { ...member, currentTaskId: 'task-1' }, + isTeamAlive: true, + spawnStatus: 'online', + spawnLaunchState: 'confirmed_alive', + spawnRuntimeAlive: false, + }) + ).toBe(false); + expect( shouldDisplayMemberCurrentTask({ member: { ...member, currentTaskId: 'task-1' }, @@ -73,6 +83,22 @@ describe('memberHelpers spawn-aware presence', () => { }, }) ).toBe(false); + + expect( + shouldDisplayMemberCurrentTask({ + member: { ...member, currentTaskId: 'task-1' }, + isTeamAlive: true, + spawnStatus: 'online', + spawnLaunchState: 'confirmed_alive', + runtimeEntry: { + memberName: 'alice', + alive: false, + restartable: true, + providerId: 'opencode', + updatedAt: '2026-04-24T12:00:00.000Z', + }, + }) + ).toBe(false); }); it('keeps current task labels for confirmed online members', () => { @@ -493,10 +519,10 @@ describe('memberHelpers spawn-aware presence', () => { isTeamProvisioning: false, }) ).toMatchObject({ - presenceLabel: 'online', - launchVisualState: null, - launchStatusLabel: null, - dotClass: expect.stringContaining('bg-emerald-400'), + presenceLabel: 'registered', + launchVisualState: 'registered_only', + launchStatusLabel: 'registered', + dotClass: expect.stringContaining('bg-red-400'), }); expect( @@ -521,13 +547,66 @@ describe('memberHelpers spawn-aware presence', () => { isTeamProvisioning: false, }) ).toMatchObject({ - presenceLabel: 'online', - launchVisualState: null, - launchStatusLabel: null, - dotClass: expect.stringContaining('bg-emerald-400'), + presenceLabel: 'registered', + launchVisualState: 'registered_only', + launchStatusLabel: 'registered', + dotClass: expect.stringContaining('bg-red-400'), }); }); + it('marks confirmed members offline when spawn runtime liveness is false', () => { + expect( + buildMemberLaunchPresentation({ + member, + spawnStatus: 'online', + spawnLaunchState: 'confirmed_alive', + spawnLivenessSource: 'process', + spawnRuntimeAlive: false, + spawnBootstrapConfirmed: true, + runtimeAdvisory: undefined, + isLaunchSettling: false, + isTeamAlive: true, + isTeamProvisioning: false, + }) + ).toMatchObject({ + presenceLabel: 'stale runtime', + launchVisualState: 'stale_runtime', + launchStatusLabel: 'stale runtime', + dotClass: expect.stringContaining('bg-red-400'), + }); + }); + + it('marks dead confirmed runtime entries as stale runtime', () => { + for (const livenessKind of ['runtime_process', 'confirmed_bootstrap'] as const) { + expect( + buildMemberLaunchPresentation({ + member, + spawnStatus: 'online', + spawnLaunchState: 'confirmed_alive', + spawnLivenessSource: 'process', + spawnRuntimeAlive: true, + spawnBootstrapConfirmed: true, + runtimeEntry: { + memberName: 'alice', + alive: false, + restartable: true, + livenessKind, + updatedAt: '2026-04-24T12:00:00.000Z', + }, + runtimeAdvisory: undefined, + isLaunchSettling: false, + isTeamAlive: true, + isTeamProvisioning: false, + }) + ).toMatchObject({ + presenceLabel: 'stale runtime', + launchVisualState: 'stale_runtime', + launchStatusLabel: 'stale runtime', + dotClass: expect.stringContaining('bg-red-400'), + }); + } + }); + it('marks stuck OpenCode launch states as manually relaunchable', () => { const openCodeMember: ResolvedTeamMember = { ...member, providerId: 'opencode' };