mirror of
https://github.com/openai/codex.git
synced 2026-04-25 23:24:55 +00:00
Drop the obsolete direct AIDL Agent bridge and abstract local-socket bridge, simplify the Android manifests to match the working framework session bridge path, and stop labeling codexd as legacy in the UI while it still provides auth and fallback transport. Co-authored-by: Codex <noreply@openai.com>
992 lines
39 KiB
Kotlin
992 lines
39 KiB
Kotlin
package com.openai.codexd
|
|
|
|
import android.Manifest
|
|
import android.app.Activity
|
|
import android.app.agent.AgentManager
|
|
import android.app.agent.AgentSessionInfo
|
|
import android.content.BroadcastReceiver
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.content.IntentFilter
|
|
import android.content.pm.PackageManager
|
|
import android.net.LocalSocket
|
|
import android.os.Binder
|
|
import android.os.Build
|
|
import android.os.Bundle
|
|
import android.util.Log
|
|
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 TAG = "CodexMainActivity"
|
|
private const val ACTION_DEBUG_START_AGENT_SESSION =
|
|
"com.openai.codexd.action.DEBUG_START_AGENT_SESSION"
|
|
private const val ACTION_DEBUG_CANCEL_ALL_AGENT_SESSIONS =
|
|
"com.openai.codexd.action.DEBUG_CANCEL_ALL_AGENT_SESSIONS"
|
|
private const val EXTRA_DEBUG_PROMPT = "prompt"
|
|
private const val EXTRA_DEBUG_TARGET_PACKAGE = "targetPackage"
|
|
}
|
|
|
|
@Volatile
|
|
private var isAuthenticated = false
|
|
@Volatile
|
|
private var isServiceRunning = false
|
|
@Volatile
|
|
private var statusRefreshInFlight = false
|
|
@Volatile
|
|
private var agentRefreshInFlight = false
|
|
@Volatile
|
|
private var latestAgentRuntimeStatus: AgentCodexAppServerClient.RuntimeStatus? = null
|
|
|
|
private val agentSessionController by lazy { AgentSessionController(this) }
|
|
private val sessionUiLeaseToken = Binder()
|
|
private val runtimeStatusListener = AgentCodexAppServerClient.RuntimeStatusListener { status ->
|
|
latestAgentRuntimeStatus = status
|
|
runOnUiThread {
|
|
findViewById<TextView>(R.id.agent_runtime_status).text = renderAgentRuntimeStatus()
|
|
}
|
|
}
|
|
private val authStatusReceiver = object : BroadcastReceiver() {
|
|
override fun onReceive(
|
|
context: Context?,
|
|
intent: Intent?,
|
|
) {
|
|
if (intent?.action == CodexdForegroundService.ACTION_AUTH_STATE_CHANGED) {
|
|
refreshAuthStatus()
|
|
}
|
|
}
|
|
}
|
|
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 authStatusReceiverRegistered = 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)
|
|
Log.i(TAG, "onNewIntent action=${intent.action}")
|
|
setIntent(intent)
|
|
handleSessionIntent(intent)
|
|
refreshAgentSessions()
|
|
}
|
|
|
|
override fun onResume() {
|
|
super.onResume()
|
|
handleSessionIntent(intent)
|
|
registerSessionListenerIfNeeded()
|
|
registerAuthStatusReceiverIfNeeded()
|
|
AgentCodexAppServerClient.registerRuntimeStatusListener(runtimeStatusListener)
|
|
AgentCodexAppServerClient.refreshRuntimeStatusAsync(this)
|
|
refreshAuthStatus()
|
|
refreshAgentSessions(force = true)
|
|
}
|
|
|
|
override fun onPause() {
|
|
AgentCodexAppServerClient.unregisterRuntimeStatusListener(runtimeStatusListener)
|
|
unregisterAuthStatusReceiverIfNeeded()
|
|
unregisterSessionListenerIfNeeded()
|
|
updateSessionUiLease(null)
|
|
super.onPause()
|
|
}
|
|
|
|
private fun updatePaths() {
|
|
findViewById<TextView>(R.id.socket_path).text = defaultSocketPath()
|
|
findViewById<TextView>(R.id.codex_home).text = defaultCodexHome()
|
|
isServiceRunning = false
|
|
latestAgentRuntimeStatus = AgentCodexAppServerClient.currentRuntimeStatus()
|
|
updateAuthUi("Codexd 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
|
|
}
|
|
maybeStartSessionFromIntent(intent)
|
|
}
|
|
|
|
private fun maybeStartSessionFromIntent(intent: Intent?) {
|
|
if (intent?.action == ACTION_DEBUG_CANCEL_ALL_AGENT_SESSIONS) {
|
|
Log.i(TAG, "Handling debug cancel-all Agent sessions intent")
|
|
thread {
|
|
val result = runCatching { agentSessionController.cancelActiveSessions() }
|
|
result.onFailure { err ->
|
|
Log.w(TAG, "Failed to cancel Agent sessions from debug intent", err)
|
|
showToast("Failed to cancel active sessions: ${err.message}")
|
|
}
|
|
result.onSuccess { cancelResult ->
|
|
focusedFrameworkSessionId = null
|
|
val cancelledCount = cancelResult.cancelledSessionIds.size
|
|
val failedCount = cancelResult.failedSessionIds.size
|
|
showToast("Cancelled $cancelledCount sessions, $failedCount failed")
|
|
refreshAgentSessions(force = true)
|
|
}
|
|
}
|
|
intent.action = null
|
|
return
|
|
}
|
|
if (intent?.action != ACTION_DEBUG_START_AGENT_SESSION) {
|
|
return
|
|
}
|
|
val prompt = intent.getStringExtra(EXTRA_DEBUG_PROMPT)?.trim().orEmpty()
|
|
if (prompt.isEmpty()) {
|
|
Log.w(TAG, "Ignoring debug start intent without prompt")
|
|
intent.action = null
|
|
return
|
|
}
|
|
val targetPackageOverride = intent.getStringExtra(EXTRA_DEBUG_TARGET_PACKAGE)?.trim()
|
|
findViewById<EditText>(R.id.agent_prompt).setText(prompt)
|
|
findViewById<EditText>(R.id.agent_target_package).setText(targetPackageOverride.orEmpty())
|
|
Log.i(TAG, "Handling debug start intent override=$targetPackageOverride prompt=${prompt.take(160)}")
|
|
startDirectAgentSession(findViewById(R.id.agent_start_button))
|
|
intent.action = null
|
|
}
|
|
|
|
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 registerAuthStatusReceiverIfNeeded() {
|
|
if (authStatusReceiverRegistered) {
|
|
return
|
|
}
|
|
val filter = IntentFilter(CodexdForegroundService.ACTION_AUTH_STATE_CHANGED)
|
|
if (Build.VERSION.SDK_INT >= 33) {
|
|
registerReceiver(authStatusReceiver, filter, RECEIVER_NOT_EXPORTED)
|
|
} else {
|
|
@Suppress("DEPRECATION")
|
|
registerReceiver(authStatusReceiver, filter)
|
|
}
|
|
authStatusReceiverRegistered = true
|
|
}
|
|
|
|
private fun unregisterAuthStatusReceiverIfNeeded() {
|
|
if (!authStatusReceiverRegistered) {
|
|
return
|
|
}
|
|
unregisterReceiver(authStatusReceiver)
|
|
authStatusReceiverRegistered = 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 targetPackageOverride = findViewById<EditText>(R.id.agent_target_package).text.toString().trim()
|
|
val prompt = findViewById<EditText>(R.id.agent_prompt).text.toString().trim()
|
|
if (prompt.isEmpty()) {
|
|
showToast("Enter a prompt")
|
|
return
|
|
}
|
|
Log.i(TAG, "startDirectAgentSession override=$targetPackageOverride prompt=${prompt.take(160)}")
|
|
thread {
|
|
val result = runCatching {
|
|
AgentTaskPlanner.startSession(
|
|
context = this,
|
|
userObjective = prompt,
|
|
targetPackageOverride = targetPackageOverride.ifBlank { null },
|
|
allowDetachedMode = true,
|
|
sessionController = agentSessionController,
|
|
requestUserInputHandler = { questions ->
|
|
AgentUserInputPrompter.promptForAnswers(this, questions)
|
|
},
|
|
)
|
|
}
|
|
result.onFailure { err ->
|
|
Log.w(TAG, "Failed to start Agent session", err)
|
|
showToast("Failed to start Agent session: ${err.message}")
|
|
refreshAgentSessions()
|
|
}
|
|
result.onSuccess { sessionStart ->
|
|
Log.i(
|
|
TAG,
|
|
"Started Agent session parent=${sessionStart.parentSessionId} children=${sessionStart.childSessionIds}",
|
|
)
|
|
focusedFrameworkSessionId = sessionStart.childSessionIds.firstOrNull()
|
|
val targetSummary = sessionStart.plannedTargets.joinToString(", ")
|
|
showToast("Started ${sessionStart.childSessionIds.size} Genie session(s) for $targetSummary via ${sessionStart.geniePackage}")
|
|
refreshAgentSessions()
|
|
}
|
|
}
|
|
}
|
|
|
|
fun refreshAgentSessionAction(@Suppress("UNUSED_PARAMETER") view: View) {
|
|
refreshAgentSessions(force = true)
|
|
}
|
|
|
|
fun cancelAllAgentSessions(@Suppress("UNUSED_PARAMETER") view: View) {
|
|
thread {
|
|
val result = runCatching { agentSessionController.cancelActiveSessions() }
|
|
result.onFailure { err ->
|
|
showToast("Failed to cancel active sessions: ${err.message}")
|
|
}
|
|
result.onSuccess { cancelResult ->
|
|
focusedFrameworkSessionId = null
|
|
val cancelledCount = cancelResult.cancelledSessionIds.size
|
|
val failedCount = cancelResult.failedSessionIds.size
|
|
if (cancelledCount == 0 && failedCount == 0) {
|
|
showToast("No active framework sessions")
|
|
} else if (failedCount == 0) {
|
|
showToast("Cancelled $cancelledCount active sessions")
|
|
} else {
|
|
showToast("Cancelled $cancelledCount sessions, $failedCount failed")
|
|
}
|
|
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<EditText>(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).apply {
|
|
putExtra(CodexdForegroundService.EXTRA_SOCKET_PATH, defaultSocketPath())
|
|
putExtra(CodexdForegroundService.EXTRA_CODEX_HOME, defaultCodexHome())
|
|
}
|
|
if (isServiceRunning) {
|
|
intent.action = CodexdForegroundService.ACTION_STOP
|
|
startService(intent)
|
|
isServiceRunning = false
|
|
updateAuthUi("Codexd status: stopping service...", false, 0, emptyList())
|
|
AgentCodexAppServerClient.refreshRuntimeStatusAsync(this)
|
|
return
|
|
}
|
|
|
|
intent.action = CodexdForegroundService.ACTION_START
|
|
startForegroundService(intent)
|
|
isServiceRunning = true
|
|
updateAuthUi("Codexd status: starting service...", isAuthenticated, null, emptyList())
|
|
refreshAuthStatus()
|
|
AgentCodexAppServerClient.refreshRuntimeStatusAsync(this)
|
|
}
|
|
|
|
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
|
|
putExtra(CodexdForegroundService.EXTRA_SOCKET_PATH, defaultSocketPath())
|
|
putExtra(CodexdForegroundService.EXTRA_CODEX_HOME, defaultCodexHome())
|
|
}
|
|
startForegroundService(intent)
|
|
isServiceRunning = true
|
|
updateAuthUi("Codexd status: requesting device code...", false, null, emptyList())
|
|
thread {
|
|
val socketPath = defaultSocketPath()
|
|
val response = runCatching { postDeviceAuthWithRetry(socketPath) }
|
|
response.onFailure { err ->
|
|
isServiceRunning = false
|
|
updateAuthUi("Codexd status: failed (${err.message})", false, null, emptyList())
|
|
}
|
|
response.onSuccess { deviceResponse ->
|
|
when (deviceResponse.status) {
|
|
"already_authenticated" -> {
|
|
updateAuthUi("Codexd status: already authenticated", true, null, emptyList())
|
|
AgentCodexAppServerClient.refreshRuntimeStatusAsync(this, refreshToken = true)
|
|
showToast("Already signed in")
|
|
}
|
|
"pending", "in_progress" -> {
|
|
val url = deviceResponse.verificationUrl.orEmpty()
|
|
val code = deviceResponse.userCode.orEmpty()
|
|
updateAuthUi(
|
|
"Codexd status: open $url and enter code $code",
|
|
false,
|
|
null,
|
|
emptyList(),
|
|
)
|
|
pollForAuthSuccess(socketPath)
|
|
}
|
|
else -> updateAuthUi(
|
|
"Codexd status: ${deviceResponse.status}",
|
|
false,
|
|
null,
|
|
emptyList(),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun startSignOut() {
|
|
updateAuthUi("Codexd status: signing out...", false, null, emptyList())
|
|
thread {
|
|
val socketPath = defaultSocketPath()
|
|
val result = runCatching { postLogoutWithRetry(socketPath) }
|
|
result.onFailure { err ->
|
|
updateAuthUi(
|
|
"Codexd status: sign out failed (${err.message})",
|
|
false,
|
|
null,
|
|
emptyList(),
|
|
)
|
|
}
|
|
result.onSuccess {
|
|
showToast("Signed out")
|
|
refreshAuthStatus()
|
|
AgentCodexAppServerClient.refreshRuntimeStatusAsync(this)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun refreshAuthStatus() {
|
|
if (statusRefreshInFlight) {
|
|
return
|
|
}
|
|
statusRefreshInFlight = true
|
|
thread {
|
|
val socketPath = defaultSocketPath()
|
|
val result = runCatching { fetchAuthStatusWithRetry(socketPath) }
|
|
result.onFailure { err ->
|
|
isServiceRunning = false
|
|
updateAuthUi(
|
|
"Codexd status: stopped or unavailable (${err.message})",
|
|
false,
|
|
null,
|
|
emptyList(),
|
|
)
|
|
}
|
|
result.onSuccess { status ->
|
|
isServiceRunning = true
|
|
val message = if (status.authenticated) {
|
|
val emailSuffix = status.accountEmail?.let { " ($it)" } ?: ""
|
|
"Codexd status: signed in$emailSuffix"
|
|
} else {
|
|
"Codexd 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(
|
|
"Codexd status: signed in$emailSuffix",
|
|
true,
|
|
status.clientCount,
|
|
status.clients,
|
|
)
|
|
AgentCodexAppServerClient.refreshRuntimeStatusAsync(this, refreshToken = true)
|
|
showToast("Signed in")
|
|
return
|
|
}
|
|
Thread.sleep(3000)
|
|
}
|
|
}
|
|
|
|
private fun updateAgentUi(snapshot: AgentSnapshot, unavailableMessage: String? = null) {
|
|
runOnUiThread {
|
|
val statusView = findViewById<TextView>(R.id.agent_status)
|
|
val runtimeStatusView = findViewById<TextView>(R.id.agent_runtime_status)
|
|
val genieView = findViewById<TextView>(R.id.agent_genie_package)
|
|
val focusView = findViewById<TextView>(R.id.agent_session_focus)
|
|
val groupView = findViewById<TextView>(R.id.agent_session_group)
|
|
val questionLabel = findViewById<TextView>(R.id.agent_question_label)
|
|
val questionView = findViewById<TextView>(R.id.agent_question)
|
|
val answerInput = findViewById<EditText>(R.id.agent_answer_input)
|
|
val answerButton = findViewById<Button>(R.id.agent_answer_button)
|
|
val attachButton = findViewById<Button>(R.id.agent_attach_button)
|
|
val cancelButton = findViewById<Button>(R.id.agent_cancel_button)
|
|
val cancelAllButton = findViewById<Button>(R.id.agent_cancel_all_button)
|
|
val timelineView = findViewById<TextView>(R.id.agent_timeline)
|
|
val startButton = findViewById<Button>(R.id.agent_start_button)
|
|
val refreshButton = findViewById<Button>(R.id.agent_refresh_button)
|
|
|
|
if (!snapshot.available) {
|
|
statusView.text = unavailableMessage?.let {
|
|
"Agent framework unavailable ($it)"
|
|
} ?: "Agent framework unavailable on this build"
|
|
runtimeStatusView.text = renderAgentRuntimeStatus()
|
|
genieView.text = "No GENIE role holder configured"
|
|
focusView.text = "No framework session selected"
|
|
groupView.text = "No framework sessions available"
|
|
questionLabel.visibility = View.GONE
|
|
questionView.visibility = View.GONE
|
|
answerInput.visibility = View.GONE
|
|
answerButton.visibility = View.GONE
|
|
attachButton.visibility = View.GONE
|
|
cancelButton.visibility = View.GONE
|
|
cancelAllButton.isEnabled = false
|
|
timelineView.text = "No framework events yet."
|
|
startButton.isEnabled = false
|
|
refreshButton.isEnabled = false
|
|
updateSessionUiLease(null)
|
|
return@runOnUiThread
|
|
}
|
|
|
|
val roleHolders = if (snapshot.roleHolders.isEmpty()) {
|
|
"none"
|
|
} else {
|
|
snapshot.roleHolders.joinToString(", ")
|
|
}
|
|
statusView.text = "Agent framework active. Genie role holders: $roleHolders"
|
|
runtimeStatusView.text = renderAgentRuntimeStatus()
|
|
genieView.text = snapshot.selectedGeniePackage ?: "No GENIE role holder configured"
|
|
focusView.text = renderSelectedSession(snapshot)
|
|
groupView.text = renderSessionGroup(snapshot)
|
|
timelineView.text = renderTimeline(snapshot)
|
|
startButton.isEnabled = snapshot.selectedGeniePackage != null
|
|
refreshButton.isEnabled = true
|
|
cancelAllButton.isEnabled = snapshot.sessions.any { !isTerminalState(it.state) }
|
|
|
|
val selectedSession = snapshot.selectedSession
|
|
val waitingQuestion = selectedSession?.latestQuestion
|
|
val isWaitingForUser = selectedSession?.state == AgentSessionInfo.STATE_WAITING_FOR_USER &&
|
|
!waitingQuestion.isNullOrEmpty()
|
|
questionLabel.visibility = if (isWaitingForUser) View.VISIBLE else View.GONE
|
|
questionView.visibility = if (isWaitingForUser) View.VISIBLE else View.GONE
|
|
answerInput.visibility = if (isWaitingForUser) View.VISIBLE else View.GONE
|
|
answerButton.visibility = if (isWaitingForUser) View.VISIBLE else View.GONE
|
|
questionView.text = waitingQuestion ?: ""
|
|
|
|
val showAttachButton = selectedSession?.targetDetached == true
|
|
attachButton.visibility = if (showAttachButton) View.VISIBLE else View.GONE
|
|
attachButton.isEnabled = showAttachButton
|
|
|
|
val showCancelButton = selectedSession != null
|
|
cancelButton.visibility = if (showCancelButton) View.VISIBLE else View.GONE
|
|
cancelButton.isEnabled = showCancelButton
|
|
|
|
updateSessionUiLease(snapshot.parentSession?.sessionId)
|
|
}
|
|
}
|
|
|
|
private fun isTerminalState(state: Int): Boolean {
|
|
return state == AgentSessionInfo.STATE_COMPLETED ||
|
|
state == AgentSessionInfo.STATE_CANCELLED ||
|
|
state == AgentSessionInfo.STATE_FAILED
|
|
}
|
|
|
|
private fun renderSelectedSession(snapshot: AgentSnapshot): String {
|
|
val selectedSession = snapshot.selectedSession ?: return "No framework session selected"
|
|
return buildString {
|
|
append("Session: ${selectedSession.sessionId}\n")
|
|
append("State: ${selectedSession.stateLabel}\n")
|
|
append("Target: ${selectedSession.targetPackage ?: "direct-agent"}\n")
|
|
append("Detached target: ${selectedSession.targetDetached}\n")
|
|
val parentSessionId = snapshot.parentSession?.sessionId
|
|
if (parentSessionId != null) {
|
|
append("Parent: $parentSessionId\n")
|
|
}
|
|
selectedSession.latestResult?.let { append("Result: $it\n") }
|
|
selectedSession.latestError?.let { append("Error: $it\n") }
|
|
if (selectedSession.latestResult == null && selectedSession.latestError == null) {
|
|
selectedSession.latestTrace?.let { append("Trace: $it") }
|
|
}
|
|
}.trimEnd()
|
|
}
|
|
|
|
private fun renderSessionGroup(snapshot: AgentSnapshot): String {
|
|
val sessions = snapshot.relatedSessions.ifEmpty { snapshot.sessions }
|
|
if (sessions.isEmpty()) {
|
|
return "No framework sessions yet"
|
|
}
|
|
return sessions.joinToString("\n") { session ->
|
|
val role = if (session.parentSessionId == null) {
|
|
if (session.targetPackage == null) "parent" else "standalone"
|
|
} else {
|
|
"child"
|
|
}
|
|
val marker = if (session.sessionId == snapshot.selectedSession?.sessionId) "*" else "-"
|
|
val detail = session.latestQuestion ?: session.latestResult ?: session.latestError ?: session.latestTrace
|
|
buildString {
|
|
append("$marker $role ${session.stateLabel} ${session.targetPackage ?: "direct-agent"}")
|
|
if (session.targetDetached) {
|
|
append(" [detached]")
|
|
}
|
|
append("\n ${session.sessionId}")
|
|
if (!detail.isNullOrEmpty()) {
|
|
append("\n $detail")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun renderTimeline(snapshot: AgentSnapshot): String {
|
|
val selectedSession = snapshot.selectedSession ?: return "No framework events yet."
|
|
val parentSession = snapshot.parentSession
|
|
if (parentSession == null || parentSession.sessionId == selectedSession.sessionId) {
|
|
return selectedSession.timeline
|
|
}
|
|
return buildString {
|
|
append("Parent ${parentSession.sessionId}\n")
|
|
append(parentSession.timeline)
|
|
append("\n\nSelected child ${selectedSession.sessionId}\n")
|
|
append(selectedSession.timeline)
|
|
}
|
|
}
|
|
|
|
private fun renderAgentRuntimeStatus(): String {
|
|
val runtimeStatus = latestAgentRuntimeStatus
|
|
if (runtimeStatus == null) {
|
|
return "Agent runtime: probing..."
|
|
}
|
|
val authSummary = if (runtimeStatus.authenticated) {
|
|
runtimeStatus.accountEmail?.let { "signed in ($it)" } ?: "signed in"
|
|
} else {
|
|
"not signed in; use the codexd controls below to start sign-in"
|
|
}
|
|
val configuredModelSuffix = runtimeStatus.configuredModel
|
|
?.takeIf { it != runtimeStatus.effectiveModel }
|
|
?.let { ", configured=$it" }
|
|
?: ""
|
|
val effectiveModel = runtimeStatus.effectiveModel ?: "unknown"
|
|
return "Agent runtime: $authSummary, provider=${runtimeStatus.modelProviderId}, effective=$effectiveModel$configuredModelSuffix, clients=${runtimeStatus.clientCount}, base=${runtimeStatus.upstreamBaseUrl}"
|
|
}
|
|
|
|
private fun updateAuthUi(
|
|
message: String,
|
|
authenticated: Boolean,
|
|
clientCount: Int?,
|
|
clients: List<ClientStats>,
|
|
) {
|
|
isAuthenticated = authenticated
|
|
runOnUiThread {
|
|
val statusView = findViewById<TextView>(R.id.auth_status)
|
|
statusView.text = message
|
|
val serviceButton = findViewById<Button>(R.id.service_toggle)
|
|
serviceButton.text = if (isServiceRunning) "Stop codexd" else "Start codexd"
|
|
val actionButton = findViewById<Button>(R.id.auth_action)
|
|
actionButton.text = if (authenticated) "Sign out" else "Start sign-in"
|
|
actionButton.isEnabled = isServiceRunning
|
|
val headingView = findViewById<TextView>(R.id.connected_clients_heading)
|
|
val countSuffix = clientCount?.let { " ($it)" } ?: " (unknown)"
|
|
headingView.text = "Connected clients$countSuffix"
|
|
renderClientsTable(clientCount, clients)
|
|
}
|
|
}
|
|
|
|
private fun renderClientsTable(clientCount: Int?, clients: List<ClientStats>) {
|
|
val clientsTable = findViewById<TableLayout>(R.id.clients_table)
|
|
while (clientsTable.childCount > 1) {
|
|
clientsTable.removeViewAt(1)
|
|
}
|
|
|
|
if (clientCount == null) {
|
|
val row = TableRow(this)
|
|
val idCell = TextView(this)
|
|
idCell.text = "unavailable"
|
|
val trafficCell = TextView(this)
|
|
trafficCell.text = "n/a"
|
|
row.addView(idCell)
|
|
row.addView(trafficCell)
|
|
clientsTable.addView(row)
|
|
return
|
|
}
|
|
|
|
if (clients.isEmpty()) {
|
|
val row = TableRow(this)
|
|
val idCell = TextView(this)
|
|
idCell.text = "none"
|
|
val trafficCell = TextView(this)
|
|
trafficCell.text = "Tx 0.0 / Rx 0.0"
|
|
row.addView(idCell)
|
|
row.addView(trafficCell)
|
|
clientsTable.addView(row)
|
|
return
|
|
}
|
|
|
|
clients.sortedBy { it.id }.forEach { client ->
|
|
val row = TableRow(this)
|
|
val idCell = TextView(this)
|
|
idCell.text = if (client.activeConnections > 0) {
|
|
client.id
|
|
} else {
|
|
"${client.id} (idle)"
|
|
}
|
|
idCell.setPadding(0, 6, 24, 6)
|
|
val trafficCell = TextView(this)
|
|
trafficCell.text = formatTrafficKb(client.bytesSent, client.bytesReceived)
|
|
trafficCell.setPadding(0, 6, 0, 6)
|
|
row.addView(idCell)
|
|
row.addView(trafficCell)
|
|
clientsTable.addView(row)
|
|
}
|
|
}
|
|
|
|
private fun formatTrafficKb(bytesSent: Long, bytesReceived: Long): String {
|
|
val sentKb = bytesSent.toDouble() / 1024.0
|
|
val receivedKb = bytesReceived.toDouble() / 1024.0
|
|
return String.format(Locale.US, "Tx %.1f / Rx %.1f", sentKb, receivedKb)
|
|
}
|
|
|
|
private fun showToast(message: String) {
|
|
runOnUiThread {
|
|
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
|
|
}
|
|
}
|
|
|
|
private data class AuthStatus(
|
|
val authenticated: Boolean,
|
|
val accountEmail: String?,
|
|
val clientCount: Int,
|
|
val clients: List<ClientStats>,
|
|
)
|
|
|
|
private data class ClientStats(
|
|
val id: String,
|
|
val activeConnections: Int,
|
|
val bytesSent: Long,
|
|
val bytesReceived: Long,
|
|
)
|
|
|
|
private data class DeviceAuthResponse(
|
|
val status: String,
|
|
val verificationUrl: String?,
|
|
val userCode: String?,
|
|
)
|
|
|
|
private data class HttpResponse(val statusCode: Int, val body: String)
|
|
|
|
private fun postDeviceAuthWithRetry(socketPath: String): DeviceAuthResponse {
|
|
val response = executeSocketRequestWithRetry(
|
|
socketPath,
|
|
"POST",
|
|
"/internal/auth/device",
|
|
null,
|
|
)
|
|
if (response.statusCode != 200) {
|
|
throw IOException("HTTP ${response.statusCode}: ${response.body}")
|
|
}
|
|
val json = JSONObject(response.body)
|
|
val verificationUrl =
|
|
if (json.isNull("verification_url")) null else json.optString("verification_url")
|
|
val userCode = if (json.isNull("user_code")) null else json.optString("user_code")
|
|
return DeviceAuthResponse(
|
|
status = json.optString("status"),
|
|
verificationUrl = verificationUrl,
|
|
userCode = userCode,
|
|
)
|
|
}
|
|
|
|
private fun postLogoutWithRetry(socketPath: String) {
|
|
val response = executeSocketRequestWithRetry(
|
|
socketPath,
|
|
"POST",
|
|
"/internal/auth/logout",
|
|
null,
|
|
)
|
|
if (response.statusCode != 200) {
|
|
throw IOException("HTTP ${response.statusCode}: ${response.body}")
|
|
}
|
|
}
|
|
|
|
private fun fetchAuthStatusWithRetry(socketPath: String): AuthStatus {
|
|
val response = executeSocketRequestWithRetry(
|
|
socketPath,
|
|
"GET",
|
|
"/internal/auth/status",
|
|
null,
|
|
)
|
|
if (response.statusCode != 200) {
|
|
throw IOException("HTTP ${response.statusCode}: ${response.body}")
|
|
}
|
|
val json = JSONObject(response.body)
|
|
val accountEmail =
|
|
if (json.isNull("accountEmail")) null else json.optString("accountEmail")
|
|
val clientCount = if (json.has("clientCount")) {
|
|
json.optInt("clientCount", 0)
|
|
} else {
|
|
json.optInt("client_count", 0)
|
|
}
|
|
val clients = parseClients(json.optJSONArray("clients"))
|
|
return AuthStatus(
|
|
authenticated = json.optBoolean("authenticated", false),
|
|
accountEmail = accountEmail,
|
|
clientCount = clientCount,
|
|
clients = clients,
|
|
)
|
|
}
|
|
|
|
private fun parseClients(clientsJson: JSONArray?): List<ClientStats> {
|
|
if (clientsJson == null) {
|
|
return emptyList()
|
|
}
|
|
val clients = mutableListOf<ClientStats>()
|
|
for (index in 0 until clientsJson.length()) {
|
|
val clientJson = clientsJson.optJSONObject(index) ?: continue
|
|
val id = clientJson.optString("id", "unknown")
|
|
val activeConnections = if (clientJson.has("activeConnections")) {
|
|
clientJson.optInt("activeConnections", 0)
|
|
} else {
|
|
clientJson.optInt("active_connections", 0)
|
|
}
|
|
val bytesSent = if (clientJson.has("bytesSent")) {
|
|
clientJson.optLong("bytesSent", 0)
|
|
} else {
|
|
clientJson.optLong("bytes_sent", 0)
|
|
}
|
|
val bytesReceived = if (clientJson.has("bytesReceived")) {
|
|
clientJson.optLong("bytesReceived", 0)
|
|
} else {
|
|
clientJson.optLong("bytes_received", 0)
|
|
}
|
|
clients.add(
|
|
ClientStats(
|
|
id = id,
|
|
activeConnections = activeConnections,
|
|
bytesSent = bytesSent,
|
|
bytesReceived = bytesReceived,
|
|
),
|
|
)
|
|
}
|
|
return clients
|
|
}
|
|
|
|
private fun executeSocketRequestWithRetry(
|
|
socketPath: String,
|
|
method: String,
|
|
path: String,
|
|
body: String?,
|
|
): HttpResponse {
|
|
var lastError: Exception? = null
|
|
repeat(10) {
|
|
try {
|
|
return executeSocketRequest(socketPath, method, path, body)
|
|
} catch (err: Exception) {
|
|
lastError = err
|
|
Thread.sleep(250)
|
|
}
|
|
}
|
|
throw IOException("Failed to connect to codexd socket: ${lastError?.message}")
|
|
}
|
|
|
|
private fun executeSocketRequest(
|
|
socketPath: String,
|
|
method: String,
|
|
path: String,
|
|
body: String?,
|
|
): HttpResponse {
|
|
val socket = LocalSocket()
|
|
val address = CodexSocketConfig.toLocalSocketAddress(socketPath)
|
|
socket.connect(address)
|
|
|
|
val payload = body ?: ""
|
|
val request = buildString {
|
|
append("$method $path HTTP/1.1\r\n")
|
|
append("Host: localhost\r\n")
|
|
append("Connection: close\r\n")
|
|
if (body != null) {
|
|
append("Content-Type: application/json\r\n")
|
|
}
|
|
append("Content-Length: ${payload.toByteArray(StandardCharsets.UTF_8).size}\r\n")
|
|
append("\r\n")
|
|
append(payload)
|
|
}
|
|
val output = socket.outputStream
|
|
output.write(request.toByteArray(StandardCharsets.UTF_8))
|
|
output.flush()
|
|
|
|
val responseBytes = BufferedInputStream(socket.inputStream).use { it.readBytes() }
|
|
socket.close()
|
|
|
|
val responseText = responseBytes.toString(StandardCharsets.UTF_8)
|
|
val splitIndex = responseText.indexOf("\r\n\r\n")
|
|
if (splitIndex == -1) {
|
|
throw IOException("Invalid HTTP response")
|
|
}
|
|
val headers = responseText.substring(0, splitIndex)
|
|
val statusLine = headers.lineSequence().firstOrNull().orEmpty()
|
|
val statusCode = statusLine.split(" ").getOrNull(1)?.toIntOrNull()
|
|
?: throw IOException("Missing status code")
|
|
val responseBody = responseText.substring(splitIndex + 4)
|
|
return HttpResponse(statusCode, responseBody)
|
|
}
|
|
|
|
private fun defaultSocketPath(): String {
|
|
return CodexSocketConfig.DEFAULT_SOCKET_PATH
|
|
}
|
|
|
|
private fun defaultCodexHome(): String {
|
|
return File(filesDir, "codex-home").absolutePath
|
|
}
|
|
}
|