Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ npx qrcode "opencode://connect?name=Mac%20mini&url=http%3A%2F%2F192.168.1.10%3A4
4. 両方のSHA-256チェックサムを検証
5. アプリ専用ディレクトリに展開
6. Alpine内にGit、bash、curl、ripgrep、CA証明書をインストール
7. `127.0.0.1:4097`でエージェントサーバーを起動
7. `127.0.0.1:4098`でエージェントサーバーを起動
8. アプリをローカルランタイムに切り替え

固定バージョン(エージェント自体を変更せずアプリのリリースで更新可能。詳細は[`local-runtime-manifest.json`](app/src/main/assets/local-runtime-manifest.json)を参照):
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ The setup process (triggered from Workspaces):
4. Validates SHA-256 checksums for both
5. Extracts to a private app directory
6. Installs Git, bash, curl, ripgrep, and CA certificates inside Alpine
7. Starts the agent server on `127.0.0.1:4097`
7. Starts the agent server on `127.0.0.1:4098`
8. Switches the app to the local runtime

Pinned versions (updatable via app releases without agent changes; see [`local-runtime-manifest.json`](app/src/main/assets/local-runtime-manifest.json)):
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/assets/legal/auth_data_flow.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
[`app/src/main/assets/local-runtime-manifest.json`](../app/src/main/assets/local-runtime-manifest.json)
内の`openCodeUrl`、`sha256`で固定されています。
- **インストール場所:** SHA-256検証後、`LocalRuntimeInstaller`/`LocalOpenCodeBackend`により、アプリ専用の端末内Alpine rootfsへ展開されます。
- **起動方法:** Alpine PRootサンドボックス内で`127.0.0.1:4097`にバインドされたローカルHTTPサーバーとして起動します(`LocalOpenCodeBackend`)。AndCodeは他のOpenCodeクライアントと同様に`OpenCodeApiClient`経由で通信します。**リモートOpenCode**の場合、AndCodeは代わりに、利用者が既に自身のPC/Mac/Linuxで実行しているOpenCodeサーバーへ、入力されたURL/ユーザー名/パスワードを用いて接続します(`RemoteOpenCodeBackend`)。
- **起動方法:** Alpine PRootサンドボックス内で`127.0.0.1:4098`にバインドされたローカルHTTPサーバーとして起動します(`LocalOpenCodeBackend`)。AndCodeは他のOpenCodeクライアントと同様に`OpenCodeApiClient`経由で通信します。**リモートOpenCode**の場合、AndCodeは代わりに、利用者が既に自身のPC/Mac/Linuxで実行しているOpenCodeサーバーへ、入力されたURL/ユーザー名/パスワードを用いて接続します(`RemoteOpenCodeBackend`)。
- **認証方法:** OpenCode自身のプロバイダー認証(例:AnthropicやOpenAIのAPIキー、あるいはプロバイダー自身のOAuthフロー)は、OpenCodeサーバー自身が処理します。AndCodeの`ProviderAuthDialog`/`SettingsViewModel`は、OpenCode APIの`providerAuthMethods`/`authorizeProvider`/`completeProviderOAuth`エンドポイントを呼び出し、サーバーが返す方式・URL・入力項目をそのまま表示します。資格情報のやり取り自体は、OpenCodeプロセスとプロバイダーの間で行われ、AndCodeの内部では行われません。
- **認証URL/コードの扱い:** プロバイダーの認証方式がブラウザURLを開くものである場合、AndCodeはAndroidの`Intent.ACTION_VIEW`でそれを開き、入力されたコードをOpenCode APIへ送り返します。完全な認証URLは永続的なログへ書き込まれません(ログに記録する前に、`SecretRedaction.redactUrlQuery`によりクエリ文字列が除去されます)。
- **OAuth/APIキーの保存主体 — 単一ではなく3つの異なる経路:**
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/assets/legal/auth_data_flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ that implement each flow, not from how the feature is expected to behave.
(`openCodeUrl`, `sha256`).
- **Install location:** extracted into the app's private on-device Alpine rootfs by
`LocalRuntimeInstaller`/`LocalOpenCodeBackend`, after SHA-256 verification.
- **Launch:** started as a local HTTP server bound to `127.0.0.1:4097` inside the Alpine PRoot
- **Launch:** started as a local HTTP server bound to `127.0.0.1:4098` inside the Alpine PRoot
sandbox (`LocalOpenCodeBackend`); AndCode talks to it over `OpenCodeApiClient` like any OpenCode
client would. For **remote OpenCode**, AndCode instead connects to an OpenCode server you already
run on your own PC/Mac/Linux (`RemoteOpenCodeBackend`), given a URL/username/password you supply.
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/assets/local-runtime-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"runtimeVersion": "2026.07.25.1",
"openCodeVersion": "1.18.5",
"alpineVersion": "3.24.1",
"port": 4097,
"port": 4098,
"architectures": {
"arm64-v8a": {
"alpineUrl": "https://dl-cdn.alpinelinux.org/alpine/v3.24/releases/aarch64/alpine-minirootfs-3.24.1-aarch64.tar.gz",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,7 @@ class AndCodeApplication : Application() {
status = localRuntimeManager.status(),
idleStopInProgress = idleStopInProgress.value,
userStoppedRuntime = settings.localRuntimeStoppedByUser,
interruptedActivationPending = localRuntimeInstaller.hasInterruptedActivation(),
)
if (shouldRestore) {
RuntimeAutoStartInitializer.restoreIfConfigured(this@AndCodeApplication, RuntimeAutoStartTrigger.AppLaunch)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,8 @@ fun ChatHomeScreen(
onLoadDraft: (String) -> String? = { null },
/** Keeps the composer's unsent text for a session; blank text clears it. */
onSaveDraft: (String, String) -> Unit = { _, _ -> },
localRuntimeStoppedManually: Boolean = false,
onStartLocalRuntime: () -> Unit = {},
) {
// Saved state, not plain remember: the Activity is recreated on rotation and other
// configuration changes, and a remembered field would come back empty. The TextFieldValue
Expand Down Expand Up @@ -470,6 +472,10 @@ fun ChatHomeScreen(
),
)

if (localRuntimeStoppedManually) {
LocalRuntimeStoppedBanner(onStart = onStartLocalRuntime)
}

state.parentSession?.let { parent ->
SubagentSessionBanner(
parentTitle = parent.title,
Expand Down Expand Up @@ -909,6 +915,41 @@ private fun SubagentSessionBanner(
}
}

@Composable
private fun LocalRuntimeStoppedBanner(onStart: () -> Unit) {
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 2.dp)
.testTag("local-runtime-stopped-banner"),
shape = RoundedCornerShape(14.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.runtime_stopped_manually_title),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
)
Text(
text = stringResource(R.string.runtime_stopped_manually_hint),
style = MaterialTheme.typography.labelSmall,
)
}
Button(onClick = onStart) {
Text(stringResource(R.string.runtime_start_button))
}
}
}
}

