mirror of
https://github.com/openai/codex.git
synced 2026-04-29 00:55:38 +00:00
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 legacy 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 legacy codexd" else "Start legacy 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
|
|
}
|
|
}
|