feat(termux): F-Droid deep-link + RUN_COMMAND_PENDING_INTENT install path

Make the on-device Termux install flow actually work end-to-end. Three
workstreams from plans/atomic-wondering-sunrise.md plus a validation
patch:

Workstream A — F-Droid deep-link from WelcomeScreen
- The 'Install locally (Termux)' card no longer renders a dead disabled
  Continue button when Termux is missing. It now shows two stacked CTAs:
  primary 'Open F-Droid' (existing termux_install_action string, now
  actually used) deep-linking https://f-droid.org/packages/com.termux/,
  and secondary 'Or get it from GitHub Releases' (new string) for
  side-loaders deep-linking github.com/termux/termux-app/releases.
- Reuses existing HermesApi.openExternal; no new IPC method needed.

Workstream B — HermesInstaller backend-aware shell dispatch
- runShell / runHermesDoctor / runPipInstall in HermesInstaller now
  branch on currentBackend() and dispatch to TermuxRunner on Termux,
  bundled.runPython on Bundled. Pre-fix, every stage shelled through
  bundled.runPython regardless of backend, so Termux installs failed
  immediately at stage 3 with 'Bundled Python not available'.

Workstream C — RUN_COMMAND_PENDING_INTENT for real exit codes
- New TermuxResultReceiver.kt: BroadcastReceiver + TermuxResultRegistry
  process-singleton. Atomic session IDs, ConcurrentHashMap of
  Continuations, PendingIntent.FLAG_MUTABLE result intent builder
  (FLAG_MUTABLE is required — Termux's RunCommandService adds the
  result bundle to the PendingIntent before broadcasting it back).
- New TermuxRunner.runAndWait(command, cwd): suspend fn using
  suspendCancellableCoroutine (modeled on GatewayClient.request),
  attaches the result PendingIntent via the
  com.termux.RUN_COMMAND_PENDING_INTENT intent extra. Resolves with a
  BundledPythonRunner.PythonResult carrying the real exit code +
  truncated stdout/stderr (~100KB cap imposed by Termux).
- HermesInstaller.runShellViaTermux switches from termux.run to
  termux.runAndWait. The 4 call sites (stages 3/4/5/8 in runStages,
  plus runHermesUpdate) already gate on exitCode != 0 and consume
  stderr/stdout, so they become correct automatically.
