English | 简体中文
手机点两下,Termux 里的 opencode web 就跑起来 —— 启动 / 探活 / 停止 / 重启,有 root 还能零手动唤醒 Termux。
把「打开 Termux → 输命令 → 等日志 → 抄 URL」这串手动活,压缩成手机上的两次点击。
opencode 的 web 子命令会在本机起一个网页版界面(默认 127.0.0.1),在手机上用浏览器操作很舒服。但在 Android 上启动它,你得先打开 Termux、敲 opencode web、盯着日志找 Web interface: 那行把端口抄下来,关掉之后想停还得再回去找进程。
更麻烦的是:Termux 进程没起来时,外部 App 发过去的 RUN_COMMAND 指令会被系统静默吞掉(点了没反应);而在 Oplus / ColorOS 这类 ROM 上,跨应用去拉 Termux 还会弹一个「想要打开 Termux」的确认框。
OpencodeStarter 就是一个图形遥控器:它本身不运行 opencode,只通过 Termux 官方的 RUN_COMMAND Intent 把「启动 / 探活 / 停止 / 重启」指令发进 Termux,并把执行结果读回来展示。有 root 时,它还会在发指令前自动把没运行的 Termux 拉起来(走 shell 通道,无跨应用弹窗)。
它是一个无后端、无联网、无账号的本地工具:不采集数据、不上传任何内容,权限只用到一个
com.termux.permission.RUN_COMMAND(且需要你在系统设置里手动授予)。
- ▶ 前台启动:让 Termux 切到前台可见终端,日志里直接看
Web interface: http://127.0.0.1:端口/,适合要盯着日志的场合。 - ⏸ 后台启动:Termux 不抢前台,适合「起完就切浏览器」;结果经
PendingIntent回传到 App 内展示。 - ● 刷新状态:在 Termux 侧跑一段只读探活脚本(
pgrep+ 可选curl),把原始回执和解析出的「是否运行 / PID / URL」三行一起显示。 - ■ 停止:执行
pkill -f 'opencode (web|serve)',二次确认,幂等(重复点不会出错)。 - 🔁 重启:先停止、再按当前输入和上次的模式重新启动,一步到位。
- 🎛 启动参数可控:工作目录(留空 = Termux
HOME)、端口(留空 = 不传--port,由 opencode 决定;填了则校验1-65535)、是否监听0.0.0.0(局域网可访问,默认仅127.0.0.1)。 - 🐚 root 自唤起(可选):有 root 时,每次发指令前先探测 Termux 是否存活,没活就用
su拉起,全程自动、无弹窗;无 root 自动回退到「先打开 Termux」的手动链路,功能不受影响。 - 🪶 轻量:经典 Views + Material3,无 Compose,APK 约 5 MB,亮 / 暗色主题都正常。
![]() 主界面:启动 / 状态 / 管控 / 说明四区 |
![]() 探活成功:是否运行 / PID / URL |
![]() 前台启动:Termux 日志里的 Web interface 行 |
![]() 手机浏览器打开 opencode Web UI |
把下面这段提示词直接发给你的本地 AI Agent(Claude Code / Codex / OpenCode …),让它帮你编译安装:
请帮我在本机编译并安装 OpencodeStarter(GitHub: https://github.com/RayMorTwinkle/OpencodeStarter)。
背景:这是一个 Android App(Kotlin + Gradle,非 Compose),用于通过 Termux 的 RUN_COMMAND Intent
远程启动 / 探活 / 停止 / 重启 `opencode web`;有 root 时会自动唤醒未运行的 Termux。
步骤:
1. 克隆:git clone https://github.com/RayMorTwinkle/OpencodeStarter.git && cd OpencodeStarter
2. 确认环境:JDK 17、Android SDK(需 compileSdk 36 与对应 build-tools)。
3. 配置 SDK 路径:export ANDROID_HOME=<你的 Android SDK 路径>,或在仓库根写 local.properties 的 sdk.dir=...
4. 编译 Debug 包:./gradlew assembleDebug
5. 手机开启 USB 调试并连接后安装:adb install -r app/build/outputs/apk/debug/app-debug.apk
6. 提醒用户完成三项前置:
a) Termux 内执行 `echo 'allow-external-apps = true' >> ~/.termux/termux.properties` 并重启 Termux;
b) 系统设置 → 应用 → OpencodeStarter → 附加权限 → 打开「Run commands in Termux」;
c) Termux 需在后台常驻(有 root 时本 App 会自动唤起,可跳过手动)。
7. 向用户确认安装成功,并简述五个操作:前台启动 / 后台启动 / 刷新状态 / 停止 / 重启。git clone https://github.com/RayMorTwinkle/OpencodeStarter.git
cd OpencodeStarter
export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools # 换成你的 Android SDK 路径
./gradlew assembleDebug # 调试包:app/build/outputs/apk/debug/app-debug.apk
./gradlew assembleRelease # release 包(可选,需 keystore.properties,见下)
adb install -r app/build/outputs/apk/debug/app-debug.apk环境要求:Android 手机(
minSdk 26/ Android 8.0+)、Termux 0.118.x(GitHub 版,不要用 Play 商店版)、Termux 内已装好可运行的opencode。 构建侧需要 JDK 17 与 Android SDK(compileSdk 36、AGP 8.7.3、Kotlin 2.0.21)。
- 允许外部指令:在 Termux 里执行一次
然后重启 Termux(或跑
echo 'allow-external-apps = true' >> ~/.termux/termux.properties
termux-reload-settings)。 - 授予附加权限:系统设置 → 应用 → OpencodeStarter → 附加权限 → 打开 Run commands in Termux。
- (可选,推荐)root:给 OpencodeStarter root 授权(KernelSU / Magisk 点允许即可),之后 Termux 未运行时会被自动拉起,全程零手动、零弹窗。
然后打开 App:输入框留空直接点 后台启动 → 刷新状态,看到 是否运行:是 即成功。在同一台手机的浏览器打开 http://127.0.0.1:<端口>/(端口由 opencode 决定,具体看 Termux 窗口日志那行 Web interface:)。
| 操作 | 做了什么 | 何时用 |
|---|---|---|
| ▶ 前台启动 | Termux 切到前台,opencode web 在可见终端运行 |
想直看日志 / 首次调试 |
| ⏸ 后台启动 | Termux 不抢前台,结果经回执返回 App | 起完就用浏览器 |
| ● 刷新状态 | 跑只读探活脚本,回显原文 + 解析三行 | 不确定有没有在跑 |
| ■ 停止 | pkill -f 'opencode (web|serve)'(幂等) |
用完清理 |
| 🔁 重启 | 停止 + 按当前输入/上次模式重发 | 改端口 / 换工作目录 |
| 字段 | 规则 | 留空时 |
|---|---|---|
| 工作目录 | 建议绝对路径(如 /data/data/com.termux/files/home/proj),非绝对路径会 toast 提醒 |
Termux 默认 ~/(不传 WORKDIR) |
| 端口 | 1-65535,非法值会 toast 并中止 |
不拼 --port,端口由 opencode 决定 |
| 监听 0.0.0.0 | 勾选后 --hostname 0.0.0.0,局域网可访问 |
仅 --hostname 127.0.0.1 |
后台启动(空输入) ──► 刷新状态(是/PID/URL) ──► 浏览器开 127.0.0.1:端口
│
改端口/换目录 ◄── 重启 ┘
│
用完 ──► 停止
App 只是一个「指令发射器」:指令经 RUN_COMMAND Intent 抵达 Termux 的 RunCommandService,由它在 Termux 环境里执行 opencode 或 bash;opencode 的 HTTP 服务再由手机浏览器访问。
flowchart LR
subgraph PHONE["Android 手机 (com.opencodestarter)"]
APP["OpencodeStarter<br/>MainActivity"]
BROWSER["手机浏览器"]
end
subgraph TERMUX["Termux 环境 (com.termux)"]
RCS["RunCommandService<br/>com.termux.app.RunCommandService"]
BIN["/data/data/com.termux/files/usr/bin<br/>opencode · bash"]
OC["opencode web<br/>--hostname … [--port …]"]
end
APP -->|"Intent<br/>action=com.termux.RUN_COMMAND"| RCS
RCS --> BIN
BIN --> OC
OC -->|"HTTP 127.0.0.1:<port>"| BROWSER
RCS -.->|"PendingIntent<br/>result Bundle"| APP
以「后台启动」为例:解析输入 → 检查 Termux → (有 root 则先确保 Termux 就绪)→ 发 RUN_COMMAND → 由 Termux 执行并回执。
sequenceDiagram
autonumber
participant U as 用户
participant M as MainActivity
participant R as RootUtil
participant T as Termux RunCommandService
participant O as opencode web
U->>M: 点「后台启动 / 停止 / 重启」
M->>M: currentTarget() 解析 workDir / port / listenAll
M->>M: isTermuxInstalled(com.termux)?
M->>R: withTermuxReady(action)
alt 有 root 且 Termux 未运行
R->>R: su -c pidof com.termux(空)
R->>R: su -c am start -n com.termux/.app.TermuxActivity
R->>R: 每 500ms 轮询,最多等 9s
end
M->>T: startService(intent) action=com.termux.RUN_COMMAND
T->>O: opencode web --hostname 127.0.0.1 [--port N]
T-->>M: PendingIntent → PluginResultsService → 广播
M-->>U: tvStatus 展示回执原文
「刷新状态」在 Termux 侧跑一段只读脚本:先 pgrep 找进程,再(已知端口时)curl 探 HTTP 状态码,最后 App 端把回执解析成三行。
flowchart TD
A["点「刷新状态」"] --> B{"端口字段合法?"}
B -->|"非空且非法"| B1["toast:请输入 1-65535"] --> Z["中止"]
B -->|空| C["probe(port = null)"]
B -->|"1-65535"| D["probe(port = N)"]
C --> E["bash -c:<br/>pgrep -af 'opencode (web|serve)'; echo ---; echo no-port-specified"]
D --> F["bash -c:<br/>pgrep …; curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:N/"]
E --> G["PluginResultsService 收 result Bundle"]
F --> G
G --> H["parseProbe():过滤 pgrep 行<br/>→ 是否运行 / PID / URL"]
任何按钮触发前都会先过 withTermuxReady():无 root 直接执行(老链路),有 root 则先确保 Termux 存活。
flowchart TD
A["任意操作触发"] --> B["withTermuxReady(action)"]
B --> C{"RootUtil.hasRoot()?"}
C -->|否| D["直接 action()<br/>(需用户手动开过 Termux)"]
C -->|是| E{"isTermuxRunning()?"}
E -->|是| D
E -->|否| F["startTermux()<br/>su -c am start -n com.termux/.app.TermuxActivity"]
F --> G{"9s 内 isTermuxRunning()?"}
G -->|是| H["action(),toast:Termux 已自动唤起"]
G -->|否| I["提示:请手动点「先打开 Termux」"]
Termux 只支持用 PendingIntent 回传结果(不支持 ResultReceiver)。回执先到本 App 的 PluginResultsService,格式化后再以同包显式广播交给 MainActivity 展示。
flowchart LR
A["Termux RunCommandService"] -->|"result Bundle<br/>key = 'result'"| B["PendingIntent.getService<br/>FLAG_ONE_SHOT | FLAG_MUTABLE"]
B --> C["PluginResultsService<br/>exported = false"]
C --> D["formatResult()<br/>stdout / stderr / exitCode / err / errmsg<br/>take(4000)"]
D --> E["sendBroadcast(ACTION_RESULT)<br/>setPackage(packageName)"]
E --> F["MainActivity.resultReceiver<br/>RECEIVER_NOT_EXPORTED"]
F --> G["tvStatus 原文 / tvParsed 三行解析"]
OpencodeStarter/
├── app/
│ ├── build.gradle.kts # namespace/版本/签名/minSdk26·targetSdk34·compileSdk36
│ └── src/main/
│ ├── AndroidManifest.xml # RUN_COMMAND 权限 + com.termux queries + Service 声明
│ ├── java/com/opencodestarter/
│ │ ├── MainActivity.kt # 四区 UI、输入校验、withTermuxReady、回执解析
│ │ ├── TermuxCommand.kt # RUN_COMMAND Intent 组装(extra key / 参数 / PendingIntent)
│ │ ├── PluginResultsService.kt # 接收 result Bundle → 格式化 → 同包广播
│ │ └── RootUtil.kt # su 探测/唤醒 Termux、切前台
│ └── res/
│ ├── layout/activity_main.xml # 启动/状态/管控/说明 四个 MaterialCardView
│ ├── values/themes.xml # Theme.Material3.DayNight.NoActionBar;teal/status 颜色 + OC.SectionTitle/OC.Mono 样式
│ └── drawable*/ # 按钮图标、状态框背景(含 night 变体)
├── docs/screenshots/ # 主界面 / 运行中 / Termux 日志 / Web UI
├── gradle/libs.versions.toml # 版本目录:agp 8.7.3 / kotlin 2.0.21 / material 1.12.0
├── build.gradle.kts / settings.gradle.kts
└── gradlew / gradle/wrapper/ # Gradle Wrapper
它真的只是一个「Intent 发射器」。 全部与 Termux 的交互都收敛在 TermuxCommand.kt,以 Termux 0.118.3 / 官方 Wiki 为准(master 分支多了几个 0.118.x 没有的 extra,别抄错):
| 项目 | 值 |
|---|---|
| action | com.termux.RUN_COMMAND(注意不是 com.termux.app.action.RUN_COMMAND) |
| component | com.termux / com.termux.app.RunCommandService |
| 发送方权限 | com.termux.permission.RUN_COMMAND(manifest 声明 + 系统设置手动授予) |
| Extra key 全称 | 类型 | 本 App 用法 |
|---|---|---|
com.termux.RUN_COMMAND_PATH |
String,唯一必填 | …/usr/bin/opencode 或 …/usr/bin/bash |
com.termux.RUN_COMMAND_ARGUMENTS |
String[] | ["web","--hostname","127.0.0.1","--port","N"] |
com.termux.RUN_COMMAND_WORKDIR |
String | 非空才传(留空 = Termux ~/) |
com.termux.RUN_COMMAND_BACKGROUND |
boolean | 前台 false;后台 / 探活 / 停止 true |
com.termux.RUN_COMMAND_SESSION_ACTION |
String "0" |
仅前台启动用(切到新 session 并打开 Termux) |
com.termux.RUN_COMMAND_COMMAND_LABEL |
String | 命令 label,如「OpencodeStarter 后台启动」 |
com.termux.RUN_COMMAND_PENDING_INTENT |
PendingIntent | 后台 / 探活 / 停止的结果回执通道 |
结果 Bundle。 外层 key 为 result,内含 stdout / stderr / exitCode(int) / err(int,-1 表示 OK) / errmsg / stdout_original_length / stderr_original_length。am startservice 构造不出 PendingIntent,所以拿不到回执 —— 必须走 Java/Kotlin 代码。
回执为什么可能收不到。 PendingIntent 用 FLAG_ONE_SHOT,因此 requestCode(即 executionId)必须每次唯一,否则只有第一次能收到回执;executionId 由 PluginResultsService.nextExecutionId() 从 1000 起单调递增。SDK ≥ S 时叠加 FLAG_MUTABLE。
三条脚本。
# opencode 参数
web --hostname <127.0.0.1|0.0.0.0> [--port <N>]
# 探活(端口已知)
pgrep -af 'opencode (web|serve)'; echo ---; curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:<port>/
# 探活(端口为空,只 pgrep,不 curl)
pgrep -af 'opencode (web|serve)'; echo ---; echo no-port-specified
# 停止(幂等)
pkill -f 'opencode (web|serve)'; echo exit=$?输入校验。 端口空 = null(不传 --port,由 opencode 决定),非空必须是 1..65535,否则返回哨兵 INVALID = -1 并 toast、中止操作;工作目录为空或非绝对路径(不以 /、~、$PREFIX 开头)时只 toast 提醒,不阻断。
root 自唤起的时间参数。 RootUtil.hasRoot() 首次约 100–200 ms(结果缓存,因此放在后台线程预热,不卡 UI);withTermuxReady() 拉起 Termux 后每 500 ms 轮询一次,总时限 9000 ms,超时则提示手动打开。所有 su -c 命令都带超时(exec(cmd, timeoutSec)),超时后 destroyForcibly()。RootUtil.exec 输出截断到 2000 字符,PluginResultsService.formatResult 截断到 4000 字符。
前台 / 后台的本质区别。 只是 RUN_COMMAND_BACKGROUND 与 SESSION_ACTION 两个 extra 的差异:前台启动 BACKGROUND=false + SESSION_ACTION="0"(Termux 被拉到前台,回执是不可靠的混合 transcript,所以 App 直接提示去看 Termux 窗口日志);后台启动 BACKGROUND=true + PENDING_INTENT(不抢前台,回执可靠)。
构建侧的小妥协。 因为 AGP 的 lint 在 JDK 26 下无法运行(lintVitalAnalyzeRelease 报 "26.0.2.1" 失败),而本项目体量小、无混淆,故 lint { checkReleaseBuilds = false }。release 未开启 minifyEnabled。
Q:点了按钮没反应?
A:三件套检查 —— ① Termux 内 allow-external-apps=true;② 系统设置里给了「Run commands in Termux」附加权限;③ Termux 进程在后台(有 root 则这条自动满足)。
Q:Oplus / ColorOS 弹「想要打开 Termux」?
A:无 root 时跨应用跳转会首次弹确认框,选「始终允许打开」即可;有 root 时走 su shell 通道,不弹。
Q:收不到执行回执?
A:确认 Termux ≥ 0.109(更老版本不支持 PendingIntent 回传)。另外 ResultReceiver 和 am 命令本来就没有回执通道,务必用 App 内的按钮触发。
Q:为什么前台启动看不到 App 里的回执?
A:前台命令的 transcript 是 stdout + stderr 混合、且经 PendingIntent 回传不可靠,所以 App 直接引导你去看 Termux 窗口里的 Web interface: 那行。
Q:填了端口但浏览器打不开?
A:可能端口被占。换个端口点「重启」,或进 Termux 手动 pkill -f 'opencode (web|serve)' 后再启。空端口不传 --port,端口由 opencode 决定,URL 一律以 Termux 日志为准。
Q:必须 root 吗? A:不必须。root 只用来「自动唤起 Termux」和「无弹窗切前台」;无 root 时点一下「先打开 Termux」手动开一次并挂后台即可,其余功能完全一样。
- 不要用 Play 商店版 Termux:
RUN_COMMAND需要 GitHub 版 Termux(0.118.x 验证可用)。 - targetSdk ≥ 30 的包可见性:已在本 App 的
AndroidManifest.xml里用<queries>声明com.termux;若你二次开发删掉它,Intent 会发不出去。 - 部分国产 ROM 杀后台激进:建议把 Termux 加入电池优化白名单,避免后台被清。
- 回执截断:Termux 侧
stdout + stderr合计约截断 100 KB,超长输出请去 Termux 窗口查看或重定向到文件。 - 前台 session 不会自动退出:Termux 默认返回键只把界面退回 App,opencode session 仍在后台;请用「停止」显式清理。
- 本 App 不采集、不上传任何数据;root 能力仅用于唤起 Termux,不读取用户数据。
MIT © 2026 RayMorTwinkle
- 与 Termux 的全部交互遵循官方 RUN_COMMAND-Intent Wiki,本项目的 extra key 对照表以 Termux 0.118.3 为准。
- 感谢 Termux 与 opencode —— 本项目只是把两者接起来的一层薄薄的 Android 遥控。
- 本仓库的图标、中英双语 README 与架构图为本项目重制。
环境:OPD2513 / Android 16 / Termux 0.118.3 / opencode 1.18.21
- 后台启动(空输入)→
opencode web --hostname 127.0.0.1,ss见127.0.0.1:4096 LISTEN。 - 刷新状态 →
[probe #1001] exit=0 err=-1,解析出「是否运行:是 / PID:19534」。 - 停止 →
pgrep为空;重启 → 出现新 PID;重复点「停止」不报错(幂等)。 - 前台启动 → Termux 置顶,可见
Web interface: http://127.0.0.1:42507/。 - root 冷机(先
force-stop com.termux再直接点后台启动)→ Termux 与 opencode 全自动起来、零弹窗。



