mirror of
https://github.com/Heretek-AI/hermes-mobile.git
synced 2026-07-01 16:12:43 -04:00
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:
@@ -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 (B1–B4): 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
|
||||
|
||||
+72
@@ -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(
|
||||
|
||||
+51
-29
@@ -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
|
||||
}
|
||||
|
||||
+284
-46
@@ -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 (B1–B4): 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)
|
||||
}
|
||||
|
||||
+244
@@ -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
|
||||
|
||||
+72
@@ -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(
|
||||
|
||||
+51
-29
@@ -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>
|
||||
|
||||
@@ -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/.
|
||||
|
||||
@@ -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 ~1–2 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 ~15–25 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 ~3–4 MB
|
||||
compressed; `apk add python3 py3-pip` brings it to ~30 MB; plus
|
||||
`pip install hermes-agent[termux-all]` brings on-device footprint
|
||||
to ~100–140 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:** 2–4 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:** ~1–2 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**: 5–20× 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 1–5 (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: 1–2 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 (2–4 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: **1–2 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
|
||||
Reference in New Issue
Block a user