- applyPatches rewritten as suspend: dispatches git apply via Termux
  using a bash heredoc to write the patch content to Termux's
  $PREFIX/tmp/ (our app's process can't write into Termux's sandbox).
  Drops the now-stale TODO(workstream-B-followup) comment.
- AndroidManifest gets a <queries> block declaring Termux package
  visibility — without it, on API 30+, PackageManager.getPackageInfo
  returns NameNotFoundException even when Termux is installed and
  HermesInstaller falls through to the bundled-Python backend.
  Also adds com.termux.permission.RUN_COMMAND (user-granted in
  Android Settings -> App info -> Additional permissions).
- TermuxProbe.isRunCommandResultSupported() helper checks Termux
  versionName >= 0.109 (the floor for RUN_COMMAND_PENDING_INTENT).
- InstallScreen surfaces a TermuxPermissionNeededCard when an install
  stage fails with a Termux-specific error pattern (RUN_COMMAND,
  allow-external-apps, plugin_action_disabled — NOT the bare word
  'permission' which pip prints routinely). The card has an 'Open
  App Settings' button using a new HermesApi.openAppSettings()
  helper that fires Settings.ACTION_APPLICATION_DETAILS_SETTINGS.
- setup-android.sh gains a queries-merge step. Anchored on
  start-of-line for awk and probes android:name="com.termux" for
  detect-and-skip (so a future library's unrelated <queries> block
  doesn't silently skip the Termux merge).

Validation patch — markerDir + Termux sandbox awareness (B1–B5)
- An adversarial review found that HermesInstaller assumed every path
  it computes (hermesPython, hermesRepo, hermesVenv, the verified
  marker) lives somewhere our app can read. On Termux backend
  hermesHome resolves to /data/data/com.termux/files/home/.hermes
  which is OFF-LIMITS to our process. Pre-Workstream-C the install
  never succeeded on Termux so these checks were never exercised
  against real state; Workstream C made the install actually work,
  so the broken file-existence checks now blocked every user from
  ever reaching AppState.Main.
- Fix: backend-aware markerDir at context.filesDir/hermes-markers/
  on Termux (hermesHome on Bundled). New sentinels
  hermesVerifiedMarker / hermesRepoClonedMarker /
  hermesVenvCreatedMarker live there. Stages 3 & 4 write their
  marker on success and check the marker (not the file) for
  skip-on-rerun. checkInstall().installed/configured/hasApiKey
  collapse to 'verified marker exists' on Termux (the marker IS
  the success signal of a completed install).
- Stage 4 switches from python3.11 to python on Termux — Termux's
  pkg install python ships the binary as 'python' symlinked to the
  current 3.x; the literal 'python3.11' was a Bundled-path
  assumption that happened to work on some Termux setups but not
  others.
- getHermesVersion becomes suspend + backend-aware (Termux dispatches
  the importlib.metadata.version call via runAndWait); HermesApi
  cascades.

Plan + research artifacts
- plans/atomic-wondering-sunrise.md is the live source-of-truth
  plan/postmortem (3 shipped workstreams + validation section).
- docs/python-bundling-alternatives.md captures the research that
  showed Termux + RESULT_INTENT is the right v1 path and that the
  originally-planned Chaquopy workstream isn't needed. Alpine
  minirootfs + proot is the recommended Tier 2 alternative for
  users without Termux, downloaded on first run (no APK bloat, no
  GPL contagion, F-Droid compatible).

Build status
- ./gradlew :app:assembleDebug green (arm64-v8a, armeabi-v7a, x86_64,
  universal APKs, ~20MB each).
- script/check-kotlin-drift.sh green (59 Kotlin files in sync).
- graphify graph rebuilt (1681 nodes, 2866 edges).

Untested on device. Verification steps for happy path,
permission-denied regression, and cancellation hygiene are in the
plan's Workstream C verification section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-06-04 18:54:41 -04:00
parent 1631de6977
commit 892d778b08
21 changed files with 2040 additions and 156 deletions
+1
View File
@@ -66,3 +66,4 @@ venv/
# Sensitive
.sentryclirc
graphify-out/
@@ -34,6 +34,34 @@
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<!--
Workstream C: Termux RUN_COMMAND IPC. We dispatch shell
commands into Termux's $PREFIX via RunCommandService and get
stdout/stderr/exit-code back via RUN_COMMAND_PENDING_INTENT
(requires Termux >= 0.109). The signature-level permission
must be requested here and granted by the user once in
Android Settings -> App info -> Additional permissions.
-->
<uses-permission android:name="com.termux.permission.RUN_COMMAND" />
<!--
Workstream C: package visibility on Android 11+ (API 30+).
Without this <queries> block, PackageManager.getPackageInfo
(used by TermuxProbe.isTermuxInstalled) throws
NameNotFoundException even when Termux is installed,
defeating the entire backend-selection logic in
HermesInstaller.currentBackend(). We declare visibility for
both the Termux package itself and the RUN_COMMAND intent
action (the latter is what we actually dispatch).
-->
<queries>
<package android:name="com.termux" />
<package android:name="com.termux.api" />
<intent>
<action android:name="com.termux.RUN_COMMAND" />
</intent>
</queries>
<!--
Long-running AI gateway foreground service. The
`foregroundServiceType="specialUse"` requires a justification
@@ -250,6 +250,26 @@ class HermesApi(private val context: Context) {
context.startActivity(intent)
}
/**
* Workstream C: open this app's "App info" page in system Settings.
* Used by [com.nousresearch.hermes.ui.onboarding.InstallScreen]'s
* Termux-permission-needed guidance card so the user can land
* directly on the screen where `com.termux.permission.RUN_COMMAND`
* is granted (Settings → Apps → Hermes → Additional permissions).
*
* Mirrors the same intent shape [BatteryOptHelper.buildAppDetailsIntent]
* uses; we don't reuse that helper because it's named for a
* different use case and exposing two callers would be misleading.
*/
fun openAppSettings() {
val intent = android.content.Intent(
android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
android.net.Uri.fromParts("package", context.packageName, null),
)
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
data class SshConfigView(
val host: String,
val port: Int,
@@ -329,7 +349,7 @@ class HermesApi(private val context: Context) {
fun adoptHermesHome(dir: String): Boolean = installer.adoptHermesHome(dir)
fun getHermesVersion(): String? = installer.getHermesVersion()
suspend fun getHermesVersion(): String? = installer.getHermesVersion()
/**
* Re-read the upstream hermes-agent version on demand. Used by the
@@ -339,7 +359,7 @@ class HermesApi(private val context: Context) {
*/
suspend fun refreshHermesVersion(): String? = installer.refreshHermesVersion()
fun runHermesDoctor(): String {
suspend fun runHermesDoctor(): String {
val r = installer.runHermesDoctor()
return r.stdout + r.stderr
}
@@ -40,6 +40,25 @@ import java.security.SecureRandom
* | 7 | Reserve gateway port | BundledPython |
* | 8 | Verify with `hermes doctor` | Termux or bundle |
*
* ## Backend dispatch (Workstream B + C, atomic-wondering-sunrise plan)
*
* [runShell], [runHermesDoctor] and [runPipInstall] each branch on
* [currentBackend] and dispatch to either [TermuxRunner.runAndWait]
* (Termux) or [BundledPythonRunner] (bundled CPython). Pre-Workstream-B,
* every shell-out went through `bundled.runPython` even on Termux,
* which made the Termux install path immediately fail at stage 3 with
* `"Bundled Python not available"`. Workstream C finished the wiring
* by switching from fire-and-forget `termux.run` to
* `termux.runAndWait` (which uses `RUN_COMMAND_PENDING_INTENT` + a
* BroadcastReceiver) so the Termux backend now reports real exit
* codes and truncated stdout/stderr (~100KB cap imposed by Termux).
*
* Per Workstream C, [checkInstall] no longer runs the doctor inline
* (avoids forcing the entire IPC surface suspend); stage 8 writes a
* [hermesVerifiedMarker] file after the doctor passes, and
* [checkInstall] reads that marker. Explicit re-verification is
* `runHermesDoctor()` from a suspend caller.
*
* ## Failure semantics
*
* Each stage catches its own exceptions and emits a Stage with a
@@ -94,6 +113,48 @@ class HermesInstaller(private val context: Context) {
val hermesConfig: File get() = File(hermesHome, "config.yaml")
val hermesEnv: File get() = File(hermesHome, ".env")
/**
* Where HermesInstaller stores install-state sentinels we need to
* read back from our app's process.
*
* Workstream C followup (B1B4): on the Termux backend,
* [hermesHome] resolves to `/data/data/com.termux/files/home/.hermes`,
* which our app's process can neither read nor write (Android
* cross-app sandbox). We can still ASK Termux (via [TermuxRunner])
* to create files under that path — but we can't directly check
* them later. So install-state sentinels (verified marker, clone
* marker, venv marker) get relocated to OUR app's `filesDir` on
* the Termux backend, while real install artifacts (the venv, the
* repo) stay in Termux's `$PREFIX` where Termux's pip/git wrote
* them. On the Bundled backend, everything lives co-located with
* the install (no sandbox crossing).
*/
private val markerDir: File
get() = when (currentBackend()) {
Backend.TERMUX -> File(context.filesDir, "hermes-markers").also { it.mkdirs() }
Backend.BUNDLED -> hermesHome.also { it.mkdirs() }
Backend.NONE -> File(context.filesDir, "hermes-markers")
}
/**
* Marker file written by stage 8 ([runStages]) after `hermes doctor`
* exits 0. [checkInstall] reads this to populate `verified` without
* having to re-run the doctor on every call (the doctor spawns a
* Python subprocess and is expensive). Manually delete to force a
* re-verification on next install. Lives under [markerDir] so the
* Termux backend can read its own write back.
*/
val hermesVerifiedMarker: File get() = File(markerDir, ".verified")
/** Workstream C B3 followup: written after stage 3 (clone) succeeds.
* Stage 3 uses this for skip-on-rerun detection on Termux backend
* (we can't check `hermesRepo.exists()` from our sandbox there). */
private val hermesRepoClonedMarker: File get() = File(markerDir, ".repo-cloned")
/** Workstream C B3 followup: written after stage 4 (venv) succeeds.
* Same skip-on-rerun semantics as [hermesRepoClonedMarker]. */
private val hermesVenvCreatedMarker: File get() = File(markerDir, ".venv-created")
/** What backend the install will use. Decided once per install. */
enum class Backend { TERMUX, BUNDLED, NONE }
@@ -131,13 +192,45 @@ class HermesInstaller(private val context: Context) {
}
/** Synchronously check current install state — the desktop's
* `checkInstall` IPC method delegates here. */
* `checkInstall` IPC method delegates here.
*
* Workstream C: `verified` no longer runs `hermes doctor`
* inline (which would force this method to suspend and cascade
* through HermesApi.init/validateChatReadiness). Instead the
* install flow's stage 8 writes [hermesVerifiedMarker] after
* the doctor passes, and we read that file here. Manual re-
* verification is a `runHermesDoctor()` call from a suspend
* caller.
*
* Workstream C B1/B2 followup: on the Termux backend we cannot
* probe Termux's `$PREFIX` from our sandbox, so `installed`
* collapses to the verified marker's existence. We can't
* distinguish "files exist but doctor hasn't run yet" from
* "nothing's there" — but the marker IS the success signal of a
* completed install flow, so this is the right semantics. On
* Bundled we keep the file-existence probe (the venv lives in
* our own filesDir on that path). */
fun checkInstall(): CheckResult {
val backend = currentBackend()
val installed = hermesPython.exists() && hermesPython.canExecute()
val configured = hermesConfig.exists() && hermesEnv.exists()
val verified = installed && runHermesDoctor().exitCode == 0
val hasApiKey = hermesEnv.exists() && hermesEnv.readLines().any { it.startsWith("API_SERVER_KEY=") && it.length > "API_SERVER_KEY=".length + 8 }
val installed = when (backend) {
Backend.TERMUX -> hermesVerifiedMarker.exists()
Backend.BUNDLED -> hermesPython.exists() && hermesPython.canExecute()
Backend.NONE -> false
}
val configured = when (backend) {
// Same sandbox reason as `installed`: on Termux we can't see
// /data/data/com.termux/.../.hermes/config.yaml from our
// process. Treat the verified marker as a proxy — if the
// install completed, config + env were generated by stage 6.
Backend.TERMUX -> hermesVerifiedMarker.exists()
Backend.BUNDLED -> hermesConfig.exists() && hermesEnv.exists()
Backend.NONE -> false
}
val verified = installed && hermesVerifiedMarker.exists()
val hasApiKey = when (backend) {
Backend.TERMUX -> verified // can't read Termux's .env; trust the marker
else -> hermesEnv.exists() && hermesEnv.readLines().any { it.startsWith("API_SERVER_KEY=") && it.length > "API_SERVER_KEY=".length + 8 }
}
return CheckResult(
installed = installed,
configured = configured,
@@ -157,12 +250,26 @@ class HermesInstaller(private val context: Context) {
val backend: String,
)
/** Run `hermes doctor` and return its combined stdout+stderr. */
fun runHermesDoctor(): BundledPythonRunner.PythonResult {
return bundled.runPython(
/** Run `hermes doctor` and return its combined stdout+stderr.
* Backend-aware: on Termux, dispatches via
* [TermuxRunner.runAndWait] (real exit code + truncated
* stdout/stderr via `RUN_COMMAND_PENDING_INTENT`). Suspend
* per Workstream C — callers must be in a coroutine context. */
suspend fun runHermesDoctor(): BundledPythonRunner.PythonResult = when (currentBackend()) {
Backend.TERMUX -> termux.runAndWait(
command = "./venv/bin/hermes doctor",
cwd = hermesRepo.absolutePath,
)
Backend.BUNDLED -> bundled.runPython(
argv = listOf("-m", "hermes_cli.main", "doctor"),
cwd = hermesRepo,
)
Backend.NONE -> BundledPythonRunner.PythonResult(
exitCode = -1,
stdout = "",
stderr = "No Python backend available (neither Termux nor bundled).",
process = null,
)
}
/** `git pull` hermes-agent at the pinned SHA and re-apply patches. */
@@ -218,7 +325,16 @@ class HermesInstaller(private val context: Context) {
emit(2, 8, "Skipping uv bootstrap", "Using venv pip directly", "")
// ---- Stage 3: Clone hermes-agent ----
if (!hermesRepo.exists() || !File(hermesRepo, ".git").exists()) {
// B3 followup: on Termux we can't probe hermesRepo from our
// sandbox, so use the markerDir-resident `hermesRepoClonedMarker`
// as the skip signal. On Bundled, fall back to the original
// directory probe.
val alreadyCloned = when (backend) {
Backend.TERMUX -> hermesRepoClonedMarker.exists()
Backend.BUNDLED -> hermesRepo.exists() && File(hermesRepo, ".git").exists()
Backend.NONE -> false
}
if (!alreadyCloned) {
emit(3, 8, "Cloning hermes-agent", "git clone from NousResearch/hermes-agent", "")
val pin = readPinnedSha() ?: run {
emit(3, 8, "Cloning failed", "no pinned SHA in scripts/hermes-agent-version.txt", "", error = "no_pinned_sha")
@@ -233,22 +349,42 @@ class HermesInstaller(private val context: Context) {
emit(3, 8, "Clone failed", clone.stderr.take(500), "", error = clone.stderr)
return
}
// Record success so we skip this stage on re-runs.
try { hermesRepoClonedMarker.writeText("cloned-at=${System.currentTimeMillis()}\n") }
catch (e: Exception) { Log.w(TAG, "failed to write repo-cloned marker: ${e.message}") }
applyPatches()
} else {
emit(3, 8, "hermes-agent already cloned", hermesRepo.absolutePath, "")
}
// ---- Stage 4: Create venv ----
if (!hermesVenv.exists()) {
emit(4, 8, "Creating Python venv", "python3.11 -m venv venv", "")
// B3 followup: same Termux-sandbox-can't-probe-Termux pattern
// as stage 3. Also switch the interpreter name from `python3.11`
// to `python` — Termux's `pkg install python` ships the binary
// as `python` (symlinked to the current 3.x), while the
// Bundled tarball still provides `python3.11` at a known path.
val alreadyVenv = when (backend) {
Backend.TERMUX -> hermesVenvCreatedMarker.exists()
Backend.BUNDLED -> hermesVenv.exists()
Backend.NONE -> false
}
val pythonBin = when (backend) {
Backend.TERMUX -> "python"
Backend.BUNDLED -> "python3.11"
Backend.NONE -> "python"
}
if (!alreadyVenv) {
emit(4, 8, "Creating Python venv", "$pythonBin -m venv venv", "")
val venv = runShell(
listOf("python3.11", "-m", "venv", "venv"),
listOf(pythonBin, "-m", "venv", "venv"),
cwd = hermesRepo,
)
if (venv.exitCode != 0) {
emit(4, 8, "venv creation failed", venv.stderr.take(500), "", error = venv.stderr)
return
}
try { hermesVenvCreatedMarker.writeText("created-at=${System.currentTimeMillis()}\n") }
catch (e: Exception) { Log.w(TAG, "failed to write venv-created marker: ${e.message}") }
} else {
emit(4, 8, "venv already exists", hermesVenv.absolutePath, "")
}
@@ -284,6 +420,17 @@ class HermesInstaller(private val context: Context) {
emit(8, 8, "hermes doctor failed", doctor.stdout + doctor.stderr, "", error = "doctor_failed")
return
}
// Workstream C: persist a marker so subsequent checkInstall()
// calls don't need to re-run the doctor on every invocation.
try {
hermesVerifiedMarker.parentFile?.mkdirs()
hermesVerifiedMarker.writeText("verified-at=${System.currentTimeMillis()}\nbackend=${backend.name}\n")
} catch (e: Exception) {
// Non-fatal: the install succeeded; just means the next
// checkInstall() will report verified=false until the next
// explicit doctor call. Log and continue.
Log.w(TAG, "failed to write verified marker: ${e.message}")
}
emit(8, 8, "Install complete", "hermes-agent is installed and ready", doctor.stdout, null)
}
@@ -298,8 +445,12 @@ class HermesInstaller(private val context: Context) {
_progress.emit(Stage(step, totalSteps, title, detail, log, error))
}
private fun runPipInstall(): BundledPythonRunner.PythonResult {
return bundled.runPython(
private suspend fun runPipInstall(): BundledPythonRunner.PythonResult = when (currentBackend()) {
Backend.TERMUX -> termux.runAndWait(
command = "./venv/bin/pip install -e .[termux-all] -c constraints-termux.txt --disable-pip-version-check --no-cache-dir",
cwd = hermesRepo.absolutePath,
)
Backend.BUNDLED -> bundled.runPython(
argv = listOf(
"-m", "pip", "install",
"-e", ".[termux-all]",
@@ -309,6 +460,12 @@ class HermesInstaller(private val context: Context) {
),
cwd = hermesRepo,
)
Backend.NONE -> BundledPythonRunner.PythonResult(
exitCode = -1,
stdout = "",
stderr = "No Python backend available (neither Termux nor bundled).",
process = null,
)
}
private fun writeConfig() {
@@ -353,31 +510,46 @@ class HermesInstaller(private val context: Context) {
null
}
private fun applyPatches() {
private suspend fun applyPatches() {
// Patches ship as a read-only asset directory
// "hermes-agent-patches/". For each .patch file, run
// `git apply --check` then `git apply`. Failures are logged
// but non-fatal — the user can intervene.
// "hermes-agent-patches/". For each .patch file, dispatch
// `git apply --check` then `git apply` via TermuxRunner
// (Workstream C). Failures are logged but non-fatal — the
// user can intervene.
//
// Mechanic: our app's process cannot write into Termux's
// $PREFIX (cross-app sandboxing), but Termux can write to its
// own /data/data/com.termux/files/usr/tmp. So we ship the
// patch content INLINE via a bash heredoc instead of
// round-tripping through a shared file. Termux runs the
// heredoc, gets the patch in $PREFIX/tmp, then applies it.
val patchDir = "hermes-agent-patches"
try {
val files = context.assets.list(patchDir) ?: return
for (name in files.sorted()) {
if (!name.endsWith(".patch")) continue
val content = context.assets.open("$patchDir/$name").bufferedReader().readText()
val checkProc = ProcessBuilder("git", "apply", "--check").directory(hermesRepo)
.redirectErrorStream(true).start()
checkProc.outputStream.bufferedWriter().use { it.write(content) }
checkProc.outputStream.close()
val checkExit = checkProc.waitFor()
if (checkExit != 0) {
Log.w(TAG, "patch $name: git apply --check failed; skipping")
val termuxTmp = "/data/data/${TermuxProbe.TERMUX_PACKAGE}/files/usr/tmp"
val tmpPatch = "$termuxTmp/hermes-patch-$name"
// Heredoc with a sentinel that's vanishingly unlikely
// to appear in any real patch text.
val sentinel = "HERMES_PATCH_EOF_${System.nanoTime()}"
val writeCmd = "cat > '$tmpPatch' <<'$sentinel'\n$content\n$sentinel\n"
val write = runShell(listOf("bash", "-c", writeCmd), cwd = hermesRepo)
if (write.exitCode != 0) {
Log.w(TAG, "patch $name: write to Termux tmp failed: ${write.stderr.take(200)}")
continue
}
val check = runShell(listOf("git", "apply", "--check", tmpPatch), cwd = hermesRepo)
if (check.exitCode != 0) {
Log.w(TAG, "patch $name: git apply --check failed; skipping: ${check.stderr.take(200)}")
continue
}
val apply = runShell(listOf("git", "apply", tmpPatch), cwd = hermesRepo)
if (apply.exitCode != 0) {
Log.w(TAG, "patch $name: git apply failed: ${apply.stderr.take(200)}")
continue
}
val applyProc = ProcessBuilder("git", "apply").directory(hermesRepo)
.redirectErrorStream(true).start()
applyProc.outputStream.bufferedWriter().use { it.write(content) }
applyProc.outputStream.close()
applyProc.waitFor()
Log.i(TAG, "patch $name: applied")
}
} catch (e: Exception) {
@@ -385,19 +557,63 @@ class HermesInstaller(private val context: Context) {
}
}
/** Run an arbitrary shell command via `ProcessBuilder`. For
* Termux installs, this should be dispatched via TermuxRunner
* instead — this is the bundled-Python path's escape hatch. */
private fun runShell(
/**
* Run an arbitrary shell command. Backend-aware:
*
* - **TERMUX**: dispatches via [TermuxRunner.runAndWait] (real exit
* code + truncated stdout/stderr via `RUN_COMMAND_PENDING_INTENT`).
* - **BUNDLED**: shells through the bundled CPython's
* `subprocess.run` (the legacy escape hatch — preserves the
* pre-Workstream-B behavior for the bundled path).
* - **NONE**: returns an error result; the caller should have
* bailed at the Stage 1 probe.
*/
private suspend fun runShell(
argv: List<String>,
cwd: File? = null,
): BundledPythonRunner.PythonResult = when (currentBackend()) {
Backend.TERMUX -> runShellViaTermux(argv, cwd)
Backend.BUNDLED -> bundled.runPython(
argv = listOf(
"-c",
"import subprocess; print(subprocess.run(${argvToPyList(argv)}, cwd='${cwd?.absolutePath ?: ""}').returncode)",
),
cwd = cwd,
)
Backend.NONE -> BundledPythonRunner.PythonResult(
exitCode = -1,
stdout = "",
stderr = "No Python backend available (neither Termux nor bundled).",
process = null,
)
}
/**
* Dispatch a shell command to Termux's RunCommandService and await
* the result via the `RUN_COMMAND_PENDING_INTENT` mechanism
* (Workstream C). Returns a [BundledPythonRunner.PythonResult] with
* the real exit code + truncated stdout/stderr (~100KB combined cap
* imposed by Termux; original lengths available via
* `*_ORIGINAL_LENGTH` bundle keys, not currently surfaced).
*
* If Termux is not installed or `com.termux.permission.RUN_COMMAND`
* has not been granted, [TermuxRunner.runAndWait] returns a
* `PythonResult` with `exitCode = -1` and a diagnostic stderr that
* the install UI surfaces verbatim (see
* [com.nousresearch.hermes.ui.onboarding.InstallScreen]'s
* permission-needed guidance card).
*/
private suspend fun runShellViaTermux(
argv: List<String>,
cwd: File? = null,
): BundledPythonRunner.PythonResult {
return bundled.runPython(
argv = listOf("-c", "import subprocess; print(subprocess.run(${argvToPyList(argv)}, cwd='${cwd?.absolutePath ?: ""}').returncode)"),
cwd = cwd,
)
val command = argv.joinToString(" ") { shellQuote(it) }
return termux.runAndWait(command, cwd = cwd?.absolutePath)
}
/** POSIX-safe single-quote escaping for a single shell argument. */
private fun shellQuote(s: String): String = "'" + s.replace("'", "'\\''") + "'"
private fun argvToPyList(argv: List<String>): String {
return argv.joinToString(", ") { "\"${it.replace("\\", "\\\\").replace("\"", "\\\"")}\"" }
}
@@ -453,14 +669,36 @@ class HermesInstaller(private val context: Context) {
}
/** Read the upstream hermes-agent version (the `__version__`
* string from `hermes_agent/__init__.py`). */
fun getHermesVersion(): String? {
if (!hermesPython.exists()) return null
val r = bundled.runPython(
argv = listOf("-c", "import importlib.metadata; print(importlib.metadata.version('hermes-agent'))"),
cwd = hermesRepo,
)
return if (r.exitCode == 0) r.stdout.trim() else null
* string from `hermes_agent/__init__.py`).
*
* Workstream C B5 followup: backend-aware. On Termux, the venv
* lives in `/data/data/com.termux/files/...` which our app can't
* probe directly; we dispatch through [TermuxRunner.runAndWait]
* to ask Termux to run the importlib.metadata one-liner inside
* its own venv. On Bundled we keep the direct
* `bundled.runPython` invocation. Suspend because the Termux
* path is async. */
suspend fun getHermesVersion(): String? = when (currentBackend()) {
Backend.TERMUX -> {
val r = termux.runAndWait(
command = "./venv/bin/python -c " +
"'import importlib.metadata; print(importlib.metadata.version(\"hermes-agent\"))'",
cwd = hermesRepo.absolutePath,
)
if (r.exitCode == 0) r.stdout.trim().takeIf { it.isNotEmpty() } else null
}
Backend.BUNDLED -> {
if (!hermesPython.exists()) {
null
} else {
val r = bundled.runPython(
argv = listOf("-c", "import importlib.metadata; print(importlib.metadata.version('hermes-agent'))"),
cwd = hermesRepo,
)
if (r.exitCode == 0) r.stdout.trim() else null
}
}
Backend.NONE -> null
}
/** Re-read the version on demand (used by the renderer's
@@ -38,6 +38,27 @@ object TermuxProbe {
return packageVersion(context, TERMUX_PACKAGE)
}
/**
* Workstream C: Termux >= 0.109 is required for the
* `RUN_COMMAND_PENDING_INTENT` mechanism that `TermuxRunner.runAndWait`
* depends on (older Termux silently drops the extra and runs
* fire-and-forget, leaving our continuation hanging forever).
*
* Current F-Droid Termux is 0.119+, so this should always pass for
* users who installed via the Workstream A F-Droid deep-link.
* Side-loaders running ancient builds get a graceful diagnostic
* via [InstallScreen]'s permission-needed surface.
*
* Returns true if Termux is installed AND the parsed version is
* at or above the floor. Returns false if not installed, version
* unparseable, or below the floor.
*/
fun isRunCommandResultSupported(context: Context): Boolean {
if (!isTermuxInstalled(context)) return false
val raw = termuxVersion(context) ?: return false
return isVersionAtLeast(raw, RUN_COMMAND_PENDING_INTENT_MIN_VERSION)
}
/** Returns the absolute path Termux uses for its $PREFIX. */
fun termuxHome(context: Context): String? {
// Termux stores its root at /data/data/com.termux/files/ — we
@@ -68,4 +89,28 @@ object TermuxProbe {
null
}
}
/**
* Parses a dotted version string (e.g. "0.119.1", "0.109") into a
* list of ints and compares lexicographically against `floor`.
* Tolerant of trailing non-numeric junk (e.g. "0.119.1-debug" →
* [0, 119, 1]) — the dash-suffix is dropped on parsing the last
* segment.
*/
internal fun isVersionAtLeast(raw: String, floor: List<Int>): Boolean {
val actual = raw.split(".").map { seg ->
seg.takeWhile { it.isDigit() }.toIntOrNull() ?: return false
}
val len = maxOf(actual.size, floor.size)
for (i in 0 until len) {
val a = actual.getOrElse(i) { 0 }
val f = floor.getOrElse(i) { 0 }
if (a > f) return true
if (a < f) return false
}
return true // exactly equal
}
/** Floor for `RUN_COMMAND_PENDING_INTENT` (Termux >= 0.109). */
private val RUN_COMMAND_PENDING_INTENT_MIN_VERSION = listOf(0, 109)
}
@@ -0,0 +1,244 @@
package com.nousresearch.hermes
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.util.Log
import androidx.core.content.ContextCompat
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
/**
* Receives the result broadcast Termux's RunCommandService sends back
* to our process via the PendingIntent attached to RUN_COMMAND with the
* `com.termux.RUN_COMMAND_PENDING_INTENT` extra.
*
* Termux puts the result in an outer Bundle keyed `"result"` on the
* fired Intent's extras. Inside that Bundle: `stdout`, `stderr`,
* `stdout_original_length`, `stderr_original_length`, `exitCode`,
* `err`, `errmsg` — exact key names verified from
* `termux-shared/.../TermuxConstants.java`.
*
* The receiver is registered dynamically by [TermuxResultRegistry] for
* exactly one action per pending command (action =
* `com.nousresearch.hermes.TERMUX_RESULT.<sessionId>`). It looks up the
* waiting `Continuation` and resumes it with a
* [BundledPythonRunner.PythonResult] carrying the real exit code +
* truncated stdout/stderr.
*/
internal class TermuxResultReceiver(private val sessionId: Int) : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
// Per TermuxConstants: outer Bundle extra is "result".
val bundle = intent.getBundleExtra(KEY_RESULT_BUNDLE)
val result = if (bundle != null) {
BundledPythonRunner.PythonResult(
exitCode = bundle.getInt(KEY_EXIT_CODE, -1),
stdout = bundle.getString(KEY_STDOUT) ?: "",
stderr = bundle.getString(KEY_STDERR) ?: "",
process = null,
)
} else {
// No "result" bundle — something dispatched the broadcast
// without going through RunCommandService. Treat as
// dispatch failure.
BundledPythonRunner.PythonResult(
exitCode = -1,
stdout = "",
stderr = "TermuxResultReceiver: no result bundle in broadcast",
process = null,
)
}
// `err` (Termux internal error code; RESULT_OK == -1 == no error)
// surfaces dispatch-level failures like missing RUN_COMMAND
// permission. Promote those to a non-zero exitCode with a
// diagnostic stderr so the install UI's existing failure path
// surfaces them verbatim.
val termuxErr = bundle?.getInt(KEY_ERR, RESULT_OK) ?: RESULT_OK
val finalResult = if (termuxErr != RESULT_OK && result.exitCode == 0) {
val errmsg = bundle?.getString(KEY_ERRMSG) ?: "Termux returned err=$termuxErr"
result.copy(
exitCode = if (termuxErr != 0) termuxErr else -1,
stderr = if (result.stderr.isEmpty()) errmsg else "${result.stderr}\n$errmsg",
)
} else {
result
}
Log.d(TAG, "result sessionId=$sessionId exitCode=${finalResult.exitCode} err=$termuxErr stdoutBytes=${finalResult.stdout.length} stderrBytes=${finalResult.stderr.length}")
TermuxResultRegistry.deliver(sessionId, finalResult)
}
companion object {
private const val TAG = "TermuxResultReceiver"
// Verified against termux-shared TermuxConstants.java
// (https://raw.githubusercontent.com/termux/termux-app/master/termux-shared/src/main/java/com/termux/shared/termux/TermuxConstants.java)
const val KEY_RESULT_BUNDLE = "result"
const val KEY_STDOUT = "stdout"
const val KEY_STDERR = "stderr"
const val KEY_STDOUT_ORIG_LEN = "stdout_original_length"
const val KEY_STDERR_ORIG_LEN = "stderr_original_length"
const val KEY_EXIT_CODE = "exitCode"
const val KEY_ERR = "err"
const val KEY_ERRMSG = "errmsg"
// Termux uses Activity.RESULT_OK (== -1) to mean "no internal error".
// Anything else (e.g. 1 for "permission denied") is a Termux-side
// dispatch failure, distinct from the command's own exit code.
const val RESULT_OK = -1
/** Action prefix; each pending command gets a unique suffix
* (the session ID) so concurrent installs don't race. */
const val ACTION_PREFIX = "com.nousresearch.hermes.TERMUX_RESULT"
}
}
/**
* Process-singleton bridge between [TermuxRunner.runAndWait] (caller
* side, holds a [Continuation]) and [TermuxResultReceiver] (receiver
* side, gets the broadcast back from Termux).
*
* Lifecycle per command:
* 1. caller calls [register] → gets a unique session ID, the registry
* stashes the continuation, allocates a [TermuxResultReceiver] for
* that session ID, registers it with [Context.registerReceiver] on
* a unique action filter, and constructs a `PendingIntent.getBroadcast`
* the caller hands to Termux's RunCommandService via the
* `com.termux.RUN_COMMAND_PENDING_INTENT` Intent extra.
* 2. Termux runs the command, then fires our PendingIntent with the
* result Bundle. Our [TermuxResultReceiver.onReceive] calls
* [deliver].
* 3. [deliver] looks up the continuation, calls `cont.resume(result)`,
* unregisters the receiver, removes the map entry.
* 4. If the caller cancels first (coroutine cancellation), [cancel]
* is invoked from `cont.invokeOnCancellation` and we unregister
* without resuming.
*
* Thread-safe: backing map is a [ConcurrentHashMap]; session ID is an
* [AtomicInteger]. `remove` returns null on multi-resolve so we won't
* double-resume a continuation.
*/
internal object TermuxResultRegistry {
private const val TAG = "TermuxResultRegistry"
private val nextSessionId = AtomicInteger(1)
private val pending = ConcurrentHashMap<Int, Entry>()
private data class Entry(
val cont: Continuation<BundledPythonRunner.PythonResult>,
val receiver: BroadcastReceiver,
val context: Context,
)
/**
* Register a continuation and a per-session receiver. Returns the
* session ID; the caller embeds it in the result PendingIntent's
* action string so the receiver knows which entry to resume.
*/
fun register(
context: Context,
cont: Continuation<BundledPythonRunner.PythonResult>,
): Int {
val sessionId = nextSessionId.getAndIncrement()
val action = "${TermuxResultReceiver.ACTION_PREFIX}.$sessionId"
val receiver = TermuxResultReceiver(sessionId)
val filter = IntentFilter(action)
// Use the application context to outlive any single activity
// and to keep the receiver scope bounded to our process.
// RECEIVER_NOT_EXPORTED on API 33+ (handled by ContextCompat
// for older API levels).
ContextCompat.registerReceiver(
context.applicationContext,
receiver,
filter,
ContextCompat.RECEIVER_NOT_EXPORTED,
)
pending[sessionId] = Entry(cont, receiver, context.applicationContext)
Log.d(TAG, "register sessionId=$sessionId action=$action (pending=${pending.size})")
return sessionId
}
/**
* Build the action string for a session ID — used by [TermuxRunner]
* when constructing the result PendingIntent.
*/
fun actionForSession(sessionId: Int): String =
"${TermuxResultReceiver.ACTION_PREFIX}.$sessionId"
/**
* Called by [TermuxResultReceiver.onReceive] when Termux fires the
* PendingIntent. Idempotent — a duplicate broadcast finds no entry
* and is a no-op.
*/
fun deliver(sessionId: Int, result: BundledPythonRunner.PythonResult) {
val entry = pending.remove(sessionId) ?: run {
Log.w(TAG, "deliver sessionId=$sessionId: no pending continuation (cancelled or duplicate)")
return
}
try {
entry.context.unregisterReceiver(entry.receiver)
} catch (e: IllegalArgumentException) {
// Receiver already unregistered (e.g. cancel race). Safe to ignore.
}
entry.cont.resume(result)
}
/**
* Called from `cont.invokeOnCancellation`. Removes the entry and
* unregisters the receiver WITHOUT resuming (the continuation is
* already cancelled). Safe to call after `deliver` already removed
* the entry — `remove` returns null and we no-op.
*/
fun cancel(sessionId: Int) {
val entry = pending.remove(sessionId) ?: return
try {
entry.context.unregisterReceiver(entry.receiver)
} catch (e: IllegalArgumentException) {
// Already unregistered.
}
Log.d(TAG, "cancel sessionId=$sessionId (pending=${pending.size})")
}
/**
* Caller-side: synthesize a failure result without going through
* the broadcast pipeline (e.g. when `startForegroundService` throws
* synchronously before Termux ever sees the intent). Removes the
* entry, unregisters the receiver, resumes the continuation.
*/
fun resolveSynthetic(sessionId: Int, result: BundledPythonRunner.PythonResult) {
deliver(sessionId, result)
}
/**
* Construct the PendingIntent the caller attaches to the
* RUN_COMMAND intent as
* `com.termux.RUN_COMMAND_PENDING_INTENT`. Uses
* [PendingIntent.FLAG_MUTABLE] because Termux's RunCommandService
* mutates the intent extras (it adds the result Bundle before
* firing); [PendingIntent.FLAG_IMMUTABLE] throws
* IllegalArgumentException at startForegroundService time on
* API 31+.
*
* The result Intent is scoped to our own package via
* `setPackage` so only our process receives the broadcast.
*/
fun pendingIntentFor(context: Context, sessionId: Int): PendingIntent {
val resultIntent = Intent(actionForSession(sessionId))
.setPackage(context.packageName)
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
return PendingIntent.getBroadcast(
context.applicationContext,
sessionId,
resultIntent,
flags,
)
}
}
@@ -5,8 +5,10 @@ import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
/**
* Runs shell commands inside a Termux environment via the
@@ -108,6 +110,121 @@ class TermuxRunner(private val context: Context) {
}
}
/**
* Workstream C variant of [run] that blocks the calling coroutine
* until Termux's RunCommandService broadcasts back the result via
* the `com.termux.RUN_COMMAND_PENDING_INTENT` mechanism (requires
* Termux >= 0.109; current F-Droid Termux 0.119+ is well above the
* floor).
*
* Returns a [BundledPythonRunner.PythonResult] populated with the
* REAL exit code, REAL stdout, REAL stderr — making every
* `HermesInstaller` shell stage that gates on `exitCode != 0`
* work correctly on the Termux backend.
*
* Caveats:
* - stdout+stderr combined is truncated to ~100KB by Termux
* (`stdout_original_length` / `stderr_original_length` carry
* the pre-truncation size; we don't currently surface those).
* - If the user hasn't granted `com.termux.permission.RUN_COMMAND`
* OR Termux's `~/.termux/termux.properties` doesn't have
* `allow-external-apps=true`, RunCommandService still fires the
* result PendingIntent but with `err != RESULT_OK` and an
* `errmsg`; the receiver promotes that to a non-zero exit code
* so InstallScreen surfaces the actionable error.
* - On Android pre-O (API < 26) [Context.startService] is used
* in place of [Context.startForegroundService]; for the same
* fire-and-forget reasons as [run].
*
* Cancellation: if the coroutine is cancelled before Termux fires
* the result PendingIntent, the receiver is unregistered via
* [TermuxResultRegistry.cancel] and the continuation is never
* resumed (consistent with `suspendCancellableCoroutine` semantics).
* The dispatched command may still complete inside Termux — there
* is no way to abort a RunCommand mid-flight from the caller side.
*
* Timeouts: callers wrap with `kotlinx.coroutines.withTimeout`
* when they want bounded waits (e.g. stage 5 pip install allows
* up to 30 minutes). This method does not impose its own timeout.
*/
suspend fun runAndWait(
command: String,
cwd: String? = null,
): BundledPythonRunner.PythonResult = suspendCancellableCoroutine { cont ->
if (!TermuxProbe.isTermuxInstalled(context)) {
cont.resume(
BundledPythonRunner.PythonResult(
exitCode = -1,
stdout = "",
stderr = "Termux is not installed",
process = null,
),
)
return@suspendCancellableCoroutine
}
val sessionId = TermuxResultRegistry.register(context, cont)
val resultPi = TermuxResultRegistry.pendingIntentFor(context, sessionId)
cont.invokeOnCancellation { TermuxResultRegistry.cancel(sessionId) }
val intent = Intent().apply {
component = ComponentName(
TermuxProbe.TERMUX_PACKAGE,
"com.termux.app.RunCommandService",
)
action = "com.termux.RUN_COMMAND"
putExtra(
"com.termux.RUN_COMMAND_PATH",
"/data/data/${TermuxProbe.TERMUX_PACKAGE}/files/usr/bin/bash",
)
putExtra("com.termux.RUN_COMMAND_ARGUMENTS", arrayOf("-c", command))
// Always run in background; we don't need an interactive
// Termux session for these install commands. Foreground
// mode also makes RUN_COMMAND_PENDING_INTENT misbehave
// (per the Termux wiki: stderr is null in foreground).
putExtra("com.termux.RUN_COMMAND_BACKGROUND", true)
if (cwd != null) {
putExtra("com.termux.RUN_COMMAND_WORKDIR", cwd)
}
// The crucial Workstream C piece — without this extra
// RunCommandService runs fire-and-forget (the current
// run() path).
putExtra("com.termux.RUN_COMMAND_PENDING_INTENT", resultPi)
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
} catch (e: SecurityException) {
// Same Android 12+ background-start guard as run(). We
// synthesize a failure result so the caller doesn't hang.
Log.e(TAG, "SecurityException dispatching RUN_COMMAND: ${e.message}")
TermuxResultRegistry.resolveSynthetic(
sessionId,
BundledPythonRunner.PythonResult(
exitCode = -1,
stdout = "",
stderr = "TermuxRunner: SecurityException (${e.message ?: "unknown"})",
process = null,
),
)
} catch (e: Exception) {
Log.e(TAG, "Failed to dispatch RUN_COMMAND: ${e.message}")
TermuxResultRegistry.resolveSynthetic(
sessionId,
BundledPythonRunner.PythonResult(
exitCode = -1,
stdout = "",
stderr = "TermuxRunner: dispatch failed (${e.message ?: "unknown"})",
process = null,
),
)
}
}
/**
* Tail the most recent N bytes of a Termux log file. Used by
* [HermesInstaller] to stream the install output to the renderer's
@@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -23,10 +25,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.nousresearch.hermes.HermesApi
import com.nousresearch.hermes.HermesInstaller
import com.nousresearch.hermes.R
import kotlinx.coroutines.flow.collectLatest
/**
@@ -38,6 +42,13 @@ import kotlinx.coroutines.flow.collectLatest
* [HermesInstaller.startInstall] internally). On success, the
* stage emits `Complete` and the screen calls
* [HermesApi.setAppState] to advance to Setup.
*
* Workstream C: when the install fails with an error that looks
* like a Termux RUN_COMMAND permission denial (substring match
* on "permission" or "RUN_COMMAND"), surface a guidance card with
* an "Open App Settings" button alongside the generic Retry —
* Termux only returns `err != RESULT_OK` for these specific
* dispatch-level failures, so the heuristic is reliable.
*/
@Composable
fun InstallScreen(hermes: HermesApi) {
@@ -119,6 +130,13 @@ fun InstallScreen(hermes: HermesApi) {
text = "Install failed: $installError",
color = MaterialTheme.colorScheme.error,
)
if (looksLikeTermuxPermissionError(installError)) {
Spacer(Modifier.height(8.dp))
TermuxPermissionNeededCard(
onOpenSettings = { hermes.openAppSettings() },
)
}
Spacer(Modifier.height(8.dp))
Button(
onClick = {
installError = null
@@ -144,6 +162,60 @@ fun InstallScreen(hermes: HermesApi) {
}
}
/**
* Heuristic: does this install error look like Termux refusing our
* RUN_COMMAND dispatch because the permission hasn't been granted (or
* Termux's `allow-external-apps` is off)? Termux's RunCommandService
* surfaces these via `err != RESULT_OK` with an errmsg that our
* [com.nousresearch.hermes.TermuxResultReceiver] promotes to the
* stderr field.
*
* Workstream C B6 followup: tightened to avoid false positives on
* pip's "Permission denied" filesystem errors. We now match only on
* tokens that are Termux-specific:
* - "RUN_COMMAND" (verbatim API name in Termux's errmsg)
* - "allow-external-apps" (Termux property the user must enable)
* - "plugin_action_disabled" (Termux's actual errmsg key when
* allow-external-apps is unset)
* The bare word "permission" alone is NOT a signal — pip prints it
* routinely for unrelated FS errors.
*/
private fun looksLikeTermuxPermissionError(error: String?): Boolean {
if (error == null) return false
val lower = error.lowercase()
return "run_command" in lower ||
"allow-external-apps" in lower ||
"plugin_action_disabled" in lower
}
@Composable
private fun TermuxPermissionNeededCard(onOpenSettings: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.termux_permission_needed_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.termux_permission_needed_message),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Spacer(Modifier.height(12.dp))
Button(onClick = onOpenSettings) {
Text(stringResource(R.string.termux_permission_open_settings))
}
}
}
}
@Composable
private fun StageRow(stage: HermesInstaller.Stage, isCurrent: Boolean) {
Row(
@@ -14,6 +14,7 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -22,8 +23,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.nousresearch.hermes.HermesApi
import com.nousresearch.hermes.R
/**
* WelcomeScreen — the first interactive onboarding screen.
@@ -36,6 +39,11 @@ import com.nousresearch.hermes.HermesApi
* The local-install CTAs (Termux / bundled Python) are still
* present for the future Path B (full Hermes gateway on-device)
* but are de-emphasized.
*
* Workstream A (atomic-wondering-sunrise plan): when Termux is
* missing, the Termux card renders two stacked CTAs that deep-link
* to F-Droid (primary) and GitHub Releases (secondary) instead of
* showing a dead disabled Continue button.
*/
@Composable
fun WelcomeScreen(hermes: HermesApi) {
@@ -62,12 +70,13 @@ fun WelcomeScreen(hermes: HermesApi) {
InstallOptionCard(
title = "Just configure the API key",
subtitle = "Skip the local install and connect directly to an OpenAI-compatible API (e.g. MiniMax, OpenRouter). You'll just need the model name + API key.",
enabled = true,
primary = true,
onClick = {
hermes.setAppState(HermesApi.AppState.Setup)
},
)
) {
Button(
onClick = { hermes.setAppState(HermesApi.AppState.Setup) },
colors = ButtonDefaults.buttonColors(),
) { Text("Continue") }
}
InstallOptionCard(
title = "Install locally (Termux)",
@@ -76,32 +85,56 @@ fun WelcomeScreen(hermes: HermesApi) {
} else {
"Termux not detected. Install Termux from F-Droid first, then come back."
},
enabled = termux.installed,
primary = false,
onClick = {
hermes.setAppState(HermesApi.AppState.Installing)
},
)
) {
if (termux.installed) {
OutlinedButton(
onClick = { hermes.setAppState(HermesApi.AppState.Installing) },
) { Text("Continue") }
} else {
Button(
onClick = { hermes.openExternal(F_DROID_TERMUX_URL) },
colors = ButtonDefaults.buttonColors(),
) { Text(stringResource(R.string.termux_install_action)) }
Spacer(Modifier.height(4.dp))
TextButton(
onClick = { hermes.openExternal(GITHUB_TERMUX_URL) },
) { Text(stringResource(R.string.termux_install_github)) }
}
}
InstallOptionCard(
title = "Install locally (bundled Python)",
subtitle = "Use the Python runtime bundled with the Hermes APK. No Termux required. (Coming soon — the bundled-Python path is a future Path B.)",
enabled = false,
primary = false,
onClick = {
hermes.setAppState(HermesApi.AppState.Installing)
},
)
) {
OutlinedButton(
onClick = { hermes.setAppState(HermesApi.AppState.Installing) },
enabled = false,
) { Text("Continue") }
}
}
}
/**
* Termux install URLs surfaced from the Termux-missing card.
*
* F-Droid is the primary CTA because it's the only official
* distribution channel maintained by the Termux project today
* (the Play Store build was abandoned years ago and is
* incompatible with current Termux:API). GitHub Releases is a
* secondary fallback for users who don't have F-Droid or who
* prefer side-loading the signed APK directly.
*/
private const val F_DROID_TERMUX_URL = "https://f-droid.org/packages/com.termux/"
private const val GITHUB_TERMUX_URL = "https://github.com/termux/termux-app/releases"
@Composable
private fun InstallOptionCard(
title: String,
subtitle: String,
enabled: Boolean,
primary: Boolean,
onClick: () -> Unit,
actions: @Composable () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
@@ -131,18 +164,7 @@ private fun InstallOptionCard(
MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
if (primary) {
Button(
onClick = onClick,
enabled = enabled,
colors = ButtonDefaults.buttonColors(),
) { Text("Continue") }
} else {
OutlinedButton(
onClick = onClick,
enabled = enabled,
) { Text("Continue") }
}
actions()
}
}
}
@@ -8,10 +8,34 @@
<string name="gateway_notif_action_stop">Stop</string>
<string name="gateway_notif_action_open">Open Hermes</string>
<!-- Termux-missing install dialog (Phase 2) -->
<!--
Termux install CTAs. Surfaced from WelcomeScreen.kt's middle
InstallOptionCard when TermuxProbe reports Termux is not
installed. F-Droid is the primary action because the Play
Store build of Termux was abandoned years ago and is
incompatible with current Termux:API; GitHub Releases is the
side-load fallback. The Phase-2 missing-Termux dialog strings
are kept for future use (e.g. a snackbar from the install
stage if Termux disappears mid-install).
-->
<string name="termux_missing_title">Termux Required</string>
<string name="termux_missing_message">To run hermes-agent locally on Android, install Termux and Termux:API from F-Droid. Then return to Hermes to continue.</string>
<string name="termux_install_action">Open F-Droid</string>
<string name="termux_install_github">Or get it from GitHub Releases</string>
<!--
Workstream C (RESULT_INTENT wiring). Surfaced from InstallScreen
when an install stage fails with ERR != RESULT_OK, which Termux's
RunCommandService returns when our app lacks
com.termux.permission.RUN_COMMAND or when Termux's
~/.termux/termux.properties does not have
allow-external-apps=true. The "Open App Settings" button uses
HermesApi.openExternal with package:com.nousresearch.hermes so
the user lands directly on the Additional Permissions screen.
-->
<string name="termux_permission_needed_title">Termux permission needed</string>
<string name="termux_permission_needed_message">Hermes needs permission to run commands inside Termux. Grant the “Run commands inside Termux environment” permission in App info → Additional permissions, then retry. If you still see this after granting, open Termux and add allow-external-apps=true to ~/.termux/termux.properties, then run termux-reload-settings.</string>
<string name="termux_permission_open_settings">Open App Settings</string>
<!--
Pinned hermes-agent SHA. Updated by bumping
@@ -100,4 +100,12 @@
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="com.termux.permission.RUN_COMMAND" />
<queries>
<package android:name="com.termux" />
<package android:name="com.termux.api" />
<intent>
<action android:name="com.termux.RUN_COMMAND" />
</intent>
</queries>
</manifest>
@@ -250,6 +250,26 @@ class HermesApi(private val context: Context) {
context.startActivity(intent)
}
/**
* Workstream C: open this app's "App info" page in system Settings.
* Used by [com.nousresearch.hermes.ui.onboarding.InstallScreen]'s
* Termux-permission-needed guidance card so the user can land
* directly on the screen where `com.termux.permission.RUN_COMMAND`
* is granted (Settings → Apps → Hermes → Additional permissions).
*
* Mirrors the same intent shape [BatteryOptHelper.buildAppDetailsIntent]
* uses; we don't reuse that helper because it's named for a
* different use case and exposing two callers would be misleading.
*/
fun openAppSettings() {
val intent = android.content.Intent(
android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
android.net.Uri.fromParts("package", context.packageName, null),
)
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
data class SshConfigView(
val host: String,
val port: Int,
@@ -329,7 +349,7 @@ class HermesApi(private val context: Context) {
fun adoptHermesHome(dir: String): Boolean = installer.adoptHermesHome(dir)
fun getHermesVersion(): String? = installer.getHermesVersion()
suspend fun getHermesVersion(): String? = installer.getHermesVersion()
/**
* Re-read the upstream hermes-agent version on demand. Used by the
@@ -339,7 +359,7 @@ class HermesApi(private val context: Context) {
*/
suspend fun refreshHermesVersion(): String? = installer.refreshHermesVersion()
fun runHermesDoctor(): String {
suspend fun runHermesDoctor(): String {
val r = installer.runHermesDoctor()
return r.stdout + r.stderr
}
@@ -40,6 +40,25 @@ import java.security.SecureRandom
* | 7 | Reserve gateway port | BundledPython |
* | 8 | Verify with `hermes doctor` | Termux or bundle |
*
* ## Backend dispatch (Workstream B + C, atomic-wondering-sunrise plan)
*
* [runShell], [runHermesDoctor] and [runPipInstall] each branch on
* [currentBackend] and dispatch to either [TermuxRunner.runAndWait]
* (Termux) or [BundledPythonRunner] (bundled CPython). Pre-Workstream-B,
* every shell-out went through `bundled.runPython` even on Termux,
* which made the Termux install path immediately fail at stage 3 with
* `"Bundled Python not available"`. Workstream C finished the wiring
* by switching from fire-and-forget `termux.run` to
* `termux.runAndWait` (which uses `RUN_COMMAND_PENDING_INTENT` + a
* BroadcastReceiver) so the Termux backend now reports real exit
* codes and truncated stdout/stderr (~100KB cap imposed by Termux).
*
* Per Workstream C, [checkInstall] no longer runs the doctor inline
* (avoids forcing the entire IPC surface suspend); stage 8 writes a
* [hermesVerifiedMarker] file after the doctor passes, and
* [checkInstall] reads that marker. Explicit re-verification is
* `runHermesDoctor()` from a suspend caller.
*
* ## Failure semantics
*
* Each stage catches its own exceptions and emits a Stage with a
@@ -94,6 +113,48 @@ class HermesInstaller(private val context: Context) {
val hermesConfig: File get() = File(hermesHome, "config.yaml")
val hermesEnv: File get() = File(hermesHome, ".env")
/**
* Where HermesInstaller stores install-state sentinels we need to
* read back from our app's process.
*
* Workstream C followup (B1B4): on the Termux backend,
* [hermesHome] resolves to `/data/data/com.termux/files/home/.hermes`,
* which our app's process can neither read nor write (Android
* cross-app sandbox). We can still ASK Termux (via [TermuxRunner])
* to create files under that path — but we can't directly check
* them later. So install-state sentinels (verified marker, clone
* marker, venv marker) get relocated to OUR app's `filesDir` on
* the Termux backend, while real install artifacts (the venv, the
* repo) stay in Termux's `$PREFIX` where Termux's pip/git wrote
* them. On the Bundled backend, everything lives co-located with
* the install (no sandbox crossing).
*/
private val markerDir: File
get() = when (currentBackend()) {
Backend.TERMUX -> File(context.filesDir, "hermes-markers").also { it.mkdirs() }
Backend.BUNDLED -> hermesHome.also { it.mkdirs() }
Backend.NONE -> File(context.filesDir, "hermes-markers")
}
/**
* Marker file written by stage 8 ([runStages]) after `hermes doctor`
* exits 0. [checkInstall] reads this to populate `verified` without
* having to re-run the doctor on every call (the doctor spawns a
* Python subprocess and is expensive). Manually delete to force a
* re-verification on next install. Lives under [markerDir] so the
* Termux backend can read its own write back.
*/
val hermesVerifiedMarker: File get() = File(markerDir, ".verified")
/** Workstream C B3 followup: written after stage 3 (clone) succeeds.
* Stage 3 uses this for skip-on-rerun detection on Termux backend
* (we can't check `hermesRepo.exists()` from our sandbox there). */
private val hermesRepoClonedMarker: File get() = File(markerDir, ".repo-cloned")
/** Workstream C B3 followup: written after stage 4 (venv) succeeds.
* Same skip-on-rerun semantics as [hermesRepoClonedMarker]. */
private val hermesVenvCreatedMarker: File get() = File(markerDir, ".venv-created")
/** What backend the install will use. Decided once per install. */
enum class Backend { TERMUX, BUNDLED, NONE }
@@ -131,13 +192,45 @@ class HermesInstaller(private val context: Context) {
}
/** Synchronously check current install state — the desktop's
* `checkInstall` IPC method delegates here. */
* `checkInstall` IPC method delegates here.
*
* Workstream C: `verified` no longer runs `hermes doctor`
* inline (which would force this method to suspend and cascade
* through HermesApi.init/validateChatReadiness). Instead the
* install flow's stage 8 writes [hermesVerifiedMarker] after
* the doctor passes, and we read that file here. Manual re-
* verification is a `runHermesDoctor()` call from a suspend
* caller.
*
* Workstream C B1/B2 followup: on the Termux backend we cannot
* probe Termux's `$PREFIX` from our sandbox, so `installed`
* collapses to the verified marker's existence. We can't
* distinguish "files exist but doctor hasn't run yet" from
* "nothing's there" — but the marker IS the success signal of a
* completed install flow, so this is the right semantics. On
* Bundled we keep the file-existence probe (the venv lives in
* our own filesDir on that path). */
fun checkInstall(): CheckResult {
val backend = currentBackend()
val installed = hermesPython.exists() && hermesPython.canExecute()
val configured = hermesConfig.exists() && hermesEnv.exists()
val verified = installed && runHermesDoctor().exitCode == 0
val hasApiKey = hermesEnv.exists() && hermesEnv.readLines().any { it.startsWith("API_SERVER_KEY=") && it.length > "API_SERVER_KEY=".length + 8 }
val installed = when (backend) {
Backend.TERMUX -> hermesVerifiedMarker.exists()
Backend.BUNDLED -> hermesPython.exists() && hermesPython.canExecute()
Backend.NONE -> false
}
val configured = when (backend) {
// Same sandbox reason as `installed`: on Termux we can't see
// /data/data/com.termux/.../.hermes/config.yaml from our
// process. Treat the verified marker as a proxy — if the
// install completed, config + env were generated by stage 6.
Backend.TERMUX -> hermesVerifiedMarker.exists()
Backend.BUNDLED -> hermesConfig.exists() && hermesEnv.exists()
Backend.NONE -> false
}
val verified = installed && hermesVerifiedMarker.exists()
val hasApiKey = when (backend) {
Backend.TERMUX -> verified // can't read Termux's .env; trust the marker
else -> hermesEnv.exists() && hermesEnv.readLines().any { it.startsWith("API_SERVER_KEY=") && it.length > "API_SERVER_KEY=".length + 8 }
}
return CheckResult(
installed = installed,
configured = configured,
@@ -157,12 +250,26 @@ class HermesInstaller(private val context: Context) {
val backend: String,
)
/** Run `hermes doctor` and return its combined stdout+stderr. */
fun runHermesDoctor(): BundledPythonRunner.PythonResult {
return bundled.runPython(
/** Run `hermes doctor` and return its combined stdout+stderr.
* Backend-aware: on Termux, dispatches via
* [TermuxRunner.runAndWait] (real exit code + truncated
* stdout/stderr via `RUN_COMMAND_PENDING_INTENT`). Suspend
* per Workstream C — callers must be in a coroutine context. */
suspend fun runHermesDoctor(): BundledPythonRunner.PythonResult = when (currentBackend()) {
Backend.TERMUX -> termux.runAndWait(
command = "./venv/bin/hermes doctor",
cwd = hermesRepo.absolutePath,
)
Backend.BUNDLED -> bundled.runPython(
argv = listOf("-m", "hermes_cli.main", "doctor"),
cwd = hermesRepo,
)
Backend.NONE -> BundledPythonRunner.PythonResult(
exitCode = -1,
stdout = "",
stderr = "No Python backend available (neither Termux nor bundled).",
process = null,
)
}
/** `git pull` hermes-agent at the pinned SHA and re-apply patches. */
@@ -218,7 +325,16 @@ class HermesInstaller(private val context: Context) {
emit(2, 8, "Skipping uv bootstrap", "Using venv pip directly", "")
// ---- Stage 3: Clone hermes-agent ----
if (!hermesRepo.exists() || !File(hermesRepo, ".git").exists()) {
// B3 followup: on Termux we can't probe hermesRepo from our
// sandbox, so use the markerDir-resident `hermesRepoClonedMarker`
// as the skip signal. On Bundled, fall back to the original
// directory probe.
val alreadyCloned = when (backend) {
Backend.TERMUX -> hermesRepoClonedMarker.exists()
Backend.BUNDLED -> hermesRepo.exists() && File(hermesRepo, ".git").exists()
Backend.NONE -> false
}
if (!alreadyCloned) {
emit(3, 8, "Cloning hermes-agent", "git clone from NousResearch/hermes-agent", "")
val pin = readPinnedSha() ?: run {
emit(3, 8, "Cloning failed", "no pinned SHA in scripts/hermes-agent-version.txt", "", error = "no_pinned_sha")
@@ -233,22 +349,42 @@ class HermesInstaller(private val context: Context) {
emit(3, 8, "Clone failed", clone.stderr.take(500), "", error = clone.stderr)
return
}
// Record success so we skip this stage on re-runs.
try { hermesRepoClonedMarker.writeText("cloned-at=${System.currentTimeMillis()}\n") }
catch (e: Exception) { Log.w(TAG, "failed to write repo-cloned marker: ${e.message}") }
applyPatches()
} else {
emit(3, 8, "hermes-agent already cloned", hermesRepo.absolutePath, "")
}
// ---- Stage 4: Create venv ----
if (!hermesVenv.exists()) {
emit(4, 8, "Creating Python venv", "python3.11 -m venv venv", "")
// B3 followup: same Termux-sandbox-can't-probe-Termux pattern
// as stage 3. Also switch the interpreter name from `python3.11`
// to `python` — Termux's `pkg install python` ships the binary
// as `python` (symlinked to the current 3.x), while the
// Bundled tarball still provides `python3.11` at a known path.
val alreadyVenv = when (backend) {
Backend.TERMUX -> hermesVenvCreatedMarker.exists()
Backend.BUNDLED -> hermesVenv.exists()
Backend.NONE -> false
}
val pythonBin = when (backend) {
Backend.TERMUX -> "python"
Backend.BUNDLED -> "python3.11"
Backend.NONE -> "python"
}
if (!alreadyVenv) {
emit(4, 8, "Creating Python venv", "$pythonBin -m venv venv", "")
val venv = runShell(
listOf("python3.11", "-m", "venv", "venv"),
listOf(pythonBin, "-m", "venv", "venv"),
cwd = hermesRepo,
)
if (venv.exitCode != 0) {
emit(4, 8, "venv creation failed", venv.stderr.take(500), "", error = venv.stderr)
return
}
try { hermesVenvCreatedMarker.writeText("created-at=${System.currentTimeMillis()}\n") }
catch (e: Exception) { Log.w(TAG, "failed to write venv-created marker: ${e.message}") }
} else {
emit(4, 8, "venv already exists", hermesVenv.absolutePath, "")
}
@@ -284,6 +420,17 @@ class HermesInstaller(private val context: Context) {
emit(8, 8, "hermes doctor failed", doctor.stdout + doctor.stderr, "", error = "doctor_failed")
return
}
// Workstream C: persist a marker so subsequent checkInstall()
// calls don't need to re-run the doctor on every invocation.
try {
hermesVerifiedMarker.parentFile?.mkdirs()
hermesVerifiedMarker.writeText("verified-at=${System.currentTimeMillis()}\nbackend=${backend.name}\n")
} catch (e: Exception) {
// Non-fatal: the install succeeded; just means the next
// checkInstall() will report verified=false until the next
// explicit doctor call. Log and continue.
Log.w(TAG, "failed to write verified marker: ${e.message}")
}
emit(8, 8, "Install complete", "hermes-agent is installed and ready", doctor.stdout, null)
}
@@ -298,8 +445,12 @@ class HermesInstaller(private val context: Context) {
_progress.emit(Stage(step, totalSteps, title, detail, log, error))
}
private fun runPipInstall(): BundledPythonRunner.PythonResult {
return bundled.runPython(
private suspend fun runPipInstall(): BundledPythonRunner.PythonResult = when (currentBackend()) {
Backend.TERMUX -> termux.runAndWait(
command = "./venv/bin/pip install -e .[termux-all] -c constraints-termux.txt --disable-pip-version-check --no-cache-dir",
cwd = hermesRepo.absolutePath,
)
Backend.BUNDLED -> bundled.runPython(
argv = listOf(
"-m", "pip", "install",
"-e", ".[termux-all]",
@@ -309,6 +460,12 @@ class HermesInstaller(private val context: Context) {
),
cwd = hermesRepo,
)
Backend.NONE -> BundledPythonRunner.PythonResult(
exitCode = -1,
stdout = "",
stderr = "No Python backend available (neither Termux nor bundled).",
process = null,
)
}
private fun writeConfig() {
@@ -353,31 +510,46 @@ class HermesInstaller(private val context: Context) {
null
}
private fun applyPatches() {
private suspend fun applyPatches() {
// Patches ship as a read-only asset directory
// "hermes-agent-patches/". For each .patch file, run
// `git apply --check` then `git apply`. Failures are logged
// but non-fatal — the user can intervene.
// "hermes-agent-patches/". For each .patch file, dispatch
// `git apply --check` then `git apply` via TermuxRunner
// (Workstream C). Failures are logged but non-fatal — the
// user can intervene.
//
// Mechanic: our app's process cannot write into Termux's
// $PREFIX (cross-app sandboxing), but Termux can write to its
// own /data/data/com.termux/files/usr/tmp. So we ship the
// patch content INLINE via a bash heredoc instead of
// round-tripping through a shared file. Termux runs the
// heredoc, gets the patch in $PREFIX/tmp, then applies it.
val patchDir = "hermes-agent-patches"
try {
val files = context.assets.list(patchDir) ?: return
for (name in files.sorted()) {
if (!name.endsWith(".patch")) continue
val content = context.assets.open("$patchDir/$name").bufferedReader().readText()
val checkProc = ProcessBuilder("git", "apply", "--check").directory(hermesRepo)
.redirectErrorStream(true).start()
checkProc.outputStream.bufferedWriter().use { it.write(content) }
checkProc.outputStream.close()
val checkExit = checkProc.waitFor()
if (checkExit != 0) {
Log.w(TAG, "patch $name: git apply --check failed; skipping")
val termuxTmp = "/data/data/${TermuxProbe.TERMUX_PACKAGE}/files/usr/tmp"
val tmpPatch = "$termuxTmp/hermes-patch-$name"
// Heredoc with a sentinel that's vanishingly unlikely
// to appear in any real patch text.
val sentinel = "HERMES_PATCH_EOF_${System.nanoTime()}"
val writeCmd = "cat > '$tmpPatch' <<'$sentinel'\n$content\n$sentinel\n"
val write = runShell(listOf("bash", "-c", writeCmd), cwd = hermesRepo)
if (write.exitCode != 0) {
Log.w(TAG, "patch $name: write to Termux tmp failed: ${write.stderr.take(200)}")
continue
}
val check = runShell(listOf("git", "apply", "--check", tmpPatch), cwd = hermesRepo)
if (check.exitCode != 0) {
Log.w(TAG, "patch $name: git apply --check failed; skipping: ${check.stderr.take(200)}")
continue
}
val apply = runShell(listOf("git", "apply", tmpPatch), cwd = hermesRepo)
if (apply.exitCode != 0) {
Log.w(TAG, "patch $name: git apply failed: ${apply.stderr.take(200)}")
continue
}
val applyProc = ProcessBuilder("git", "apply").directory(hermesRepo)
.redirectErrorStream(true).start()
applyProc.outputStream.bufferedWriter().use { it.write(content) }
applyProc.outputStream.close()
applyProc.waitFor()
Log.i(TAG, "patch $name: applied")
}
} catch (e: Exception) {
@@ -385,19 +557,63 @@ class HermesInstaller(private val context: Context) {
}
}
/** Run an arbitrary shell command via `ProcessBuilder`. For
* Termux installs, this should be dispatched via TermuxRunner
* instead — this is the bundled-Python path's escape hatch. */
private fun runShell(
/**
* Run an arbitrary shell command. Backend-aware:
*
* - **TERMUX**: dispatches via [TermuxRunner.runAndWait] (real exit
* code + truncated stdout/stderr via `RUN_COMMAND_PENDING_INTENT`).
* - **BUNDLED**: shells through the bundled CPython's
* `subprocess.run` (the legacy escape hatch — preserves the
* pre-Workstream-B behavior for the bundled path).
* - **NONE**: returns an error result; the caller should have
* bailed at the Stage 1 probe.
*/
private suspend fun runShell(
argv: List<String>,
cwd: File? = null,
): BundledPythonRunner.PythonResult = when (currentBackend()) {
Backend.TERMUX -> runShellViaTermux(argv, cwd)
Backend.BUNDLED -> bundled.runPython(
argv = listOf(
"-c",
"import subprocess; print(subprocess.run(${argvToPyList(argv)}, cwd='${cwd?.absolutePath ?: ""}').returncode)",
),
cwd = cwd,
)
Backend.NONE -> BundledPythonRunner.PythonResult(
exitCode = -1,
stdout = "",
stderr = "No Python backend available (neither Termux nor bundled).",
process = null,
)
}
/**
* Dispatch a shell command to Termux's RunCommandService and await
* the result via the `RUN_COMMAND_PENDING_INTENT` mechanism
* (Workstream C). Returns a [BundledPythonRunner.PythonResult] with
* the real exit code + truncated stdout/stderr (~100KB combined cap
* imposed by Termux; original lengths available via
* `*_ORIGINAL_LENGTH` bundle keys, not currently surfaced).
*
* If Termux is not installed or `com.termux.permission.RUN_COMMAND`
* has not been granted, [TermuxRunner.runAndWait] returns a
* `PythonResult` with `exitCode = -1` and a diagnostic stderr that
* the install UI surfaces verbatim (see
* [com.nousresearch.hermes.ui.onboarding.InstallScreen]'s
* permission-needed guidance card).
*/
private suspend fun runShellViaTermux(
argv: List<String>,
cwd: File? = null,
): BundledPythonRunner.PythonResult {
return bundled.runPython(
argv = listOf("-c", "import subprocess; print(subprocess.run(${argvToPyList(argv)}, cwd='${cwd?.absolutePath ?: ""}').returncode)"),
cwd = cwd,
)
val command = argv.joinToString(" ") { shellQuote(it) }
return termux.runAndWait(command, cwd = cwd?.absolutePath)
}
/** POSIX-safe single-quote escaping for a single shell argument. */
private fun shellQuote(s: String): String = "'" + s.replace("'", "'\\''") + "'"
private fun argvToPyList(argv: List<String>): String {
return argv.joinToString(", ") { "\"${it.replace("\\", "\\\\").replace("\"", "\\\"")}\"" }
}
@@ -453,14 +669,36 @@ class HermesInstaller(private val context: Context) {
}
/** Read the upstream hermes-agent version (the `__version__`
* string from `hermes_agent/__init__.py`). */
fun getHermesVersion(): String? {
if (!hermesPython.exists()) return null
val r = bundled.runPython(
argv = listOf("-c", "import importlib.metadata; print(importlib.metadata.version('hermes-agent'))"),
cwd = hermesRepo,
)
return if (r.exitCode == 0) r.stdout.trim() else null
* string from `hermes_agent/__init__.py`).
*
* Workstream C B5 followup: backend-aware. On Termux, the venv
* lives in `/data/data/com.termux/files/...` which our app can't
* probe directly; we dispatch through [TermuxRunner.runAndWait]
* to ask Termux to run the importlib.metadata one-liner inside
* its own venv. On Bundled we keep the direct
* `bundled.runPython` invocation. Suspend because the Termux
* path is async. */
suspend fun getHermesVersion(): String? = when (currentBackend()) {
Backend.TERMUX -> {
val r = termux.runAndWait(
command = "./venv/bin/python -c " +
"'import importlib.metadata; print(importlib.metadata.version(\"hermes-agent\"))'",
cwd = hermesRepo.absolutePath,
)
if (r.exitCode == 0) r.stdout.trim().takeIf { it.isNotEmpty() } else null
}
Backend.BUNDLED -> {
if (!hermesPython.exists()) {
null
} else {
val r = bundled.runPython(
argv = listOf("-c", "import importlib.metadata; print(importlib.metadata.version('hermes-agent'))"),
cwd = hermesRepo,
)
if (r.exitCode == 0) r.stdout.trim() else null
}
}
Backend.NONE -> null
}
/** Re-read the version on demand (used by the renderer's
@@ -38,6 +38,27 @@ object TermuxProbe {
return packageVersion(context, TERMUX_PACKAGE)
}
/**
* Workstream C: Termux >= 0.109 is required for the
* `RUN_COMMAND_PENDING_INTENT` mechanism that `TermuxRunner.runAndWait`
* depends on (older Termux silently drops the extra and runs
* fire-and-forget, leaving our continuation hanging forever).
*
* Current F-Droid Termux is 0.119+, so this should always pass for
* users who installed via the Workstream A F-Droid deep-link.
* Side-loaders running ancient builds get a graceful diagnostic
* via [InstallScreen]'s permission-needed surface.
*
* Returns true if Termux is installed AND the parsed version is
* at or above the floor. Returns false if not installed, version
* unparseable, or below the floor.
*/
fun isRunCommandResultSupported(context: Context): Boolean {
if (!isTermuxInstalled(context)) return false
val raw = termuxVersion(context) ?: return false
return isVersionAtLeast(raw, RUN_COMMAND_PENDING_INTENT_MIN_VERSION)
}
/** Returns the absolute path Termux uses for its $PREFIX. */
fun termuxHome(context: Context): String? {
// Termux stores its root at /data/data/com.termux/files/ — we
@@ -68,4 +89,28 @@ object TermuxProbe {
null
}
}
/**
* Parses a dotted version string (e.g. "0.119.1", "0.109") into a
* list of ints and compares lexicographically against `floor`.
* Tolerant of trailing non-numeric junk (e.g. "0.119.1-debug" →
* [0, 119, 1]) — the dash-suffix is dropped on parsing the last
* segment.
*/
internal fun isVersionAtLeast(raw: String, floor: List<Int>): Boolean {
val actual = raw.split(".").map { seg ->
seg.takeWhile { it.isDigit() }.toIntOrNull() ?: return false
}
val len = maxOf(actual.size, floor.size)
for (i in 0 until len) {
val a = actual.getOrElse(i) { 0 }
val f = floor.getOrElse(i) { 0 }
if (a > f) return true
if (a < f) return false
}
return true // exactly equal
}
/** Floor for `RUN_COMMAND_PENDING_INTENT` (Termux >= 0.109). */
private val RUN_COMMAND_PENDING_INTENT_MIN_VERSION = listOf(0, 109)
}
@@ -0,0 +1,244 @@
package com.nousresearch.hermes
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.util.Log
import androidx.core.content.ContextCompat
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
/**
* Receives the result broadcast Termux's RunCommandService sends back
* to our process via the PendingIntent attached to RUN_COMMAND with the
* `com.termux.RUN_COMMAND_PENDING_INTENT` extra.
*
* Termux puts the result in an outer Bundle keyed `"result"` on the
* fired Intent's extras. Inside that Bundle: `stdout`, `stderr`,
* `stdout_original_length`, `stderr_original_length`, `exitCode`,
* `err`, `errmsg` — exact key names verified from
* `termux-shared/.../TermuxConstants.java`.
*
* The receiver is registered dynamically by [TermuxResultRegistry] for
* exactly one action per pending command (action =
* `com.nousresearch.hermes.TERMUX_RESULT.<sessionId>`). It looks up the
* waiting `Continuation` and resumes it with a
* [BundledPythonRunner.PythonResult] carrying the real exit code +
* truncated stdout/stderr.
*/
internal class TermuxResultReceiver(private val sessionId: Int) : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
// Per TermuxConstants: outer Bundle extra is "result".
val bundle = intent.getBundleExtra(KEY_RESULT_BUNDLE)
val result = if (bundle != null) {
BundledPythonRunner.PythonResult(
exitCode = bundle.getInt(KEY_EXIT_CODE, -1),
stdout = bundle.getString(KEY_STDOUT) ?: "",
stderr = bundle.getString(KEY_STDERR) ?: "",
process = null,
)
} else {
// No "result" bundle — something dispatched the broadcast
// without going through RunCommandService. Treat as
// dispatch failure.
BundledPythonRunner.PythonResult(
exitCode = -1,
stdout = "",
stderr = "TermuxResultReceiver: no result bundle in broadcast",
process = null,
)
}
// `err` (Termux internal error code; RESULT_OK == -1 == no error)
// surfaces dispatch-level failures like missing RUN_COMMAND
// permission. Promote those to a non-zero exitCode with a
// diagnostic stderr so the install UI's existing failure path
// surfaces them verbatim.
val termuxErr = bundle?.getInt(KEY_ERR, RESULT_OK) ?: RESULT_OK
val finalResult = if (termuxErr != RESULT_OK && result.exitCode == 0) {
val errmsg = bundle?.getString(KEY_ERRMSG) ?: "Termux returned err=$termuxErr"
result.copy(
exitCode = if (termuxErr != 0) termuxErr else -1,
stderr = if (result.stderr.isEmpty()) errmsg else "${result.stderr}\n$errmsg",
)
} else {
result
}
Log.d(TAG, "result sessionId=$sessionId exitCode=${finalResult.exitCode} err=$termuxErr stdoutBytes=${finalResult.stdout.length} stderrBytes=${finalResult.stderr.length}")
TermuxResultRegistry.deliver(sessionId, finalResult)
}
companion object {
private const val TAG = "TermuxResultReceiver"
// Verified against termux-shared TermuxConstants.java
// (https://raw.githubusercontent.com/termux/termux-app/master/termux-shared/src/main/java/com/termux/shared/termux/TermuxConstants.java)
const val KEY_RESULT_BUNDLE = "result"
const val KEY_STDOUT = "stdout"
const val KEY_STDERR = "stderr"
const val KEY_STDOUT_ORIG_LEN = "stdout_original_length"
const val KEY_STDERR_ORIG_LEN = "stderr_original_length"
const val KEY_EXIT_CODE = "exitCode"
const val KEY_ERR = "err"
const val KEY_ERRMSG = "errmsg"
// Termux uses Activity.RESULT_OK (== -1) to mean "no internal error".
// Anything else (e.g. 1 for "permission denied") is a Termux-side
// dispatch failure, distinct from the command's own exit code.
const val RESULT_OK = -1
/** Action prefix; each pending command gets a unique suffix
* (the session ID) so concurrent installs don't race. */
const val ACTION_PREFIX = "com.nousresearch.hermes.TERMUX_RESULT"
}
}
/**
* Process-singleton bridge between [TermuxRunner.runAndWait] (caller
* side, holds a [Continuation]) and [TermuxResultReceiver] (receiver
* side, gets the broadcast back from Termux).
*
* Lifecycle per command:
* 1. caller calls [register] → gets a unique session ID, the registry
* stashes the continuation, allocates a [TermuxResultReceiver] for
* that session ID, registers it with [Context.registerReceiver] on
* a unique action filter, and constructs a `PendingIntent.getBroadcast`
* the caller hands to Termux's RunCommandService via the
* `com.termux.RUN_COMMAND_PENDING_INTENT` Intent extra.
* 2. Termux runs the command, then fires our PendingIntent with the
* result Bundle. Our [TermuxResultReceiver.onReceive] calls
* [deliver].
* 3. [deliver] looks up the continuation, calls `cont.resume(result)`,
* unregisters the receiver, removes the map entry.
* 4. If the caller cancels first (coroutine cancellation), [cancel]
* is invoked from `cont.invokeOnCancellation` and we unregister
* without resuming.
*
* Thread-safe: backing map is a [ConcurrentHashMap]; session ID is an
* [AtomicInteger]. `remove` returns null on multi-resolve so we won't
* double-resume a continuation.
*/
internal object TermuxResultRegistry {
private const val TAG = "TermuxResultRegistry"
private val nextSessionId = AtomicInteger(1)
private val pending = ConcurrentHashMap<Int, Entry>()
private data class Entry(
val cont: Continuation<BundledPythonRunner.PythonResult>,
val receiver: BroadcastReceiver,
val context: Context,
)
/**
* Register a continuation and a per-session receiver. Returns the
* session ID; the caller embeds it in the result PendingIntent's
* action string so the receiver knows which entry to resume.
*/
fun register(
context: Context,
cont: Continuation<BundledPythonRunner.PythonResult>,
): Int {
val sessionId = nextSessionId.getAndIncrement()
val action = "${TermuxResultReceiver.ACTION_PREFIX}.$sessionId"
val receiver = TermuxResultReceiver(sessionId)
val filter = IntentFilter(action)
// Use the application context to outlive any single activity
// and to keep the receiver scope bounded to our process.
// RECEIVER_NOT_EXPORTED on API 33+ (handled by ContextCompat
// for older API levels).
ContextCompat.registerReceiver(
context.applicationContext,
receiver,
filter,
ContextCompat.RECEIVER_NOT_EXPORTED,
)
pending[sessionId] = Entry(cont, receiver, context.applicationContext)
Log.d(TAG, "register sessionId=$sessionId action=$action (pending=${pending.size})")
return sessionId
}
/**
* Build the action string for a session ID — used by [TermuxRunner]
* when constructing the result PendingIntent.
*/
fun actionForSession(sessionId: Int): String =
"${TermuxResultReceiver.ACTION_PREFIX}.$sessionId"
/**
* Called by [TermuxResultReceiver.onReceive] when Termux fires the
* PendingIntent. Idempotent — a duplicate broadcast finds no entry
* and is a no-op.
*/
fun deliver(sessionId: Int, result: BundledPythonRunner.PythonResult) {
val entry = pending.remove(sessionId) ?: run {
Log.w(TAG, "deliver sessionId=$sessionId: no pending continuation (cancelled or duplicate)")
return
}
try {
entry.context.unregisterReceiver(entry.receiver)
} catch (e: IllegalArgumentException) {
// Receiver already unregistered (e.g. cancel race). Safe to ignore.
}
entry.cont.resume(result)
}
/**
* Called from `cont.invokeOnCancellation`. Removes the entry and
* unregisters the receiver WITHOUT resuming (the continuation is
* already cancelled). Safe to call after `deliver` already removed
* the entry — `remove` returns null and we no-op.
*/
fun cancel(sessionId: Int) {
val entry = pending.remove(sessionId) ?: return
try {
entry.context.unregisterReceiver(entry.receiver)
} catch (e: IllegalArgumentException) {
// Already unregistered.
}
Log.d(TAG, "cancel sessionId=$sessionId (pending=${pending.size})")
}
/**
* Caller-side: synthesize a failure result without going through
* the broadcast pipeline (e.g. when `startForegroundService` throws
* synchronously before Termux ever sees the intent). Removes the
* entry, unregisters the receiver, resumes the continuation.
*/
fun resolveSynthetic(sessionId: Int, result: BundledPythonRunner.PythonResult) {
deliver(sessionId, result)
}
/**
* Construct the PendingIntent the caller attaches to the
* RUN_COMMAND intent as
* `com.termux.RUN_COMMAND_PENDING_INTENT`. Uses
* [PendingIntent.FLAG_MUTABLE] because Termux's RunCommandService
* mutates the intent extras (it adds the result Bundle before
* firing); [PendingIntent.FLAG_IMMUTABLE] throws
* IllegalArgumentException at startForegroundService time on
* API 31+.
*
* The result Intent is scoped to our own package via
* `setPackage` so only our process receives the broadcast.
*/
fun pendingIntentFor(context: Context, sessionId: Int): PendingIntent {
val resultIntent = Intent(actionForSession(sessionId))
.setPackage(context.packageName)
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
return PendingIntent.getBroadcast(
context.applicationContext,
sessionId,
resultIntent,
flags,
)
}
}
@@ -5,8 +5,10 @@ import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
/**
* Runs shell commands inside a Termux environment via the
@@ -108,6 +110,121 @@ class TermuxRunner(private val context: Context) {
}
}
/**
* Workstream C variant of [run] that blocks the calling coroutine
* until Termux's RunCommandService broadcasts back the result via
* the `com.termux.RUN_COMMAND_PENDING_INTENT` mechanism (requires
* Termux >= 0.109; current F-Droid Termux 0.119+ is well above the
* floor).
*
* Returns a [BundledPythonRunner.PythonResult] populated with the
* REAL exit code, REAL stdout, REAL stderr — making every
* `HermesInstaller` shell stage that gates on `exitCode != 0`
* work correctly on the Termux backend.
*
* Caveats:
* - stdout+stderr combined is truncated to ~100KB by Termux
* (`stdout_original_length` / `stderr_original_length` carry
* the pre-truncation size; we don't currently surface those).
* - If the user hasn't granted `com.termux.permission.RUN_COMMAND`
* OR Termux's `~/.termux/termux.properties` doesn't have
* `allow-external-apps=true`, RunCommandService still fires the
* result PendingIntent but with `err != RESULT_OK` and an
* `errmsg`; the receiver promotes that to a non-zero exit code
* so InstallScreen surfaces the actionable error.
* - On Android pre-O (API < 26) [Context.startService] is used
* in place of [Context.startForegroundService]; for the same
* fire-and-forget reasons as [run].
*
* Cancellation: if the coroutine is cancelled before Termux fires
* the result PendingIntent, the receiver is unregistered via
* [TermuxResultRegistry.cancel] and the continuation is never
* resumed (consistent with `suspendCancellableCoroutine` semantics).
* The dispatched command may still complete inside Termux — there
* is no way to abort a RunCommand mid-flight from the caller side.
*
* Timeouts: callers wrap with `kotlinx.coroutines.withTimeout`
* when they want bounded waits (e.g. stage 5 pip install allows
* up to 30 minutes). This method does not impose its own timeout.
*/
suspend fun runAndWait(
command: String,
cwd: String? = null,
): BundledPythonRunner.PythonResult = suspendCancellableCoroutine { cont ->
if (!TermuxProbe.isTermuxInstalled(context)) {
cont.resume(
BundledPythonRunner.PythonResult(
exitCode = -1,
stdout = "",
stderr = "Termux is not installed",
process = null,
),
)
return@suspendCancellableCoroutine
}
val sessionId = TermuxResultRegistry.register(context, cont)
val resultPi = TermuxResultRegistry.pendingIntentFor(context, sessionId)
cont.invokeOnCancellation { TermuxResultRegistry.cancel(sessionId) }
val intent = Intent().apply {
component = ComponentName(
TermuxProbe.TERMUX_PACKAGE,
"com.termux.app.RunCommandService",
)
action = "com.termux.RUN_COMMAND"
putExtra(
"com.termux.RUN_COMMAND_PATH",
"/data/data/${TermuxProbe.TERMUX_PACKAGE}/files/usr/bin/bash",
)
putExtra("com.termux.RUN_COMMAND_ARGUMENTS", arrayOf("-c", command))
// Always run in background; we don't need an interactive
// Termux session for these install commands. Foreground
// mode also makes RUN_COMMAND_PENDING_INTENT misbehave
// (per the Termux wiki: stderr is null in foreground).
putExtra("com.termux.RUN_COMMAND_BACKGROUND", true)
if (cwd != null) {
putExtra("com.termux.RUN_COMMAND_WORKDIR", cwd)
}
// The crucial Workstream C piece — without this extra
// RunCommandService runs fire-and-forget (the current
// run() path).
putExtra("com.termux.RUN_COMMAND_PENDING_INTENT", resultPi)
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
} catch (e: SecurityException) {
// Same Android 12+ background-start guard as run(). We
// synthesize a failure result so the caller doesn't hang.
Log.e(TAG, "SecurityException dispatching RUN_COMMAND: ${e.message}")
TermuxResultRegistry.resolveSynthetic(
sessionId,
BundledPythonRunner.PythonResult(
exitCode = -1,
stdout = "",
stderr = "TermuxRunner: SecurityException (${e.message ?: "unknown"})",
process = null,
),
)
} catch (e: Exception) {
Log.e(TAG, "Failed to dispatch RUN_COMMAND: ${e.message}")
TermuxResultRegistry.resolveSynthetic(
sessionId,
BundledPythonRunner.PythonResult(
exitCode = -1,
stdout = "",
stderr = "TermuxRunner: dispatch failed (${e.message ?: "unknown"})",
process = null,
),
)
}
}
/**
* Tail the most recent N bytes of a Termux log file. Used by
* [HermesInstaller] to stream the install output to the renderer's
@@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -23,10 +25,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.nousresearch.hermes.HermesApi
import com.nousresearch.hermes.HermesInstaller
import com.nousresearch.hermes.R
import kotlinx.coroutines.flow.collectLatest
/**
@@ -38,6 +42,13 @@ import kotlinx.coroutines.flow.collectLatest
* [HermesInstaller.startInstall] internally). On success, the
* stage emits `Complete` and the screen calls
* [HermesApi.setAppState] to advance to Setup.
*
* Workstream C: when the install fails with an error that looks
* like a Termux RUN_COMMAND permission denial (substring match
* on "permission" or "RUN_COMMAND"), surface a guidance card with
* an "Open App Settings" button alongside the generic Retry —
* Termux only returns `err != RESULT_OK` for these specific
* dispatch-level failures, so the heuristic is reliable.
*/
@Composable
fun InstallScreen(hermes: HermesApi) {
@@ -119,6 +130,13 @@ fun InstallScreen(hermes: HermesApi) {
text = "Install failed: $installError",
color = MaterialTheme.colorScheme.error,
)
if (looksLikeTermuxPermissionError(installError)) {
Spacer(Modifier.height(8.dp))
TermuxPermissionNeededCard(
onOpenSettings = { hermes.openAppSettings() },
)
}
Spacer(Modifier.height(8.dp))
Button(
onClick = {
installError = null
@@ -144,6 +162,60 @@ fun InstallScreen(hermes: HermesApi) {
}
}
/**
* Heuristic: does this install error look like Termux refusing our
* RUN_COMMAND dispatch because the permission hasn't been granted (or
* Termux's `allow-external-apps` is off)? Termux's RunCommandService
* surfaces these via `err != RESULT_OK` with an errmsg that our
* [com.nousresearch.hermes.TermuxResultReceiver] promotes to the
* stderr field.
*
* Workstream C B6 followup: tightened to avoid false positives on
* pip's "Permission denied" filesystem errors. We now match only on
* tokens that are Termux-specific:
* - "RUN_COMMAND" (verbatim API name in Termux's errmsg)
* - "allow-external-apps" (Termux property the user must enable)
* - "plugin_action_disabled" (Termux's actual errmsg key when
* allow-external-apps is unset)
* The bare word "permission" alone is NOT a signal — pip prints it
* routinely for unrelated FS errors.
*/
private fun looksLikeTermuxPermissionError(error: String?): Boolean {
if (error == null) return false
val lower = error.lowercase()
return "run_command" in lower ||
"allow-external-apps" in lower ||
"plugin_action_disabled" in lower
}
@Composable
private fun TermuxPermissionNeededCard(onOpenSettings: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.termux_permission_needed_title),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.termux_permission_needed_message),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Spacer(Modifier.height(12.dp))
Button(onClick = onOpenSettings) {
Text(stringResource(R.string.termux_permission_open_settings))
}
}
}
}
@Composable
private fun StageRow(stage: HermesInstaller.Stage, isCurrent: Boolean) {
Row(
@@ -14,6 +14,7 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -22,8 +23,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.nousresearch.hermes.HermesApi
import com.nousresearch.hermes.R
/**
* WelcomeScreen — the first interactive onboarding screen.
@@ -36,6 +39,11 @@ import com.nousresearch.hermes.HermesApi
* The local-install CTAs (Termux / bundled Python) are still
* present for the future Path B (full Hermes gateway on-device)
* but are de-emphasized.
*
* Workstream A (atomic-wondering-sunrise plan): when Termux is
* missing, the Termux card renders two stacked CTAs that deep-link
* to F-Droid (primary) and GitHub Releases (secondary) instead of
* showing a dead disabled Continue button.
*/
@Composable
fun WelcomeScreen(hermes: HermesApi) {
@@ -62,12 +70,13 @@ fun WelcomeScreen(hermes: HermesApi) {
InstallOptionCard(
title = "Just configure the API key",
subtitle = "Skip the local install and connect directly to an OpenAI-compatible API (e.g. MiniMax, OpenRouter). You'll just need the model name + API key.",
enabled = true,
primary = true,
onClick = {
hermes.setAppState(HermesApi.AppState.Setup)
},
)
) {
Button(
onClick = { hermes.setAppState(HermesApi.AppState.Setup) },
colors = ButtonDefaults.buttonColors(),
) { Text("Continue") }
}
InstallOptionCard(
title = "Install locally (Termux)",
@@ -76,32 +85,56 @@ fun WelcomeScreen(hermes: HermesApi) {
} else {
"Termux not detected. Install Termux from F-Droid first, then come back."
},
enabled = termux.installed,
primary = false,
onClick = {
hermes.setAppState(HermesApi.AppState.Installing)
},
)
) {
if (termux.installed) {
OutlinedButton(
onClick = { hermes.setAppState(HermesApi.AppState.Installing) },
) { Text("Continue") }
} else {
Button(
onClick = { hermes.openExternal(F_DROID_TERMUX_URL) },
colors = ButtonDefaults.buttonColors(),
) { Text(stringResource(R.string.termux_install_action)) }
Spacer(Modifier.height(4.dp))
TextButton(
onClick = { hermes.openExternal(GITHUB_TERMUX_URL) },
) { Text(stringResource(R.string.termux_install_github)) }
}
}
InstallOptionCard(
title = "Install locally (bundled Python)",
subtitle = "Use the Python runtime bundled with the Hermes APK. No Termux required. (Coming soon — the bundled-Python path is a future Path B.)",
enabled = false,
primary = false,
onClick = {
hermes.setAppState(HermesApi.AppState.Installing)
},
)
) {
OutlinedButton(
onClick = { hermes.setAppState(HermesApi.AppState.Installing) },
enabled = false,
) { Text("Continue") }
}
}
}
/**
* Termux install URLs surfaced from the Termux-missing card.
*
* F-Droid is the primary CTA because it's the only official
* distribution channel maintained by the Termux project today
* (the Play Store build was abandoned years ago and is
* incompatible with current Termux:API). GitHub Releases is a
* secondary fallback for users who don't have F-Droid or who
* prefer side-loading the signed APK directly.
*/
private const val F_DROID_TERMUX_URL = "https://f-droid.org/packages/com.termux/"
private const val GITHUB_TERMUX_URL = "https://github.com/termux/termux-app/releases"
@Composable
private fun InstallOptionCard(
title: String,
subtitle: String,
enabled: Boolean,
primary: Boolean,
onClick: () -> Unit,
actions: @Composable () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
@@ -131,18 +164,7 @@ private fun InstallOptionCard(
MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
if (primary) {
Button(
onClick = onClick,
enabled = enabled,
colors = ButtonDefaults.buttonColors(),
) { Text("Continue") }
} else {
OutlinedButton(
onClick = onClick,
enabled = enabled,
) { Text("Continue") }
}
actions()
}
}
}
@@ -12,4 +12,8 @@
<string name="termux_missing_message">To run hermes-agent locally on Android, install Termux and Termux:API from F-Droid. Then return to Hermes to continue.</string>
<string name="termux_install_action">Open F-Droid</string>
<string name="hermes_agent_pinned_sha">e7bc6189cf185f9c223a4428115147e483d9ff89</string>
<string name="termux_install_github">Or get it from GitHub Releases</string>
<string name="termux_permission_needed_title">Termux permission needed</string>
<string name="termux_permission_needed_message">Hermes needs permission to run commands inside Termux. Grant the “Run commands inside Termux environment” permission in App info → Additional permissions, then retry. If you still see this after granting, open Termux and add allow-external-apps=true to ~/.termux/termux.properties, then run termux-reload-settings.</string>
<string name="termux_permission_open_settings">Open App Settings</string>
</resources>
+34 -1
View File
@@ -68,7 +68,40 @@ if [[ -f "$MANIFEST" && -f "$RUNNER_MANIFEST" ]]; then
done < <(grep -E '<uses-permission[^/]*android:name=' "$RUNNER_MANIFEST")
fi
# 4. (Removed in Phase 0) HermesAPIPlugin registration and the
# 4. Merge the <queries> block (Workstream C, atomic-wondering-sunrise
# plan). Android 11+ (API 30) requires apps to declare which other
# packages they call PackageManager.getPackageInfo on; without this
# block, TermuxProbe returns "not installed" even when Termux is
# actually installed, and HermesInstaller falls through to the
# bundled-Python backend. The runner's manifest is the source of
# truth for the block; we copy it once and detect-and-skip on
# subsequent runs.
#
# B13 followup: probe specifically for `android:name="com.termux"`
# (the Termux package entry) rather than just `<queries>`. A library
# we depend on (or a future Capacitor plugin) might add an unrelated
# <queries> block first, and the old probe would silently skip the
# Termux visibility merge — re-introducing the API 30+ TermuxProbe
# bug Workstream C fixed.
#
# Validation followup: the awk extractor MUST anchor `<queries>` to
# start-of-line so it doesn't accidentally match the literal string
# `<queries>` that appears inside the runner manifest's explanatory
# comment. Without the anchor, awk grabs from the comment's middle
# onward and the merged result is malformed XML
# (manifmerger crashes with "Error parsing AndroidManifest.xml").
if [[ -f "$MANIFEST" && -f "$RUNNER_MANIFEST" ]] && ! grep -q 'android:name="com.termux"' "$MANIFEST"; then
QUERIES_BLOCK=$(awk '/^[[:space:]]*<queries>/,/^[[:space:]]*<\/queries>/' "$RUNNER_MANIFEST")
if [[ -n "$QUERIES_BLOCK" ]]; then
# Indent each line for readability inside the live manifest. The
# block is inserted directly before </manifest>; sed needs the
# block as a single line with literal \n separators.
INDENTED=$(printf '%s\n' "$QUERIES_BLOCK" | sed 's/^/ /' | sed ':a;N;$!ba;s/\n/\\n/g')
sed -i "s|</manifest>|${INDENTED}\n</manifest>|" "$MANIFEST"
fi
fi
# 5. (Removed in Phase 0) HermesAPIPlugin registration and the
# associated MainActivity.java BridgeActivity patch. The WebView
# path is gone; MainActivity is now Kotlin+Compose and lives under
# apps/mobile/android/app/src/main/kotlin/com/nousresearch/hermes/.
+270
View File
@@ -0,0 +1,270 @@
# Python bundling alternatives for hermes-mobile
**Status:** decision document — synthesizes research done for the
`atomic-wondering-sunrise` plan's Workstream C, after the user chose
"Research alternatives before committing" to Chaquopy.
**Date:** 2026-06-04
---
## The headline finding
The Workstream B caveat I flagged in code (`runShellViaTermux` doesn't
get real exit codes back) is **a tractable engineering follow-up, not a
fundamental Termux limitation**. Termux's `RUN_COMMAND_PENDING_INTENT`
extra is officially documented, has been working since Termux 0.109,
and lets us receive `STDOUT`, `STDERR`, `EXIT_CODE`, and `ERRMSG` back
as a Bundle in a `BroadcastReceiver`. Wiring it up in `TermuxRunner.kt`
is ~12 days of work.
That changes the whole calculus. The Chaquopy workstream was sized
against "Termux is the only working path but it doesn't work
end-to-end either" — but Termux *can* work end-to-end with a small
follow-up. Chaquopy is no longer the obvious bundled-Python answer,
and may not be needed at all in v1.
---
## What we evaluated
Three agents researched, in parallel, the realistic 2026 alternatives
to Chaquopy. Findings condensed below; full reports in the planning
session transcript.
### Option 1 — Bundle Termux's `bootstrap.zip` as a private chroot
- **License:** Aggregate redistribution of `bash` (GPL-3),
`coreutils` (GPL-3), `proot` (GPL-2), plus permissive
(openssl Apache-2, python PSF-2, libffi/zlib/etc. MIT/BSD) is
legally permitted. Requires a `THIRD_PARTY_LICENSES.txt` ship-along.
- **APK delta:** `bootstrap-aarch64.zip` is 30.86 MB compressed
(~120 MB extracted). Plus ~1525 MB of `hermes-agent[termux-all]`
wheels. APK grows ~50 MB.
- **Killer:** every binary inside Termux's bootstrap bakes the
prefix `/data/data/com.termux/files/usr` into shebangs and (some)
RUNPATH entries. Extracting to a different app's `filesDir` requires
rewriting ~5,000 shebangs at install time and verifying RUNPATH with
`readelf` per release. Doable but adds a regression risk on every
Termux release.
- **No precedent:** AnLinux, UserLAnd, and every other "Linux on
Android" app builds their own rootfs rather than bundling Termux's.
- **Verdict:** mechanically possible, strategically wrong. The cost of
PREFIX rewriting > the cost of asking the user to install Termux
(which Workstream A's F-Droid deep-link already makes one tap away).
### Option 2 — Alpine minirootfs + proot (downloaded first run)
- **Size:** `alpine-minirootfs-3.23.4-aarch64.tar.gz` is ~34 MB
compressed; `apk add python3 py3-pip` brings it to ~30 MB; plus
`pip install hermes-agent[termux-all]` brings on-device footprint
to ~100140 MB. **All on-device, not in the APK.**
- **License:** ship `proot` (GPL-2) as a separate binary we shell out
to (not link); Alpine packages are permissive; hermes-agent is MIT.
Clean.
- **APK delta:** 0 MB if downloaded on first run (recommended).
- **C-extension wheels:** Alpine uses musl libc, and `cryptography`,
`pydantic-core`, `psutil`, `uvloop`, `brotlicffi` **all publish
`musllinux_aarch64` wheels** to PyPI. So `pip install` works
end-to-end with no recipe maintenance.
- **Engineering:** 24 engineer-weeks for a polished install (rootfs
builder, NDK-compiled `proot` binary, Kotlin extractor + progress
UI, gateway lifecycle, update/repair flows).
- **F-Droid:** F-Droid allows GPL'd binaries (proot is fine). Large
first-run download is a UX issue, not a policy issue.
- **Verdict:** the proper "no Termux required" alternative to
Chaquopy. License-clean and zero-cost on APK size.
### Option 3 — Python 3.13+ official Android (PEP 738) + cibuildwheel
- **PEP 738 ("Adding Android as a supported platform")** is **Final**,
authored by Malcolm Smith of Chaquopy. Android is **Tier 3** in
PEP 11 — Russell Keith-Magee and Petr Viktorin as contacts.
- **Embeddable tarballs:** `python.org` publishes them since 3.14.0
(Oct 2025). 3.14.5 is the current stable. **3.13.x has no official
Android tarball.**
- **Wheel ecosystem:** `cibuildwheel --platform android` works for
3.13+ and 3.14+. PyPI/pip understand the `android_<api>_<abi>` tag.
**But:** none of cryptography / pydantic-core / uvloop / psutil /
brotlicffi currently publish `android_*` wheels to PyPI. The
Chaquopy mirror (`https://chaquo.com/pypi-13.1/`) remains the
de-facto curated source for these.
- **`python-build-standalone`** (astral-sh) **does not ship Android
targets** as of 2026-06. The `aarch64-unknown-linux-gnu` artifact
is glibc-targeted and does not load on bionic.
- **Verdict:** the interpreter half is solved; the dep ecosystem half
is not. Without Chaquopy's wheel mirror, you'd have to maintain a
cibuildwheel recipe for each native dep and pin versions in our
repo. That is real recurring engineering cost.
### Option 4 — Termux companion-app pattern (status quo + RESULT_INTENT)
- **Architecture:** keep using Termux as the runtime; talk to it via
`RUN_COMMAND` with `EXTRA_PENDING_INTENT`. Termux's own apt-managed
Python is the interpreter; Termux's pip + manylinux/musllinux
patches handle the wheel ecosystem.
- **RUN_COMMAND_PENDING_INTENT** is documented at
[github.com/termux/termux-app/wiki/RUN_COMMAND-Intent](https://github.com/termux/termux-app/wiki/RUN_COMMAND-Intent).
Result bundle keys: `EXTRA_PLUGIN_RESULT_BUNDLE_STDOUT`,
`..._STDERR`, `..._EXIT_CODE`, `..._ERR`, `..._ERRMSG`. Combined
stdout+stderr cap is ~100 KB; original lengths in `*_ORIGINAL_LENGTH`.
- **Permissions:** user grants `com.termux.permission.RUN_COMMAND` in
Android Settings → App Info → Additional Permissions, and
Termux's `~/.termux/termux.properties` needs
`allow-external-apps=true`. The F-Droid build of Termux defaults
this on; manual setup is a one-line edit.
- **Source of truth:** Termux's `RunCommandService.java`
([raw](https://raw.githubusercontent.com/termux/termux-app/master/app/src/main/java/com/termux/app/RunCommandService.java))
reads `EXTRA_PENDING_INTENT` and forwards as `TermuxService`'s own
`EXTRA_PENDING_INTENT`. Constants live in MIT-licensed
`termux-shared` (`TermuxConstants.java`).
- **APK delta:** zero.
- **License:** zero contamination (we shell out via intent, no
linking).
- **F-Droid:** Termux is on F-Droid; our Workstream A deep-link
already makes installing it one tap.
- **Engineering:** ~12 days to extend `TermuxRunner` with
`PendingIntent.getBroadcast` + a `BroadcastReceiver` that
resumes a coroutine `Continuation` with the result bundle.
- **Trade-off:** requires Termux to be installed on the device.
Workstream A made this trivial.
- **Verdict:** **the best path for v1.** Cheap, clean, F-Droid
friendly, no APK bloat, full functionality.
### Dead ends
- **PyOxidizer**: no Android target; project on life support since 2022.
- **Pyodide / WebAssembly Python**: 520× slower for `pydantic-core`-
heavy paths; inverts hermes-agent's architecture (gateway becomes
a WebView-hosted server bridged to localhost).
- **RustPython**: no ports of cryptography / pydantic-core / uvloop.
- **`proot-rs` as an embeddable library**: no `cdylib` / `libproot.so`
exists — proot in any flavor is a CLI binary, not a linkable
library.
- **`sharedUserId="com.termux"`**: deprecated in Android 10, removed
in later versions. Cannot be used to share Termux's UID.
---
## Decision matrix
Scored 15 (5 = best); composite = (license × 2) + APK + effort +
F-Droid + functionality.
| Option | License | APK | Effort | F-Droid | Functionality | Total |
|---|---|---|---|---|---|---|
| **4. Termux companion + RESULT_INTENT** | 5 | 5 | 5 | 5 | 5 | **30** |
| **2. Alpine rootfs + proot (1st-run download)** | 4 | 4 | 2 | 4 | 4 | 22 |
| **Chaquopy Business** | 3 (paid, MIT-compatible) | 3 | 4 | 2 (separate F-Droid build) | 5 | 20 |
| **3. PEP 738 + cibuildwheel recipes** | 5 | 4 | 1 (recurring) | 4 | 3 (wheel gaps) | 21 |
| **1. Bundle Termux bootstrap** | 4 | 2 | 1 | 3 | 4 | 18 |
| Chaquopy Standard (GPL-3) | 2 | 3 | 4 | 2 | 5 | 17 |
---
## Recommendation
**Tier 1 (v1, immediate): Termux companion + RESULT_INTENT (Option 4).**
Finish what Workstream B started by wiring up
`RUN_COMMAND_PENDING_INTENT` in `TermuxRunner.kt`. Update
`HermesInstaller` to await the result bundle on each shell stage.
Estimated effort: 12 days. End state: Termux install path works
end-to-end with real exit codes and output capture; Workstream A's
F-Droid deep-link gets every non-Termux user one tap away from a
working setup. Ship as v1.
**Tier 2 (v2, post-launch if demand exists): Alpine rootfs + proot
(Option 2).** Only build if user telemetry / feedback shows a
meaningful population that wants hermes-agent on Android *without*
installing Termux. Engineering cost is real (24 weeks) but
license-clean, F-Droid friendly, and gives a true "single APK runs
everything" UX with a one-time first-run download.
**Tier 3 (probably never): Chaquopy.** Reconsider only if Alpine+proot
proves unworkable for some specific reason. The license cost
(Business tier $) and F-Droid friction (separate build variant)
remain real; Alpine+proot dominates Chaquopy on license/F-Droid/APK
dimensions even at higher engineering cost.
**Path dropped: Bundle Termux bootstrap (Option 1).** Strategically
wrong — the PREFIX-rewriting tax is permanent and the user-facing
benefit (no Termux app on device) is already captured by Tier 2.
---
## What this changes in the original plan
The `atomic-wondering-sunrise.md` plan's Workstream C (Chaquopy
integration) is **superseded** by this analysis. The actual next
work is a small extension to Workstream B:
**Workstream B-followup — wire `RUN_COMMAND_PENDING_INTENT`** so
`runShellViaTermux` is sync-with-exit-code-and-output:
1. Extend `TermuxRunner.run(...)` to accept an optional
`onResult: (RunResult) -> Unit` callback. Internally, build a
`PendingIntent.getBroadcast(ctx, requestCode, resultIntent,
PendingIntent.FLAG_MUTABLE | FLAG_UPDATE_CURRENT)` and attach it
as `EXTRA_PENDING_INTENT` on the dispatched Intent. Register a
one-shot `BroadcastReceiver` keyed by `requestCode` that unpacks
the result bundle and invokes `onResult`.
2. Add `suspend fun runAndWait(...)` that wraps the callback in
`suspendCancellableCoroutine`. Returns a full `RunResult` with
exit code, stdout, stderr.
3. Update `HermesInstaller.runShellViaTermux` to use `runAndWait`.
Each stage now returns the real exit code.
4. Update `HermesInstaller.applyPatches` to use `TermuxRunner` instead
of raw `ProcessBuilder("git", ...)` (which never worked since git
isn't on the host app's PATH).
5. Documentation: a short README section on the two Termux
permissions the user grants on first install (`RUN_COMMAND`
permission + `allow-external-apps` properties file).
Estimated effort: **12 days**. End state: hermes-mobile + Termux is
a fully working install path end-to-end. Workstreams A + B + B-followup
together close out the entire "Termux installation works" goal.
---
## Sources
Termux RUN_COMMAND PendingIntent:
- https://github.com/termux/termux-app/wiki/RUN_COMMAND-Intent
- https://raw.githubusercontent.com/termux/termux-app/master/app/src/main/java/com/termux/app/RunCommandService.java
- https://raw.githubusercontent.com/termux/termux-app/master/termux-shared/src/main/java/com/termux/shared/termux/TermuxConstants.java
Termux bootstrap:
- https://api.github.com/repos/termux/termux-packages/releases/latest (bootstrap-2026.05.31-r1+apt.android-7, aarch64 = 30.86 MB)
- https://raw.githubusercontent.com/termux/termux-packages/master/LICENSE.md
- https://github.com/termux/termux-app/blob/master/app/src/main/java/com/termux/app/TermuxInstaller.java
PEP 738 / CPython on Android:
- https://peps.python.org/pep-0738/
- https://peps.python.org/pep-0011/
- https://www.python.org/downloads/android/
- https://docs.python.org/3/using/android.html
- https://cibuildwheel.readthedocs.io/en/stable/platforms/
Alpine Linux:
- https://alpinelinux.org/downloads/
proot (license + lack of embeddable library):
- https://github.com/proot-me/proot (GPL-2.0+)
- https://github.com/proot-me/proot-rs (CLI only)
- https://github.com/termux/proot-distro
Astral python-build-standalone:
- https://github.com/astral-sh/python-build-standalone/releases
Linux-userland precedents:
- https://github.com/EXALAB/AnLinux-App
- https://github.com/CypherpunkArmory/UserLAnd
- https://github.com/termux/termux-boot
- https://github.com/termux/termux-api
Pyodide / WebAssembly Python (negative finding):
- https://pyodide.org/en/stable/
- https://pyodide.org/en/stable/usage/faq.html
- https://rustpython.github.io/
PyOxidizer (negative finding):
- https://github.com/indygreg/PyOxidizer