@Composable
private fun StickyTodoBar(
todos: List<TodoItem>,
Expand Down Expand Up @@ -2367,3 +2408,11 @@ private fun ChatHomeScreenCodexFastPreview() {
)
}
}

@Preview(showBackground = true, widthDp = 360)
@Composable
private fun LocalRuntimeStoppedBannerPreview() {
AndCodeTheme {
LocalRuntimeStoppedBanner(onStart = {})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1373,7 +1373,7 @@ private fun AndroidSetupScreenPreview() {
private fun AndroidSetupProviderStepPreview() {
AndCodeTheme {
AndroidSetupScreen(
runtimeStatus = LocalRuntimeStatus.Ready("1.0.0", 4097),
runtimeStatus = LocalRuntimeStatus.Ready("1.0.0", 4098),
claude = ClaudeCodeUiState(installed = true, version = "2.1.212"),
onStartSetup = { _, _ -> },
onBeginClaudeSignIn = {},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.yugahashimoto.andcode.runtime.local

/**
* Keeps the watchdog's idle auto-stop from overriding a newer command intent.
*
* Every command the service receives calls [onCommand], which advances a generation. An idle stop
* takes its token from [beginIdleStop] at the generation it was decided at, and [isCurrent] reports
* whether nothing newer has arrived since. The stop only tears the service down while that still
* holds; a start that arrives in the meantime wins and the service stays up.
*
* Kept free of Android types so the interleavings can be tested directly.
*/
internal class IdleStopGate {
private val lock = Any()
private var generation = 0L
private var pendingToken: Long? = null

/** Records a newer command intent and returns the generation it produced. */
fun onCommand(): Long =
synchronized(lock) {
generation++
generation
}

/** Starts an idle stop decision. Returns its token, or null when one is already pending. */
fun beginIdleStop(): Long? =
synchronized(lock) {
if (pendingToken != null) {
null
} else {
generation.also { pendingToken = it }
}
}

/** Whether no command has arrived since [token] was taken. */
fun isCurrent(token: Long): Boolean = synchronized(lock) { generation == token }

/** Clears the pending idle stop for [token]. A token that is no longer pending is ignored. */
fun finishIdleStop(token: Long) {
synchronized(lock) {
if (pendingToken == token) pendingToken = null
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,16 @@ class LocalRuntimeInstaller(
}
}

/**
* Whether an install or update was interrupted and left state that the start path repairs: a
* rollback copy of the environment (fixed by [recoverInterruptedActivation]) or an in-app update
* journal (fixed by the update engine's recover step in the manager's start). A plain
* file-existence check, so it is cheap enough for the foreground-return path; it takes no lock.
*/
fun hasInterruptedActivation(): Boolean =
File(runtimeDirectory, "environment.rollback").isDirectory ||
INTERRUPTED_UPDATE_JOURNALS.any { name -> File(runtimeDirectory, name).isFile }

fun recoverInterruptedActivation(): Boolean =
accessCoordinator.write {
recoverInterruptedRuntimeEnvironment(
Expand All @@ -259,6 +269,7 @@ class LocalRuntimeInstaller(
accessCoordinator.write {
val active = File(runtimeDirectory, "environment")
normalizeRuntimeSymlinks(active)
migrateLegacyRuntimePort(runtimeDirectory, manifestReader::port, json)
val metadataFile = File(runtimeDirectory, METADATA_FILE)
val rootfs = File(active, "rootfs")
if (!metadataFile.isFile || !rootfs.isDirectory) return@write null
Expand Down Expand Up @@ -685,6 +696,13 @@ class LocalRuntimeInstaller(

internal companion object {
private const val METADATA_FILE = "metadata.json"

/**
* Journals [LocalRuntimeUpdater] leaves behind when an update or rollback is interrupted.
* Mirrors the names in its companion; [LocalRuntimeManager.startLocked] runs its recover()
* for these, so they count as an interrupted activation.
*/
private val INTERRUPTED_UPDATE_JOURNALS = listOf("update-transaction.json", "rollback-transaction.json")
private const val BROWSER_MCP_NAME = "and-code-browser"
private const val BROWSER_MCP_BIN = "/usr/local/bin/andcode-browser-mcp.py"
private const val BROWSER_MCP_TIMEOUT_MILLIS = 30000
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ class LocalRuntimeManager(
private val runtimeDirectory: File,
private val abi: String,
private val portProbe: (Int) -> Boolean = ::defaultPortProbe,
/** Whether the server answering on a port is ours; see [defaultServerOwnershipProbe]. */
private val ownershipProbe: (Int, String) -> Boolean = ::defaultServerOwnershipProbe,
/**
* Whether our own OpenCode server proot is alive, by scanning /proc. Only the server counts:
* Claude Code, Codex and terminal sessions run proot from the same runtime directory too.
*/
private val managedRuntimeRunning: () -> Boolean = { findOpenCodeServerPids(runtimeDirectory).isNotEmpty() },
private val installer: LocalRuntimeInstaller? = null,
private val processLauncher: LocalRuntimeProcessLauncher? = null,
/** Whether the server process we started is still alive, independent of whether it answers. */
Expand All @@ -74,6 +81,12 @@ class LocalRuntimeManager(
encodeDefaults = true
}
private val operationMutex = Mutex()

/**
* The port the start path last found held by a server that is not ours, or null. Lets
* [computeStatus] report that port as Broken without probing it on every status read.
*/
@Volatile private var foreignPortOwner: Int? = null
private val mutableState = MutableStateFlow(computeStatus())
val state: StateFlow<LocalRuntimeStatus> = mutableState.asStateFlow()

Expand Down Expand Up @@ -172,16 +185,33 @@ class LocalRuntimeManager(

suspend fun stop(): Result<LocalRuntimeStatus.Stopped> =
operationMutex.withLock {
runCatching {
withContext(Dispatchers.IO) { processLauncher?.stop() }
val metadata = readMetadata() ?: error("Local runtime metadata is missing")
LocalRuntimeStatus.Stopped(metadata.version, metadata.port).also { mutableState.value = it }
}.onFailure { error ->
mutableState.value =
LocalRuntimeStatus.Broken(
error.message ?: messages.stopFailed,
)
}
stopLocked()
}

/**
* Stops the runtime only if [stillWanted] still holds once the operation lock is acquired.
*
* Used by the idle auto-stop: it can queue behind a newer start, and checking its own
* conditions before queueing would miss a start that lands while it waits. Returns false, and
* leaves the runtime alone, when [stillWanted] no longer holds.
*/
suspend fun stopIfStillWanted(stillWanted: () -> Boolean): Boolean =
operationMutex.withLock {
if (!stillWanted()) return@withLock false
stopLocked()
true
}

private suspend fun stopLocked(): Result<LocalRuntimeStatus.Stopped> =
runCatching {
withContext(Dispatchers.IO) { processLauncher?.stop() }
val metadata = readMetadata() ?: error("Local runtime metadata is missing")
LocalRuntimeStatus.Stopped(metadata.version, metadata.port).also { mutableState.value = it }
}.onFailure { error ->
mutableState.value =
LocalRuntimeStatus.Broken(
error.message ?: messages.stopFailed,
)
}

suspend fun deleteRuntime(): Result<LocalRuntimeStatus.NotInstalled> =
Expand Down Expand Up @@ -596,7 +626,17 @@ class LocalRuntimeManager(
runCatching { installer?.provisionGuestCapabilitiesForExistingInstall() }
// Before the server starts, so its first turn already reads the selected preset.
applyOpenCodeSystemPrompt(installed.rootfs, systemPrompt())
if (!portProbe(installed.metadata.port)) launcher.start(installed)
if (!portProbe(installed.metadata.port)) {
launcher.start(installed)
} else if (!processAlive() && !ownsPort(installed.metadata.port)) {
// Something else holds the port: launching would fail, and reporting Ready would
// point the chat at a foreign server. Surface it instead; never kill it.
foreignPortOwner = installed.metadata.port
val reason = messages.portInUse(installed.metadata.port)
mutableState.value = LocalRuntimeStatus.Broken(reason)
error(reason)
}
foreignPortOwner = null
val ready =
LocalRuntimeStatus.Ready(
installed.metadata.version,
Expand All @@ -606,6 +646,18 @@ class LocalRuntimeManager(
ready
}

/**
* Whether the server on [port] is ours. Only called from the start path, where an open port
* would otherwise be taken as proof of a running runtime, so the blocking probe is acceptable.
*
* Our own proot process counts as ours without probing: that is the server we started, merely
* slow to answer. The probe is reserved for a listener this app did not launch.
*/
private fun ownsPort(port: Int): Boolean {
if (managedRuntimeRunning()) return true
return ownershipProbe(port, LocalServerAuth.password(runtimeDirectory))
}

private fun computeStatus(): LocalRuntimeStatus {
if (abi !in SUPPORTED_ABIS) return LocalRuntimeStatus.UnsupportedAbi(abi)
val metadataFile = File(runtimeDirectory, METADATA_FILE)
Expand All @@ -627,7 +679,13 @@ class LocalRuntimeManager(
if (metadata.version.isBlank() || metadata.port !in 1..65535) {
return LocalRuntimeStatus.Broken("Runtime metadata contains invalid values")
}
if (portProbe(metadata.port)) return LocalRuntimeStatus.Ready(metadata.version, metadata.port)
if (portProbe(metadata.port)) {
// Open, but not ours, if the start path found a foreign server there. Cached rather than
// probed here: this runs often, and an HTTP probe must not sit on that path.
if (foreignPortOwner == metadata.port) return LocalRuntimeStatus.Broken(messages.portInUse(metadata.port))
return LocalRuntimeStatus.Ready(metadata.version, metadata.port)
}
foreignPortOwner = null
// A missed probe is not proof of death. Our own child process is: if it is still running,
// the server exists and is merely too busy to answer, and restarting it would destroy work
// in progress rather than recover anything.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ class LocalRuntimeManifestReader(
isLenient = true
},
) {
/** The local server port this build uses; see [LocalRuntimeManifest.port]. */
fun port(): Int = read().port

fun read(): LocalRuntimeManifest {
val payload = context.assets.open(ASSET_NAME).bufferedReader().use { it.readText() }
return json.decodeFromString<LocalRuntimeManifest>(payload).also { it.validate() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ interface LocalRuntimeMessages {
reason: String,
): String

/** Shown when the local port is held by a server that is not ours. */
fun portInUse(port: Int): String

/**
* English fallbacks, used by unit tests and by any construction path that has no [Context].
* Production wiring always injects [AndroidLocalRuntimeMessages].
Expand Down Expand Up @@ -129,6 +132,8 @@ interface LocalRuntimeMessages {
version: String,
reason: String,
) = "Restored OpenCode $version but it will not start: $reason"

override fun portInUse(port: Int) = "Port $port is in use by another process"
}
}

Expand Down Expand Up @@ -189,4 +194,6 @@ class AndroidLocalRuntimeMessages(private val context: Context) : LocalRuntimeMe
version: String,
reason: String,
): String = context.getString(R.string.runtime_error_restored_but_cannot_start, version, reason)

override fun portInUse(port: Int): String = context.getString(R.string.runtime_error_port_in_use, port)
}
Loading
Loading