diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000000..267adf846c --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,112 @@ +import org.gradle.api.GradleException +import org.gradle.api.tasks.Sync + +plugins { + id("com.android.application") +} + +val minAndroidJavaVersion = 17 +val maxAndroidJavaVersion = 21 +val hostJavaMajorVersion = JavaVersion.current().majorVersion.toIntOrNull() + ?: throw GradleException("Unable to determine Java version from ${JavaVersion.current()}.") +if (hostJavaMajorVersion < minAndroidJavaVersion) { + throw GradleException( + "Android service build requires Java ${minAndroidJavaVersion}+ (tested through Java ${maxAndroidJavaVersion}). Found Java ${hostJavaMajorVersion}." + ) +} +val androidJavaTargetVersion = hostJavaMajorVersion.coerceAtMost(maxAndroidJavaVersion) +val androidJavaVersion = JavaVersion.toVersion(androidJavaTargetVersion) + +android { + namespace = "com.openai.codexd" + compileSdk = 34 + + defaultConfig { + applicationId = "com.openai.codexd" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "0.1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = androidJavaVersion + targetCompatibility = androidJavaVersion + } + + packaging { + jniLibs.useLegacyPackaging = true + } +} + +val repoRoot = rootProject.projectDir.parentFile +val agentPlatformStubSdkZip = providers + .gradleProperty("agentPlatformStubSdkZip") + .orElse(providers.environmentVariable("ANDROID_AGENT_PLATFORM_STUB_SDK_ZIP")) +val extractedAgentPlatformJar = layout.buildDirectory.file( + "generated/agent-platform/android-agent-platform-stub-sdk.jar" +) +val codexdTargets = mapOf( + "arm64-v8a" to "aarch64-linux-android", + "x86_64" to "x86_64-linux-android", +) +val codexdJniDir = layout.buildDirectory.dir("generated/codexd-jni") + +val extractAgentPlatformStubSdk = tasks.register("extractAgentPlatformStubSdk") { + val sdkZip = agentPlatformStubSdkZip.orNull + ?: throw GradleException( + "Set ANDROID_AGENT_PLATFORM_STUB_SDK_ZIP or -PagentPlatformStubSdkZip to the Android Agent Platform stub SDK zip." + ) + val outputDir = extractedAgentPlatformJar.get().asFile.parentFile + from(zipTree(sdkZip)) { + include("payloads/compile_only/android-agent-platform-stub-sdk.jar") + eachFile { path = name } + includeEmptyDirs = false + } + into(outputDir) +} + +val syncCodexdJniLibs = tasks.register("syncCodexdJniLibs") { + val outputDir = codexdJniDir + into(outputDir) + + codexdTargets.forEach { (abi, triple) -> + val binary = file("${repoRoot}/codex-rs/target/android/${triple}/release/codexd") + from(binary) { + into(abi) + rename { "libcodexd.so" } + } + } + + doFirst { + codexdTargets.forEach { (abi, triple) -> + val binary = file("${repoRoot}/codex-rs/target/android/${triple}/release/codexd") + if (!binary.exists()) { + throw GradleException( + "Missing codexd binary for ${abi} at ${binary}. Run `just android-service-build` from the repo root." + ) + } + } + } +} + +android.sourceSets["main"].jniLibs.srcDir(codexdJniDir.get().asFile) + +tasks.named("preBuild").configure { + dependsOn(syncCodexdJniLibs) + dependsOn(extractAgentPlatformStubSdk) +} + +dependencies { + compileOnly(files(extractedAgentPlatformJar)) +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..a93dedf393 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/openai/codexd/AgentSessionController.kt b/android/app/src/main/java/com/openai/codexd/AgentSessionController.kt new file mode 100644 index 0000000000..4176542087 --- /dev/null +++ b/android/app/src/main/java/com/openai/codexd/AgentSessionController.kt @@ -0,0 +1,295 @@ +package com.openai.codexd + +import android.app.agent.AgentManager +import android.app.agent.AgentSessionEvent +import android.app.agent.AgentSessionInfo +import android.content.Context +import android.os.Binder +import android.os.Process +import java.util.concurrent.Executor + +class AgentSessionController(context: Context) { + companion object { + private const val PREFERRED_GENIE_PACKAGE = "com.openai.codex.genie" + } + + private val agentManager = context.getSystemService(AgentManager::class.java) + + fun isAvailable(): Boolean = agentManager != null + + fun registerSessionListener( + executor: Executor, + listener: AgentManager.SessionListener, + ): Boolean { + val manager = agentManager ?: return false + manager.registerSessionListener(currentUserId(), executor, listener) + return true + } + + fun unregisterSessionListener(listener: AgentManager.SessionListener) { + agentManager?.unregisterSessionListener(listener) + } + + fun registerSessionUiLease(parentSessionId: String, token: Binder) { + agentManager?.registerSessionUiLease(parentSessionId, token) + } + + fun unregisterSessionUiLease(parentSessionId: String, token: Binder) { + agentManager?.unregisterSessionUiLease(parentSessionId, token) + } + + fun loadSnapshot(focusedSessionId: String?): AgentSnapshot { + val manager = agentManager ?: return AgentSnapshot.unavailable + val roleHolders = manager.getGenieRoleHolders(currentUserId()) + val selectedGeniePackage = selectGeniePackage(roleHolders) + val sessionDetails = manager.getSessions(currentUserId()).map { session -> + val events = manager.getSessionEvents(session.sessionId) + AgentSessionDetails( + sessionId = session.sessionId, + parentSessionId = session.parentSessionId, + targetPackage = session.targetPackage, + anchor = session.anchor, + state = session.state, + stateLabel = stateToString(session.state), + targetDetached = session.isTargetDetached, + latestQuestion = findLastEventMessage(events, AgentSessionEvent.TYPE_QUESTION), + latestResult = findLastEventMessage(events, AgentSessionEvent.TYPE_RESULT), + latestError = findLastEventMessage(events, AgentSessionEvent.TYPE_ERROR), + latestTrace = findLastEventMessage(events, AgentSessionEvent.TYPE_TRACE), + timeline = renderTimeline(events), + ) + } + val selectedSession = chooseSelectedSession(sessionDetails, focusedSessionId) + val parentSession = findParentSession(sessionDetails, selectedSession) + val relatedSessions = if (parentSession == null) { + selectedSession?.let(::listOf) ?: emptyList() + } else { + sessionDetails.filter { session -> + session.sessionId == parentSession.sessionId || + session.parentSessionId == parentSession.sessionId + }.sortedWith(compareBy { it.parentSessionId != null }.thenBy { it.sessionId }) + } + return AgentSnapshot( + available = true, + roleHolders = roleHolders, + selectedGeniePackage = selectedGeniePackage, + sessions = sessionDetails, + selectedSession = selectedSession, + parentSession = parentSession, + relatedSessions = relatedSessions, + ) + } + + fun startDirectSession( + targetPackage: String, + prompt: String, + allowDetachedMode: Boolean, + ): SessionStartResult { + val manager = requireAgentManager() + val geniePackage = selectGeniePackage(manager.getGenieRoleHolders(currentUserId())) + ?: throw IllegalStateException("No GENIE role holder configured") + val parentSession = manager.createDirectSession(currentUserId()) + val childSessionIds = mutableListOf() + try { + manager.publishTrace( + parentSession.sessionId, + "Starting Codex direct session for $targetPackage.", + ) + val childSession = manager.createChildSession(parentSession.sessionId, targetPackage) + childSessionIds += childSession.sessionId + manager.publishTrace( + parentSession.sessionId, + "Created child session ${childSession.sessionId} for $targetPackage.", + ) + manager.startGenieSession( + childSession.sessionId, + geniePackage, + prompt, + allowDetachedMode, + ) + return SessionStartResult( + parentSessionId = parentSession.sessionId, + childSessionId = childSession.sessionId, + geniePackage = geniePackage, + ) + } catch (err: RuntimeException) { + childSessionIds.forEach { childSessionId -> + runCatching { manager.cancelSession(childSessionId) } + } + runCatching { manager.cancelSession(parentSession.sessionId) } + throw err + } + } + + fun answerQuestion(sessionId: String, answer: String, parentSessionId: String?) { + val manager = requireAgentManager() + manager.answerQuestion(sessionId, answer) + if (parentSessionId != null) { + manager.publishTrace(parentSessionId, "Answered question for $sessionId: $answer") + } + } + + fun attachTarget(sessionId: String) { + requireAgentManager().attachTarget(sessionId) + } + + fun cancelSession(sessionId: String) { + requireAgentManager().cancelSession(sessionId) + } + + private fun requireAgentManager(): AgentManager { + return checkNotNull(agentManager) { "AgentManager unavailable" } + } + + private fun chooseSelectedSession( + sessions: List, + focusedSessionId: String?, + ): AgentSessionDetails? { + val sessionsById = sessions.associateBy(AgentSessionDetails::sessionId) + val focusedSession = focusedSessionId?.let(sessionsById::get) + if (focusedSession != null) { + if (focusedSession.parentSessionId != null) { + return focusedSession + } + val childCandidate = sessions.firstOrNull { session -> + session.parentSessionId == focusedSession.sessionId && + session.state == AgentSessionInfo.STATE_WAITING_FOR_USER + } ?: sessions.firstOrNull { session -> + session.parentSessionId == focusedSession.sessionId && + !isTerminalState(session.state) + } + return childCandidate ?: focusedSession + } + return sessions.firstOrNull { session -> + session.parentSessionId != null && + session.state == AgentSessionInfo.STATE_WAITING_FOR_USER + } ?: sessions.firstOrNull { session -> + session.parentSessionId != null && !isTerminalState(session.state) + } ?: sessions.firstOrNull(::isDirectParentSession) ?: sessions.firstOrNull() + } + + private fun findParentSession( + sessions: List, + selectedSession: AgentSessionDetails?, + ): AgentSessionDetails? { + if (selectedSession == null) { + return null + } + if (selectedSession.parentSessionId == null) { + return if (isDirectParentSession(selectedSession)) { + selectedSession + } else { + null + } + } + return sessions.firstOrNull { it.sessionId == selectedSession.parentSessionId } + } + + private fun selectGeniePackage(roleHolders: List): String? { + return when { + roleHolders.contains(PREFERRED_GENIE_PACKAGE) -> PREFERRED_GENIE_PACKAGE + else -> roleHolders.firstOrNull() + } + } + + private fun findLastEventMessage(events: List, type: Int): String? { + for (index in events.indices.reversed()) { + val event = events[index] + if (event.type == type && event.message != null) { + return event.message + } + } + return null + } + + private fun renderTimeline(events: List): String { + if (events.isEmpty()) { + return "No framework events yet." + } + return events.joinToString("\n") { event -> + "${eventTypeToString(event.type)}: ${event.message ?: ""}" + } + } + + private fun eventTypeToString(type: Int): String { + return when (type) { + AgentSessionEvent.TYPE_TRACE -> "Trace" + AgentSessionEvent.TYPE_QUESTION -> "Question" + AgentSessionEvent.TYPE_RESULT -> "Result" + AgentSessionEvent.TYPE_ERROR -> "Error" + AgentSessionEvent.TYPE_POLICY -> "Policy" + AgentSessionEvent.TYPE_ANSWER -> "Answer" + else -> "Event($type)" + } + } + + private fun isDirectParentSession(session: AgentSessionDetails): Boolean { + return session.anchor == AgentSessionInfo.ANCHOR_AGENT && + session.parentSessionId == null && + session.targetPackage == null + } + + private fun isTerminalState(state: Int): Boolean { + return state == AgentSessionInfo.STATE_COMPLETED || + state == AgentSessionInfo.STATE_CANCELLED || + state == AgentSessionInfo.STATE_FAILED + } + + private fun stateToString(state: Int): String { + return when (state) { + AgentSessionInfo.STATE_CREATED -> "CREATED" + AgentSessionInfo.STATE_RUNNING -> "RUNNING" + AgentSessionInfo.STATE_WAITING_FOR_USER -> "WAITING_FOR_USER" + AgentSessionInfo.STATE_QUEUED -> "QUEUED" + AgentSessionInfo.STATE_COMPLETED -> "COMPLETED" + AgentSessionInfo.STATE_CANCELLED -> "CANCELLED" + AgentSessionInfo.STATE_FAILED -> "FAILED" + else -> state.toString() + } + } + + private fun currentUserId(): Int = Process.myUid() / 100000 +} + +data class AgentSnapshot( + val available: Boolean, + val roleHolders: List, + val selectedGeniePackage: String?, + val sessions: List, + val selectedSession: AgentSessionDetails?, + val parentSession: AgentSessionDetails?, + val relatedSessions: List, +) { + companion object { + val unavailable = AgentSnapshot( + available = false, + roleHolders = emptyList(), + selectedGeniePackage = null, + sessions = emptyList(), + selectedSession = null, + parentSession = null, + relatedSessions = emptyList(), + ) + } +} + +data class AgentSessionDetails( + val sessionId: String, + val parentSessionId: String?, + val targetPackage: String?, + val anchor: Int, + val state: Int, + val stateLabel: String, + val targetDetached: Boolean, + val latestQuestion: String?, + val latestResult: String?, + val latestError: String?, + val latestTrace: String?, + val timeline: String, +) + +data class SessionStartResult( + val parentSessionId: String, + val childSessionId: String, + val geniePackage: String, +) diff --git a/android/app/src/main/java/com/openai/codexd/CodexAgentService.kt b/android/app/src/main/java/com/openai/codexd/CodexAgentService.kt new file mode 100644 index 0000000000..f2539eaf4e --- /dev/null +++ b/android/app/src/main/java/com/openai/codexd/CodexAgentService.kt @@ -0,0 +1,19 @@ +package com.openai.codexd + +import android.app.agent.AgentService +import android.app.agent.AgentSessionInfo +import android.util.Log + +class CodexAgentService : AgentService() { + companion object { + private const val TAG = "CodexAgentService" + } + + override fun onSessionChanged(session: AgentSessionInfo) { + Log.i(TAG, "onSessionChanged $session") + } + + override fun onSessionRemoved(sessionId: String) { + Log.i(TAG, "onSessionRemoved sessionId=$sessionId") + } +} diff --git a/android/app/src/main/java/com/openai/codexd/MainActivity.kt b/android/app/src/main/java/com/openai/codexd/MainActivity.kt new file mode 100644 index 0000000000..757c1a15a5 --- /dev/null +++ b/android/app/src/main/java/com/openai/codexd/MainActivity.kt @@ -0,0 +1,836 @@ +package com.openai.codexd + +import android.Manifest +import android.app.Activity +import android.app.agent.AgentManager +import android.app.agent.AgentSessionInfo +import android.content.Intent +import android.content.pm.PackageManager +import android.net.LocalSocket +import android.net.LocalSocketAddress +import android.os.Binder +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.View +import android.widget.Button +import android.widget.EditText +import android.widget.TableLayout +import android.widget.TableRow +import android.widget.TextView +import android.widget.Toast +import org.json.JSONArray +import org.json.JSONObject +import java.io.BufferedInputStream +import java.io.File +import java.io.IOException +import java.nio.charset.StandardCharsets +import java.util.Locale +import kotlin.concurrent.thread + +class MainActivity : Activity() { + companion object { + private const val STATUS_REFRESH_INTERVAL_MS = 2000L + } + + @Volatile + private var isAuthenticated = false + @Volatile + private var isServiceRunning = false + @Volatile + private var statusRefreshInFlight = false + @Volatile + private var agentRefreshInFlight = false + + private val refreshHandler = Handler(Looper.getMainLooper()) + private val agentSessionController by lazy { AgentSessionController(this) } + private val sessionUiLeaseToken = Binder() + private val refreshRunnable = object : Runnable { + override fun run() { + refreshAuthStatus() + refreshAgentSessions() + refreshHandler.postDelayed(this, STATUS_REFRESH_INTERVAL_MS) + } + } + private val sessionListener = object : AgentManager.SessionListener { + override fun onSessionChanged(session: AgentSessionInfo) { + if (focusedFrameworkSessionId == null && session.parentSessionId != null) { + focusedFrameworkSessionId = session.sessionId + } + refreshAgentSessions() + } + + override fun onSessionRemoved(sessionId: String, userId: Int) { + if (focusedFrameworkSessionId == sessionId) { + focusedFrameworkSessionId = null + } + refreshAgentSessions() + } + } + + private var sessionListenerRegistered = false + private var focusedFrameworkSessionId: String? = null + private var leasedParentSessionId: String? = null + private var latestAgentSnapshot: AgentSnapshot = AgentSnapshot.unavailable + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + updatePaths() + handleSessionIntent(intent) + requestNotificationPermissionIfNeeded() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleSessionIntent(intent) + refreshAgentSessions() + } + + override fun onResume() { + super.onResume() + registerSessionListenerIfNeeded() + refreshHandler.removeCallbacks(refreshRunnable) + refreshHandler.post(refreshRunnable) + } + + override fun onPause() { + refreshHandler.removeCallbacks(refreshRunnable) + unregisterSessionListenerIfNeeded() + updateSessionUiLease(null) + super.onPause() + } + + private fun updatePaths() { + findViewById(R.id.socket_path).text = defaultSocketPath() + findViewById(R.id.codex_home).text = defaultCodexHome() + isServiceRunning = false + updateAuthUi("Auth status: unknown", false, null, emptyList()) + updateAgentUi(AgentSnapshot.unavailable) + } + + private fun handleSessionIntent(intent: Intent?) { + val sessionId = intent?.getStringExtra(AgentManager.EXTRA_SESSION_ID) + if (!sessionId.isNullOrEmpty()) { + focusedFrameworkSessionId = sessionId + } + } + + private fun registerSessionListenerIfNeeded() { + if (sessionListenerRegistered || !agentSessionController.isAvailable()) { + return + } + sessionListenerRegistered = runCatching { + agentSessionController.registerSessionListener(mainExecutor, sessionListener) + }.getOrDefault(false) + } + + private fun unregisterSessionListenerIfNeeded() { + if (!sessionListenerRegistered) { + return + } + runCatching { agentSessionController.unregisterSessionListener(sessionListener) } + sessionListenerRegistered = false + } + + private fun updateSessionUiLease(parentSessionId: String?) { + if (leasedParentSessionId == parentSessionId) { + return + } + val previousParentSessionId = leasedParentSessionId + if (previousParentSessionId != null) { + runCatching { + agentSessionController.unregisterSessionUiLease(previousParentSessionId, sessionUiLeaseToken) + } + leasedParentSessionId = null + } + if (parentSessionId != null) { + val registered = runCatching { + agentSessionController.registerSessionUiLease(parentSessionId, sessionUiLeaseToken) + } + if (registered.isSuccess) { + leasedParentSessionId = parentSessionId + } + } + } + + private fun requestNotificationPermissionIfNeeded() { + if (Build.VERSION.SDK_INT < 33) { + return + } + if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED + ) { + return + } + requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1001) + } + + fun startDirectAgentSession(@Suppress("UNUSED_PARAMETER") view: View) { + val targetPackage = findViewById(R.id.agent_target_package).text.toString().trim() + val prompt = findViewById(R.id.agent_prompt).text.toString().trim() + if (targetPackage.isEmpty()) { + showToast("Enter a target package") + return + } + if (prompt.isEmpty()) { + showToast("Enter a prompt") + return + } + thread { + val result = runCatching { + agentSessionController.startDirectSession( + targetPackage = targetPackage, + prompt = prompt, + allowDetachedMode = true, + ) + } + result.onFailure { err -> + showToast("Failed to start Agent session: ${err.message}") + refreshAgentSessions() + } + result.onSuccess { sessionStart -> + focusedFrameworkSessionId = sessionStart.childSessionId + showToast("Started ${sessionStart.childSessionId} via ${sessionStart.geniePackage}") + refreshAgentSessions() + } + } + } + + fun refreshAgentSessionAction(@Suppress("UNUSED_PARAMETER") view: View) { + refreshAgentSessions(force = true) + } + + fun answerAgentQuestion(@Suppress("UNUSED_PARAMETER") view: View) { + val selectedSession = latestAgentSnapshot.selectedSession + if (selectedSession == null) { + showToast("No active Genie session selected") + return + } + val answerInput = findViewById(R.id.agent_answer_input) + val answer = answerInput.text.toString().trim() + if (answer.isEmpty()) { + showToast("Enter an answer") + return + } + thread { + val result = runCatching { + agentSessionController.answerQuestion( + selectedSession.sessionId, + answer, + latestAgentSnapshot.parentSession?.sessionId, + ) + } + result.onFailure { err -> + showToast("Failed to answer question: ${err.message}") + } + result.onSuccess { + answerInput.post { answerInput.text.clear() } + showToast("Answered ${selectedSession.sessionId}") + refreshAgentSessions(force = true) + } + } + } + + fun attachAgentTarget(@Suppress("UNUSED_PARAMETER") view: View) { + val selectedSession = latestAgentSnapshot.selectedSession + if (selectedSession == null) { + showToast("No detached target available") + return + } + thread { + val result = runCatching { + agentSessionController.attachTarget(selectedSession.sessionId) + } + result.onFailure { err -> + showToast("Failed to attach target: ${err.message}") + } + result.onSuccess { + showToast("Attached target for ${selectedSession.sessionId}") + refreshAgentSessions(force = true) + } + } + } + + fun cancelAgentSession(@Suppress("UNUSED_PARAMETER") view: View) { + val selectedSession = latestAgentSnapshot.selectedSession + if (selectedSession == null) { + showToast("No framework session selected") + return + } + val sessionIdToCancel = latestAgentSnapshot.parentSession?.sessionId ?: selectedSession.sessionId + thread { + val result = runCatching { + agentSessionController.cancelSession(sessionIdToCancel) + } + result.onFailure { err -> + showToast("Failed to cancel session: ${err.message}") + } + result.onSuccess { + if (focusedFrameworkSessionId == selectedSession.sessionId) { + focusedFrameworkSessionId = null + } + showToast("Cancelled $sessionIdToCancel") + refreshAgentSessions(force = true) + } + } + } + + fun toggleCodexd(@Suppress("UNUSED_PARAMETER") view: View) { + val intent = Intent(this, CodexdForegroundService::class.java) + if (isServiceRunning) { + intent.action = CodexdForegroundService.ACTION_STOP + startService(intent) + isServiceRunning = false + updateAuthUi("Auth status: stopping service...", false, 0, emptyList()) + return + } + + intent.action = CodexdForegroundService.ACTION_START + startForegroundService(intent) + isServiceRunning = true + updateAuthUi("Auth status: starting service...", isAuthenticated, null, emptyList()) + refreshAuthStatus() + } + + fun authAction(@Suppress("UNUSED_PARAMETER") view: View) { + if (isAuthenticated) { + startSignOut() + } else { + startDeviceAuth() + } + } + + private fun startDeviceAuth() { + val intent = Intent(this, CodexdForegroundService::class.java).apply { + action = CodexdForegroundService.ACTION_START + } + startForegroundService(intent) + isServiceRunning = true + updateAuthUi("Auth status: requesting device code...", false, null, emptyList()) + thread { + val socketPath = defaultSocketPath() + val response = runCatching { postDeviceAuthWithRetry(socketPath) } + response.onFailure { err -> + isServiceRunning = false + updateAuthUi("Auth status: failed (${err.message})", false, null, emptyList()) + } + response.onSuccess { deviceResponse -> + when (deviceResponse.status) { + "already_authenticated" -> { + updateAuthUi("Auth status: already authenticated", true, null, emptyList()) + showToast("Already signed in") + } + "pending", "in_progress" -> { + val url = deviceResponse.verificationUrl.orEmpty() + val code = deviceResponse.userCode.orEmpty() + updateAuthUi( + "Auth status: open $url and enter code $code", + false, + null, + emptyList(), + ) + pollForAuthSuccess(socketPath) + } + else -> updateAuthUi( + "Auth status: ${deviceResponse.status}", + false, + null, + emptyList(), + ) + } + } + } + } + + private fun startSignOut() { + updateAuthUi("Auth status: signing out...", false, null, emptyList()) + thread { + val socketPath = defaultSocketPath() + val result = runCatching { postLogoutWithRetry(socketPath) } + result.onFailure { err -> + updateAuthUi( + "Auth status: sign out failed (${err.message})", + false, + null, + emptyList(), + ) + } + result.onSuccess { + showToast("Signed out") + refreshAuthStatus() + } + } + } + + private fun refreshAuthStatus() { + if (statusRefreshInFlight) { + return + } + statusRefreshInFlight = true + thread { + val socketPath = defaultSocketPath() + val result = runCatching { fetchAuthStatusWithRetry(socketPath) } + result.onFailure { err -> + isServiceRunning = false + updateAuthUi( + "Auth status: codexd stopped or unavailable (${err.message})", + false, + null, + emptyList(), + ) + } + result.onSuccess { status -> + isServiceRunning = true + val message = if (status.authenticated) { + val emailSuffix = status.accountEmail?.let { " ($it)" } ?: "" + "Auth status: signed in$emailSuffix" + } else { + "Auth status: not signed in" + } + updateAuthUi(message, status.authenticated, status.clientCount, status.clients) + } + statusRefreshInFlight = false + } + } + + private fun refreshAgentSessions(force: Boolean = false) { + if (!force && agentRefreshInFlight) { + return + } + agentRefreshInFlight = true + thread { + val result = runCatching { agentSessionController.loadSnapshot(focusedFrameworkSessionId) } + result.onFailure { err -> + latestAgentSnapshot = AgentSnapshot.unavailable + runOnUiThread { + updateAgentUi(AgentSnapshot.unavailable, err.message) + } + } + result.onSuccess { snapshot -> + latestAgentSnapshot = snapshot + focusedFrameworkSessionId = snapshot.selectedSession?.sessionId ?: focusedFrameworkSessionId + updateAgentUi(snapshot) + } + agentRefreshInFlight = false + } + } + + private fun pollForAuthSuccess(socketPath: String) { + val deadline = System.currentTimeMillis() + 15 * 60 * 1000 + while (System.currentTimeMillis() < deadline) { + val status = runCatching { fetchAuthStatusWithRetry(socketPath) }.getOrNull() + if (status?.authenticated == true) { + val emailSuffix = status.accountEmail?.let { " ($it)" } ?: "" + updateAuthUi( + "Auth status: signed in$emailSuffix", + true, + status.clientCount, + status.clients, + ) + showToast("Signed in") + return + } + Thread.sleep(3000) + } + } + + private fun updateAgentUi(snapshot: AgentSnapshot, unavailableMessage: String? = null) { + runOnUiThread { + val statusView = findViewById(R.id.agent_status) + val genieView = findViewById(R.id.agent_genie_package) + val focusView = findViewById(R.id.agent_session_focus) + val groupView = findViewById(R.id.agent_session_group) + val questionLabel = findViewById(R.id.agent_question_label) + val questionView = findViewById(R.id.agent_question) + val answerInput = findViewById(R.id.agent_answer_input) + val answerButton = findViewById