diff --git a/README.md b/README.md
index 98f2a65..e809606 100644
--- a/README.md
+++ b/README.md
@@ -18,9 +18,10 @@ or model weights.
```text
Happy app -> 127.0.0.1 Happy server -> local Happy daemon -> local Claude Code
- |
-OpenCode TUI/Web ---------------------+-> provider profile -> model API
- (cloud/relay/local)
+ |
+ Claude/Happy profile -> model API
+
+OpenCode TUI/Web -> independent OpenCode profile -> model API (cloud/relay/local)
```
- The control plane is self-contained on the phone.
@@ -29,11 +30,12 @@ OpenCode TUI/Web ---------------------+-> provider profile -> model API
if you intentionally need one.
- Local inference is optional. A small `llama.cpp` server can expose a
loopback OpenAI-compatible API for lightweight offline tasks.
-- Switching model profiles is explicit, keeps secrets outside committed
- configuration, and never changes service lifecycle implicitly.
-- The offline `Mobile Lab Control` app exposes three independent native widgets
- for Happy, llama.cpp, and model profiles. Termux:Widget remains an opt-in
- terminal fallback.
+- OpenCode and Claude/Happy have separate selectors and active files. Switching
+ either route is explicit and never changes the other client or a service
+ lifecycle.
+- The offline `Mobile Lab Control` app exposes five independent native widgets:
+ Happy, llama.cpp, Claude/Happy models, OpenCode Web, and OpenCode models.
+ Termux:Widget remains an opt-in terminal fallback.
## Repository layout
@@ -82,12 +84,13 @@ fallback have both been exercised. Exact versions and evidence are recorded in
| Phone-local Happy | exercised end to end | lifecycle wrapper included |
| Provider switching | exercised on the private device config | redacted generic profiles included |
| llama.cpp local model | Qwen3 0.6B Q8, API and `local-lite` passed | authenticated loopback lifecycle and bounded checker included |
-| Native control widgets | three independent widgets exercised with zero model calls | source, allow-list bridge, bilingual UI, and release APK included |
+| Native control widgets | five controls exercised on Android 14; lifecycle and model routes stayed independent | source, allow-list bridge, bilingual UI, and release APK included |
-The final phone acceptance also exercised the control split: profile changes
-left running services untouched, Happy reported `STALE` when its startup
-profile differed, and both services stopped cleanly without changing the
-selected profile.
+The 2026-08-09 phone acceptance also exercised real inference: OpenCode and
+Claude CLI each returned an exact nonce through the OpenAI-labelled relay, and
+Happy returned an exact nonce from the mobile UI. The Android 14 widget path was
+fixed to use a user-initiated activity, and both local services stopped cleanly
+afterward.
“Included” does not mean automatically installed. Upstream binaries and model
weights remain explicit, pinned operator choices.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 897d293..9ecfac6 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -17,9 +17,10 @@ GitHub Release 资产发布,不进入 Git 历史。
```text
Happy App -> 127.0.0.1 Happy server -> 本地 Happy daemon -> 本地 Claude Code
- |
-OpenCode TUI/Web ------------------------+-> provider profile -> 模型 API
- 云 / 中转 / 本地
+ |
+ Claude/Happy profile -> 模型 API
+
+OpenCode TUI/Web -> 独立 OpenCode profile -> 模型 API(云 / 中转 / 本地)
```
- 手机默认是自洽的控制面,不依赖远端 Happy relay。
@@ -28,9 +29,10 @@ OpenCode TUI/Web ------------------------+-> provider profile -> 模型 API
负责。
- 本地推理也是可选扩展:小型 llama.cpp server 只监听 loopback,适合离线
摘要、分类、脱敏和断网兜底。
-- 模型 profile 只保存路由信息;凭据始终位于独立的 mode `0600` 文件。
-- 离线 `Mobile Lab Control` App 分别提供 Happy、llama.cpp、模型配置三个
- 原生微件;Termux:Widget 只保留为可选终端回退。
+- OpenCode 与 Claude/Happy 分别使用独立选择器和 active 文件,切换一边绝不
+ 改写另一边,也不会隐式启停服务;凭据仍位于独立的 mode `0600` 文件。
+- 离线 `Mobile Lab Control` App 提供五个原生微件:Happy、llama.cpp、
+ Claude/Happy 模型、OpenCode Web、OpenCode 模型。
## 仓库内容
@@ -66,11 +68,12 @@ OpenCode TUI/Web ------------------------+-> provider profile -> 模型 API
| 手机本地 Happy | 已做端到端验证 | 提供显式启停 wrapper |
| Provider 切换 | 私有设备配置已验证 | 提供脱敏通用 profile |
| llama.cpp 本地模型 | Qwen3 0.6B Q8 的 API 与 `local-lite` 已通过 | 提供带认证的 loopback wrapper 与限次 checker |
-| 原生控制微件 | 三个独立微件已完成零模型调用真机验收 | 提供源码、白名单桥接、双语 UI 与 Release APK |
+| 原生控制微件 | 五项控制已在 Android 14 真机验收,服务与两条模型路由互不联动 | 提供源码、白名单桥接、双语 UI 与 Release APK |
-最终手机验收也覆盖了控制面解耦:切换 profile 不改变正在运行的服务,Happy
-启动时 profile 与当前配置不一致时明确显示 `STALE`,两个服务都能在不改变
-模型选择的情况下独立关闭。
+2026-08-09 的手机验收不仅检查配置:OpenCode、Claude CLI 均通过标为 OpenAI
+的中转返回精确 nonce,Happy 也从手机 App 得到精确回复。Android 14 微件改用
+用户触发的短生命周期 Activity 后不再报后台启动限制;验收结束后所有本地服务
+端口均已关闭。
“仓库包含 wrapper”不等于“上游组件已经自动安装”。二进制、服务端源码和
GGUF 都必须由操作者明确选择、固定来源并验证 SHA-256。
diff --git a/apps/mobile-lab-control/AndroidManifest.xml b/apps/mobile-lab-control/AndroidManifest.xml
index 5ee8f7d..3ac75c7 100644
--- a/apps/mobile-lab-control/AndroidManifest.xml
+++ b/apps/mobile-lab-control/AndroidManifest.xml
@@ -24,6 +24,16 @@
+
+
+
@@ -60,5 +70,15 @@
android:name="android.appwidget.provider"
android:resource="@xml/widget_model_info" />
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/mobile-lab-control/README.md b/apps/mobile-lab-control/README.md
index 07a30c2..7315ee4 100644
--- a/apps/mobile-lab-control/README.md
+++ b/apps/mobile-lab-control/README.md
@@ -2,13 +2,16 @@
[English](README.md) · [简体中文](README.zh-CN.md)
-An offline Android control surface for the Android AI Stack. It exposes three
+An offline Android control surface for the Android AI Stack. It exposes five
independent home-screen widgets:
- **Happy** — status, start/stop, restart, and refresh;
- **llama.cpp** — status, start/stop, memory release, and refresh;
-- **model profiles** — current readable profile plus one-tap configuration
- switching without changing either service lifecycle.
+- **Claude Code + Happy models** — readable cloud profile selection for that
+ client pair only;
+- **OpenCode Web** — status, loopback-only start/stop, and browser handoff;
+- **OpenCode models** — an independent cloud/relay/local selector that never
+ rewrites Claude Code or Happy.
The app has no Internet permission, reads no token, holds no root privilege,
and accepts no free-form command. It can invoke only
@@ -42,12 +45,18 @@ Verify future APKs against this identity as well as the attached file checksum.
`install-control-bridge --enable-external-apps`.
3. Install the APK and grant `com.termux.permission.RUN_COMMAND` when Android
prompts.
-4. Open the app and add Happy, llama.cpp, and model-profile widgets separately.
+4. Open the app and add the five controls separately. OpenCode Web may also be
+ started and stopped from the app page.
Copy [`config/widget-profiles.example`](../../config/widget-profiles.example) to
-`~/.config/android-ai-stack/widget-profiles`, keep it mode `0600`, and replace
-only the redacted profile names and labels. The bridge never reads provider
-tokens or CC Switch databases.
+`~/.config/android-ai-stack/widget-profiles`, or split it into mode-`0600`
+`claude-widget-profiles` and `opencode-widget-profiles`. The two selectors use
+separate active files and commands (`ai-model` and `oc-model`). The bridge never
+reads provider tokens or CC Switch databases.
+
+Android 14 blocks an AppWidget broadcast receiver from starting Termux's
+service directly. Widget taps therefore enter a short-lived, user-initiated
+activity before invoking the fixed allow-list bridge.
## Upgrade identity
diff --git a/apps/mobile-lab-control/README.zh-CN.md b/apps/mobile-lab-control/README.zh-CN.md
index 99cfaab..7e4a3f0 100644
--- a/apps/mobile-lab-control/README.zh-CN.md
+++ b/apps/mobile-lab-control/README.zh-CN.md
@@ -2,11 +2,13 @@
[English](README.md) · [简体中文](README.zh-CN.md)
-这是 Android AI Stack 的离线原生控制面,提供三个彼此独立的桌面微件:
+这是 Android AI Stack 的离线原生控制面,提供五个彼此独立的桌面微件:
- **Happy**:状态、启动/关闭、重启与刷新;
- **llama.cpp**:状态、启动/关闭、释放模型内存与刷新;
-- **模型配置**:显示当前可读名称,一键切换配置,但不改变两个服务的启停状态。
+- **Claude Code + Happy 模型**:只切换这两个客户端使用的云端配置;
+- **OpenCode Web**:显示状态、仅 loopback 启停,并在启动后打开浏览器;
+- **OpenCode 模型**:独立切换云端、中转或本地模型,绝不改写 Claude/Happy。
App 不申请联网权限、不读取 Token、不持有 root,也没有任意命令输入。它只通过
Termux 官方 [`RUN_COMMAND` 接口](https://github.com/termux/termux-app/wiki/RUN_COMMAND-Intent)
@@ -35,11 +37,16 @@ Release 发布,不提交进源码历史。
1. 在 Termux 安装 Android AI Stack 操作脚本;
2. 理解外部命令边界后执行 `install-control-bridge --enable-external-apps`;
3. 安装 APK,并在 Android 提示时授予 `com.termux.permission.RUN_COMMAND`;
-4. 打开 App,分别添加 Happy、llama.cpp、模型配置三个微件。
+4. 打开 App,分别添加五个控制微件;OpenCode Web 也可以直接在 App 页面启停。
把 [`config/widget-profiles.example`](../../config/widget-profiles.example) 复制到
-`~/.config/android-ai-stack/widget-profiles`,保持 mode `0600`,只填写脱敏的
-Profile 名称和展示名。桥接层不会读取 Provider Token 或 CC Switch 数据库。
+`~/.config/android-ai-stack/widget-profiles`,或拆成 mode `0600` 的
+`claude-widget-profiles` 与 `opencode-widget-profiles`。两个选择器分别使用
+`ai-model`、`oc-model` 和独立 active 文件。桥接层不会读取 Provider Token 或
+CC Switch 数据库。
+
+Android 14 不允许 AppWidget 广播接收器直接启动 Termux 服务,因此微件点击会先
+进入一个短生命周期、由用户明确触发的 Activity,再调用固定白名单桥接。
## 升级签名说明
diff --git a/apps/mobile-lab-control/res/layout/activity_main.xml b/apps/mobile-lab-control/res/layout/activity_main.xml
index d41023b..e6f6661 100644
--- a/apps/mobile-lab-control/res/layout/activity_main.xml
+++ b/apps/mobile-lab-control/res/layout/activity_main.xml
@@ -56,6 +56,24 @@
android:text="@string/refresh_all"
android:textColor="@color/widget_text" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/mobile-lab-control/res/values-zh-rCN/strings.xml b/apps/mobile-lab-control/res/values-zh-rCN/strings.xml
index 5508b11..08e7dd1 100644
--- a/apps/mobile-lab-control/res/values-zh-rCN/strings.xml
+++ b/apps/mobile-lab-control/res/values-zh-rCN/strings.xml
@@ -3,30 +3,39 @@
Mobile Lab Control
Happy 服务控制
llama.cpp 本地模型
- AI 模型切换
+ Claude Code + Happy 模型
+ OpenCode Web 服务
+ OpenCode 模型切换
- 三个独立桌面微件分别控制 Happy、llama.cpp 与模型配置。按钮显示已配置的可读名称,不暴露内部别名。
+ 五个独立桌面微件。Claude Code / Happy 与 OpenCode 是彼此独立的控制面,切换互不影响。
正在检查 Termux 权限…
授权 Termux 控制权限
刷新全部状态
+ 启动 OpenCode Web 并在浏览器打开
添加到桌面
添加 Happy 微件
添加 llama.cpp 微件
- 添加模型切换微件
+ 添加 Claude Code + Happy 模型微件
+ 添加 OpenCode Web 微件
+ 添加 OpenCode 模型微件
安全边界:本 App 不联网、不读取 Token、不持有 root,只调用已安装的固定白名单桥接脚本。
Happy 本机服务
llama.cpp 本地模型
- AI 模型配置
+ Claude Code + Happy 模型
+ OpenCode Web 服务
+ OpenCode 模型配置
状态:等待刷新
启动 / 停止
重启
↻
- 点击名称只切配置,不启停 Happy 或本地模型。
+ 只切换 Claude Code 与 Happy 的配置;不影响 OpenCode,也不启停服务。
+ 仅切换 OpenCode 的模型配置,绝不改动 Claude Code 或 Happy。
+ 本地 llama.cpp
当前:等待刷新
等待刷新
- 本地模型
+ 本地 llama.cpp · Qwen3-0.6B
模型配置 A
模型配置 B
模型配置 C
@@ -51,6 +60,10 @@
正在启动本地模型,通常需要 5–15 秒…
正在停止本地模型并释放内存…
正在切换模型配置…
+ 正在启动 OpenCode Web…
+ 正在停止 OpenCode Web…
+ 正在更新 OpenCode Web 服务…
+ 正在切换 OpenCode 模型…
Termux 没有返回结果
命令失败,退出码 %1$d
@@ -76,6 +89,10 @@
状态:已关闭,模型内存已释放
停止并释放内存
启动本地模型
+ 状态:已运行 · http://127.0.0.1:4096
+ 状态:已关闭
+ 启动 Web 服务
+ 停止 Web 服务
%1$s(需登录)
%1$s(未配置)
diff --git a/apps/mobile-lab-control/res/values/strings.xml b/apps/mobile-lab-control/res/values/strings.xml
index 96714fc..a7e8095 100644
--- a/apps/mobile-lab-control/res/values/strings.xml
+++ b/apps/mobile-lab-control/res/values/strings.xml
@@ -3,30 +3,39 @@
Mobile Lab Control
Happy service control
llama.cpp local model
- AI profile switcher
+ Claude Code + Happy models
+ OpenCode Web service
+ OpenCode model switcher
- Three independent home-screen widgets for Happy, llama.cpp, and model profiles. Buttons show configured names instead of internal aliases.
+ Five independent home-screen widgets. Claude Code / Happy profiles and OpenCode profiles are separate control planes.
Checking Termux permission…
Grant Termux command permission
Refresh all status
+ Start OpenCode Web and open browser
Add to home screen
Add Happy widget
Add llama.cpp widget
- Add profile widget
+ Add Claude Code + Happy model widget
+ Add OpenCode Web widget
+ Add OpenCode model widget
Security boundary: this app has no network permission, reads no token, holds no root privilege, and invokes only an installed allow-list bridge.
Happy local service
llama.cpp local model
- AI model profile
+ Claude Code + Happy models
+ OpenCode Web service
+ OpenCode model profile
Status: waiting for refresh
Start / stop
Restart
↻
- Selecting a name changes configuration only; it does not start or stop a service.
+ Changes Claude Code and Happy configuration only; OpenCode and service state are untouched.
+ OpenCode only: changes its provider configuration, never Claude Code or Happy.
+ Local llama.cpp
Current: waiting for refresh
waiting for refresh
- Local model
+ Local llama.cpp · Qwen3-0.6B
Profile A
Profile B
Profile C
@@ -51,6 +60,10 @@
Starting the local model; this usually takes 5–15 seconds…
Stopping the local model and releasing memory…
Switching model profile…
+ Starting OpenCode Web…
+ Stopping OpenCode Web…
+ Updating OpenCode Web service…
+ Switching OpenCode model…
Termux returned no result
Command failed with exit code %1$d
@@ -76,6 +89,10 @@
Status: stopped; model memory released
Stop and release memory
Start local model
+ Status: running · http://127.0.0.1:4096
+ Status: stopped
+ Start Web service
+ Stop Web service
%1$s (sign-in required)
%1$s (not configured)
diff --git a/apps/mobile-lab-control/res/values/styles.xml b/apps/mobile-lab-control/res/values/styles.xml
index 85fe2bd..a33ce50 100644
--- a/apps/mobile-lab-control/res/values/styles.xml
+++ b/apps/mobile-lab-control/res/values/styles.xml
@@ -7,4 +7,11 @@
- #FF0B1020
- false
+
diff --git a/apps/mobile-lab-control/res/xml/widget_oc_model_info.xml b/apps/mobile-lab-control/res/xml/widget_oc_model_info.xml
new file mode 100644
index 0000000..a860093
--- /dev/null
+++ b/apps/mobile-lab-control/res/xml/widget_oc_model_info.xml
@@ -0,0 +1,6 @@
+
+
diff --git a/apps/mobile-lab-control/res/xml/widget_oc_web_info.xml b/apps/mobile-lab-control/res/xml/widget_oc_web_info.xml
new file mode 100644
index 0000000..b3d4919
--- /dev/null
+++ b/apps/mobile-lab-control/res/xml/widget_oc_web_info.xml
@@ -0,0 +1,6 @@
+
+
diff --git a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/CommandResultReceiver.java b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/CommandResultReceiver.java
index 4184caa..d645b66 100644
--- a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/CommandResultReceiver.java
+++ b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/CommandResultReceiver.java
@@ -3,6 +3,7 @@
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
+import android.net.Uri;
import android.os.Bundle;
import android.widget.Toast;
@@ -26,6 +27,15 @@ public void onReceive(Context context, Intent intent) {
if (exitCode == 0) {
WidgetState.applyStatus(context, stdout, operation);
+ if ("oc-web-toggle".equals(operation) && stdout.contains("READY |")) {
+ Intent browser = new Intent(Intent.ACTION_VIEW, Uri.parse("http://127.0.0.1:4096"));
+ browser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ try {
+ context.startActivity(browser);
+ } catch (Exception ignored) {
+ // The widget still shows the fixed loopback address if no browser is available.
+ }
+ }
if (operation != null && !operation.startsWith("status")) {
Toast.makeText(context, R.string.operation_complete, Toast.LENGTH_SHORT).show();
}
diff --git a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/ControlActivity.java b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/ControlActivity.java
new file mode 100644
index 0000000..8b11e88
--- /dev/null
+++ b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/ControlActivity.java
@@ -0,0 +1,86 @@
+package io.github.toolazytoname.mobilelabcontrol;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+
+/** User-initiated bridge from an AppWidget tap to Termux RunCommandService. */
+public final class ControlActivity extends Activity {
+ @Override protected void onCreate(Bundle state) {
+ super.onCreate(state);
+ new Handler().post(this::dispatch);
+ }
+
+ private void dispatch() {
+ Intent intent = getIntent();
+ String action = intent == null ? null : intent.getAction();
+ if (action == null) {
+ finish();
+ return;
+ }
+ if (ControlContract.ACTION_HAPPY_TOGGLE.equals(action)) {
+ String current = WidgetState.get(this, "happy_state", "OFF");
+ busy(ControlContract.SCOPE_HAPPY, "ON".equals(current)
+ ? R.string.busy_happy_stop : R.string.busy_happy_start);
+ TermuxRunner.run(this, "happy-toggle");
+ } else if (ControlContract.ACTION_HAPPY_RESTART.equals(action)) {
+ busy(ControlContract.SCOPE_HAPPY, R.string.busy_happy_restart);
+ TermuxRunner.run(this, "happy-restart");
+ } else if (ControlContract.ACTION_LLAMA_TOGGLE.equals(action)) {
+ String current = WidgetState.get(this, "llama_state", "OFF");
+ busy(ControlContract.SCOPE_LLAMA, "ON".equals(current)
+ ? R.string.busy_llama_stop : R.string.busy_llama_start);
+ TermuxRunner.run(this, "llama-toggle");
+ } else if (ControlContract.ACTION_MODEL.equals(action)) {
+ String target = intent.getStringExtra(ControlContract.EXTRA_MODEL);
+ if (!ControlContract.isAllowedModelTarget(target)) {
+ fail(ControlContract.SCOPE_MODEL, R.string.error_unknown_profile);
+ } else {
+ busy(ControlContract.SCOPE_MODEL, R.string.busy_model_switch);
+ TermuxRunner.run(this, "model-use", target);
+ }
+ } else if (ControlContract.ACTION_OC_MODEL.equals(action)) {
+ String target = intent.getStringExtra(ControlContract.EXTRA_MODEL);
+ if (!ControlContract.isAllowedOpenCodeTarget(target)) {
+ fail(ControlContract.SCOPE_OC_MODEL, R.string.error_unknown_profile);
+ } else {
+ busy(ControlContract.SCOPE_OC_MODEL, R.string.busy_oc_model_switch);
+ TermuxRunner.run(this, "oc-model-use", target);
+ }
+ } else if (ControlContract.ACTION_OC_WEB_TOGGLE.equals(action)) {
+ String current = WidgetState.get(this, "oc_web_state", "OFF");
+ boolean starting = !"ON".equals(current);
+ busy(ControlContract.SCOPE_OC_WEB, starting
+ ? R.string.busy_oc_web_start : R.string.busy_oc_web_stop);
+ TermuxRunner.run(this, "oc-web-toggle");
+ if (starting) {
+ // Keep the user-initiated Activity alive until the bounded web
+ // startup window ends, then open the fixed loopback URL.
+ new Handler().postDelayed(() -> {
+ startActivity(new Intent(Intent.ACTION_VIEW,
+ Uri.parse("http://127.0.0.1:4096")));
+ finish();
+ }, 5000);
+ return;
+ }
+ } else if (ControlContract.ACTION_REFRESH.equals(action)) {
+ String scope = intent.getStringExtra(ControlContract.EXTRA_SCOPE);
+ if (!ControlContract.isAllowedScope(scope)) scope = ControlContract.SCOPE_ALL;
+ busy(scope, R.string.busy_refresh);
+ TermuxRunner.refresh(this, scope);
+ }
+ finish();
+ }
+
+ private void busy(String scope, int message) {
+ WidgetState.setBusy(this, scope, getString(message));
+ WidgetUpdater.updateAll(this);
+ }
+
+ private void fail(String scope, int message) {
+ WidgetState.setError(this, scope, getString(message));
+ WidgetUpdater.updateAll(this);
+ }
+}
diff --git a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/ControlContract.java b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/ControlContract.java
index f1963e4..86f8986 100644
--- a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/ControlContract.java
+++ b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/ControlContract.java
@@ -25,24 +25,41 @@ final class ControlContract {
static final String ACTION_LLAMA_TOGGLE = "io.github.toolazytoname.mobilelabcontrol.LLAMA_TOGGLE";
static final String ACTION_REFRESH = "io.github.toolazytoname.mobilelabcontrol.REFRESH";
static final String ACTION_MODEL = "io.github.toolazytoname.mobilelabcontrol.MODEL";
+ static final String ACTION_OC_WEB_TOGGLE = "io.github.toolazytoname.mobilelabcontrol.OC_WEB_TOGGLE";
+ static final String ACTION_OC_MODEL = "io.github.toolazytoname.mobilelabcontrol.OC_MODEL";
static final String EXTRA_MODEL = "model";
static final String EXTRA_OPERATION = "operation";
+ static final String EXTRA_SCOPE = "scope";
static final String SCOPE_ALL = "all";
static final String SCOPE_HAPPY = "happy";
static final String SCOPE_LLAMA = "llama";
static final String SCOPE_MODEL = "model";
+ static final String SCOPE_OC_WEB = "oc-web";
+ static final String SCOPE_OC_MODEL = "oc-model";
static boolean isAllowedModelTarget(String target) {
+ return target != null && target.matches("slot-0[1-9]");
+ }
+
+ static boolean isAllowedOpenCodeTarget(String target) {
return "local".equals(target) || (target != null && target.matches("slot-0[1-9]"));
}
+ static boolean isAllowedScope(String scope) {
+ return SCOPE_ALL.equals(scope) || SCOPE_HAPPY.equals(scope) || SCOPE_LLAMA.equals(scope)
+ || SCOPE_MODEL.equals(scope) || SCOPE_OC_WEB.equals(scope) || SCOPE_OC_MODEL.equals(scope);
+ }
+
static boolean isAllowedCommand(String operation, String... arguments) {
int count = arguments == null ? 0 : arguments.length;
if ("status".equals(operation) || "happy-toggle".equals(operation)
|| "happy-restart".equals(operation) || "llama-toggle".equals(operation)) {
return count == 0;
}
+ if ("oc-web-toggle".equals(operation) || "oc-web-start".equals(operation)
+ || "oc-web-stop".equals(operation)) return count == 0;
+ if ("oc-model-use".equals(operation)) return count == 1 && isAllowedOpenCodeTarget(arguments[0]);
return "model-use".equals(operation) && count == 1 && isAllowedModelTarget(arguments[0]);
}
@@ -51,9 +68,13 @@ static String scopeForOperation(String operation) {
if ("status-happy".equals(operation)) return SCOPE_HAPPY;
if ("status-llama".equals(operation)) return SCOPE_LLAMA;
if ("status-model".equals(operation)) return SCOPE_MODEL;
+ if ("status-oc-web".equals(operation)) return SCOPE_OC_WEB;
+ if ("status-oc-model".equals(operation)) return SCOPE_OC_MODEL;
if (operation.startsWith("happy-")) return SCOPE_HAPPY;
if (operation.startsWith("llama-")) return SCOPE_LLAMA;
if (operation.startsWith("model-")) return SCOPE_MODEL;
+ if (operation.startsWith("oc-web-")) return SCOPE_OC_WEB;
+ if (operation.startsWith("oc-model-")) return SCOPE_OC_MODEL;
return SCOPE_ALL;
}
diff --git a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/HappyWidgetProvider.java b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/HappyWidgetProvider.java
index 3b09ad0..9cc86f5 100644
--- a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/HappyWidgetProvider.java
+++ b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/HappyWidgetProvider.java
@@ -3,39 +3,10 @@
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.Context;
-import android.content.Intent;
public final class HappyWidgetProvider extends AppWidgetProvider {
@Override
public void onUpdate(Context context, AppWidgetManager manager, int[] appWidgetIds) {
WidgetUpdater.updateHappy(context);
- TermuxRunner.refresh(context, ControlContract.SCOPE_HAPPY);
- }
-
- @Override
- public void onReceive(Context context, Intent intent) {
- super.onReceive(context, intent);
- if (intent == null || intent.getAction() == null) return;
- switch (intent.getAction()) {
- case ControlContract.ACTION_HAPPY_TOGGLE:
- String current = WidgetState.get(context, "happy_state", "OFF");
- WidgetState.setBusy(context, ControlContract.SCOPE_HAPPY, context.getString("ON".equals(current)
- ? R.string.busy_happy_stop : R.string.busy_happy_start));
- WidgetUpdater.updateAll(context);
- TermuxRunner.run(context, "happy-toggle");
- break;
- case ControlContract.ACTION_HAPPY_RESTART:
- WidgetState.setBusy(context, ControlContract.SCOPE_HAPPY, context.getString(R.string.busy_happy_restart));
- WidgetUpdater.updateAll(context);
- TermuxRunner.run(context, "happy-restart");
- break;
- case ControlContract.ACTION_REFRESH:
- WidgetState.setBusy(context, ControlContract.SCOPE_HAPPY, context.getString(R.string.busy_refresh));
- WidgetUpdater.updateAll(context);
- TermuxRunner.refresh(context, ControlContract.SCOPE_HAPPY);
- break;
- default:
- break;
- }
}
}
diff --git a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/LlamaWidgetProvider.java b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/LlamaWidgetProvider.java
index 94ff03f..0c4787b 100644
--- a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/LlamaWidgetProvider.java
+++ b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/LlamaWidgetProvider.java
@@ -3,34 +3,10 @@
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.Context;
-import android.content.Intent;
public final class LlamaWidgetProvider extends AppWidgetProvider {
@Override
public void onUpdate(Context context, AppWidgetManager manager, int[] appWidgetIds) {
WidgetUpdater.updateLlama(context);
- TermuxRunner.refresh(context, ControlContract.SCOPE_LLAMA);
- }
-
- @Override
- public void onReceive(Context context, Intent intent) {
- super.onReceive(context, intent);
- if (intent == null || intent.getAction() == null) return;
- switch (intent.getAction()) {
- case ControlContract.ACTION_LLAMA_TOGGLE:
- String current = WidgetState.get(context, "llama_state", "OFF");
- WidgetState.setBusy(context, ControlContract.SCOPE_LLAMA, context.getString("ON".equals(current)
- ? R.string.busy_llama_stop : R.string.busy_llama_start));
- WidgetUpdater.updateAll(context);
- TermuxRunner.run(context, "llama-toggle");
- break;
- case ControlContract.ACTION_REFRESH:
- WidgetState.setBusy(context, ControlContract.SCOPE_LLAMA, context.getString(R.string.busy_refresh));
- WidgetUpdater.updateAll(context);
- TermuxRunner.refresh(context, ControlContract.SCOPE_LLAMA);
- break;
- default:
- break;
- }
}
}
diff --git a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/MainActivity.java b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/MainActivity.java
index c32641c..58d27e6 100644
--- a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/MainActivity.java
+++ b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/MainActivity.java
@@ -5,6 +5,9 @@
import android.content.ComponentName;
import android.content.pm.PackageManager;
import android.os.Bundle;
+import android.os.Handler;
+import android.net.Uri;
+import android.content.Intent;
import android.widget.Button;
import android.widget.TextView;
import android.widget.Toast;
@@ -23,9 +26,24 @@ protected void onCreate(Bundle savedInstanceState) {
WidgetUpdater.updateAll(this);
TermuxRunner.refresh(this, ControlContract.SCOPE_ALL);
});
+ findViewById(R.id.oc_web_toggle).setOnClickListener(view -> {
+ WidgetState.setBusy(this, ControlContract.SCOPE_OC_WEB, getString(R.string.busy_oc_web_toggle));
+ WidgetUpdater.updateAll(this);
+ TermuxRunner.run(this, "oc-web-start");
+ new Handler().postDelayed(() -> {
+ startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("http://127.0.0.1:4096")));
+ }, 5000);
+ });
+ findViewById(R.id.oc_web_stop).setOnClickListener(view -> {
+ WidgetState.setBusy(this, ControlContract.SCOPE_OC_WEB, getString(R.string.busy_oc_web_stop));
+ WidgetUpdater.updateAll(this);
+ TermuxRunner.run(this, "oc-web-stop");
+ });
findViewById(R.id.pin_happy).setOnClickListener(view -> pin(HappyWidgetProvider.class));
findViewById(R.id.pin_llama).setOnClickListener(view -> pin(LlamaWidgetProvider.class));
findViewById(R.id.pin_model).setOnClickListener(view -> pin(ModelWidgetProvider.class));
+ findViewById(R.id.pin_oc_web).setOnClickListener(view -> pin(OpenCodeWebWidgetProvider.class));
+ findViewById(R.id.pin_oc_model).setOnClickListener(view -> pin(OpenCodeModelWidgetProvider.class));
updatePermissionStatus();
}
diff --git a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/ModelWidgetProvider.java b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/ModelWidgetProvider.java
index 91cc6ac..23989ea 100644
--- a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/ModelWidgetProvider.java
+++ b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/ModelWidgetProvider.java
@@ -3,38 +3,10 @@
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.Context;
-import android.content.Intent;
public final class ModelWidgetProvider extends AppWidgetProvider {
@Override
public void onUpdate(Context context, AppWidgetManager manager, int[] appWidgetIds) {
WidgetUpdater.updateModel(context);
- TermuxRunner.refresh(context, ControlContract.SCOPE_MODEL);
- }
-
- @Override
- public void onReceive(Context context, Intent intent) {
- super.onReceive(context, intent);
- if (intent == null || intent.getAction() == null) return;
- switch (intent.getAction()) {
- case ControlContract.ACTION_MODEL:
- String model = intent.getStringExtra(ControlContract.EXTRA_MODEL);
- if (!ControlContract.isAllowedModelTarget(model)) {
- WidgetState.setError(context, ControlContract.SCOPE_MODEL, context.getString(R.string.error_unknown_profile));
- WidgetUpdater.updateAll(context);
- return;
- }
- WidgetState.setBusy(context, ControlContract.SCOPE_MODEL, context.getString(R.string.busy_model_switch));
- WidgetUpdater.updateAll(context);
- TermuxRunner.run(context, "model-use", model);
- break;
- case ControlContract.ACTION_REFRESH:
- WidgetState.setBusy(context, ControlContract.SCOPE_MODEL, context.getString(R.string.busy_refresh));
- WidgetUpdater.updateAll(context);
- TermuxRunner.refresh(context, ControlContract.SCOPE_MODEL);
- break;
- default:
- break;
- }
}
}
diff --git a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/OpenCodeModelWidgetProvider.java b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/OpenCodeModelWidgetProvider.java
new file mode 100644
index 0000000..bfb3104
--- /dev/null
+++ b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/OpenCodeModelWidgetProvider.java
@@ -0,0 +1,11 @@
+package io.github.toolazytoname.mobilelabcontrol;
+
+import android.appwidget.AppWidgetManager;
+import android.appwidget.AppWidgetProvider;
+import android.content.Context;
+
+public final class OpenCodeModelWidgetProvider extends AppWidgetProvider {
+ @Override public void onUpdate(Context context, AppWidgetManager manager, int[] ids) {
+ WidgetUpdater.updateOpenCodeModel(context);
+ }
+}
diff --git a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/OpenCodeWebWidgetProvider.java b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/OpenCodeWebWidgetProvider.java
new file mode 100644
index 0000000..2f5c2b7
--- /dev/null
+++ b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/OpenCodeWebWidgetProvider.java
@@ -0,0 +1,11 @@
+package io.github.toolazytoname.mobilelabcontrol;
+
+import android.appwidget.AppWidgetManager;
+import android.appwidget.AppWidgetProvider;
+import android.content.Context;
+
+public final class OpenCodeWebWidgetProvider extends AppWidgetProvider {
+ @Override public void onUpdate(Context context, AppWidgetManager manager, int[] ids) {
+ WidgetUpdater.updateOpenCodeWeb(context);
+ }
+}
diff --git a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/TermuxRunner.java b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/TermuxRunner.java
index 62041f7..3119c30 100644
--- a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/TermuxRunner.java
+++ b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/TermuxRunner.java
@@ -63,6 +63,23 @@ private static void runInternal(Context context, String operation, String callba
try {
context.startService(command);
+ } catch (IllegalStateException backgroundRestricted) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ WidgetState.setError(context, scope, context.getString(
+ R.string.error_termux_call, backgroundRestricted.getClass().getSimpleName()));
+ WidgetUpdater.updateAll(context);
+ return;
+ }
+ try {
+ // Android 12+ can reject even a documented external service
+ // start after an AppWidget PendingIntent. Termux supports the
+ // foreground-service entry path for RUN_COMMAND.
+ context.startForegroundService(command);
+ } catch (Exception foregroundFailure) {
+ WidgetState.setError(context, scope, context.getString(
+ R.string.error_termux_call, foregroundFailure.getClass().getSimpleName()));
+ WidgetUpdater.updateAll(context);
+ }
} catch (Exception exception) {
WidgetState.setError(context, scope, context.getString(
R.string.error_termux_call, exception.getClass().getSimpleName()));
diff --git a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/WidgetState.java b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/WidgetState.java
index 34583ec..9321fb5 100644
--- a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/WidgetState.java
+++ b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/WidgetState.java
@@ -12,7 +12,9 @@ final class WidgetState {
ControlContract.SCOPE_ALL,
ControlContract.SCOPE_HAPPY,
ControlContract.SCOPE_LLAMA,
- ControlContract.SCOPE_MODEL
+ ControlContract.SCOPE_MODEL,
+ ControlContract.SCOPE_OC_WEB,
+ ControlContract.SCOPE_OC_MODEL
};
static SharedPreferences prefs(Context context) {
@@ -75,8 +77,10 @@ private static Map parse(String stdout) {
private static boolean isAllowedStatusKey(String key) {
if ("happy_state".equals(key) || "llama_state".equals(key)
- || "model_alias".equals(key) || "model_name".equals(key)) return true;
- return key.matches("(provider|readiness)_(local|slot0[1-9])");
+ || "model_alias".equals(key) || "model_name".equals(key)
+ || "oc_web_state".equals(key) || "oc_model_alias".equals(key)
+ || "oc_model_name".equals(key)) return true;
+ return key.matches("(provider|readiness|oc_provider|oc_readiness)_(local|slot0[1-9])");
}
static String get(Context context, String key, String fallback) {
diff --git a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/WidgetUpdater.java b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/WidgetUpdater.java
index 5d0825f..97a4597 100644
--- a/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/WidgetUpdater.java
+++ b/apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/WidgetUpdater.java
@@ -6,6 +6,7 @@
import android.content.Context;
import android.content.Intent;
import android.os.Build;
+import android.view.View;
import android.widget.RemoteViews;
final class WidgetUpdater {
@@ -28,6 +29,8 @@ static void updateAll(Context context) {
updateHappy(context);
updateLlama(context);
updateModel(context);
+ updateOpenCodeWeb(context);
+ updateOpenCodeModel(context);
}
static void updateHappy(Context context) {
@@ -41,9 +44,9 @@ static void updateHappy(Context context) {
? context.getString(R.string.error_format, error) : happyText(context, state));
views.setTextViewText(R.id.happy_status, status);
views.setTextViewText(R.id.happy_toggle, happyAction(context, state));
- views.setOnClickPendingIntent(R.id.happy_toggle, broadcast(context, HappyWidgetProvider.class, ControlContract.ACTION_HAPPY_TOGGLE, 101));
- views.setOnClickPendingIntent(R.id.happy_restart, broadcast(context, HappyWidgetProvider.class, ControlContract.ACTION_HAPPY_RESTART, 102));
- views.setOnClickPendingIntent(R.id.happy_refresh, broadcast(context, HappyWidgetProvider.class, ControlContract.ACTION_REFRESH, 103));
+ views.setOnClickPendingIntent(R.id.happy_toggle, control(context, ControlContract.ACTION_HAPPY_TOGGLE, ControlContract.SCOPE_HAPPY, 101));
+ views.setOnClickPendingIntent(R.id.happy_restart, control(context, ControlContract.ACTION_HAPPY_RESTART, ControlContract.SCOPE_HAPPY, 102));
+ views.setOnClickPendingIntent(R.id.happy_refresh, control(context, ControlContract.ACTION_REFRESH, ControlContract.SCOPE_HAPPY, 103));
manager.updateAppWidget(ids, views);
}
@@ -59,8 +62,8 @@ static void updateLlama(Context context) {
views.setTextViewText(R.id.llama_status, status);
views.setTextViewText(R.id.llama_toggle, context.getString("ON".equals(state)
? R.string.llama_action_stop : R.string.llama_action_start));
- views.setOnClickPendingIntent(R.id.llama_toggle, broadcast(context, LlamaWidgetProvider.class, ControlContract.ACTION_LLAMA_TOGGLE, 201));
- views.setOnClickPendingIntent(R.id.llama_refresh, broadcast(context, LlamaWidgetProvider.class, ControlContract.ACTION_REFRESH, 202));
+ views.setOnClickPendingIntent(R.id.llama_toggle, control(context, ControlContract.ACTION_LLAMA_TOGGLE, ControlContract.SCOPE_LLAMA, 201));
+ views.setOnClickPendingIntent(R.id.llama_refresh, control(context, ControlContract.ACTION_REFRESH, ControlContract.SCOPE_LLAMA, 202));
manager.updateAppWidget(ids, views);
}
@@ -75,23 +78,69 @@ static void updateModel(Context context) {
String current = !busy.isEmpty() ? busy : (!error.isEmpty()
? context.getString(R.string.error_format, error) : context.getString(R.string.current_format, name));
views.setTextViewText(R.id.model_current, current);
- views.setOnClickPendingIntent(R.id.model_refresh, broadcast(context, ModelWidgetProvider.class, ControlContract.ACTION_REFRESH, 301));
+ views.setOnClickPendingIntent(R.id.model_refresh, control(context, ControlContract.ACTION_REFRESH, ControlContract.SCOPE_MODEL, 301));
+ // The phone-local llama.cpp route belongs to OpenCode only. Hiding it
+ // here prevents the Claude Code + Happy widget from implying that
+ // Happy can run against the local 0.6B model.
+ views.setViewVisibility(R.id.model_local, View.GONE);
- for (int i = 0; i < MODEL_TARGETS.length; i++) {
+ for (int i = 1; i < MODEL_TARGETS.length; i++) {
String readiness = WidgetState.get(context, readinessKey(MODEL_TARGETS[i]), "unavailable");
String providerName = providerName(context, MODEL_TARGETS[i], MODEL_DEFAULT_NAMES[i], readiness);
String label = MODEL_TARGETS[i].equals(target) ? "✓ " + providerName : providerName;
views.setTextViewText(MODEL_IDS[i], label);
views.setBoolean(MODEL_IDS[i], "setEnabled", !"unavailable".equals(readiness));
- Intent action = new Intent(context, ModelWidgetProvider.class);
+ Intent action = new Intent(context, ControlActivity.class);
action.setAction(ControlContract.ACTION_MODEL);
action.putExtra(ControlContract.EXTRA_MODEL, MODEL_TARGETS[i]);
- views.setOnClickPendingIntent(MODEL_IDS[i], PendingIntent.getBroadcast(
+ views.setOnClickPendingIntent(MODEL_IDS[i], PendingIntent.getActivity(
context, 400 + i, action, immutableUpdateFlags()));
}
manager.updateAppWidget(ids, views);
}
+ static void updateOpenCodeWeb(Context context) {
+ AppWidgetManager manager = AppWidgetManager.getInstance(context);
+ int[] ids = manager.getAppWidgetIds(new ComponentName(context, OpenCodeWebWidgetProvider.class));
+ RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_oc_web);
+ String busy = WidgetState.busy(context, ControlContract.SCOPE_OC_WEB);
+ String error = WidgetState.error(context, ControlContract.SCOPE_OC_WEB);
+ String state = WidgetState.get(context, "oc_web_state", "UNKNOWN");
+ String status = !busy.isEmpty() ? busy : (!error.isEmpty() ? context.getString(R.string.error_format, error)
+ : ("ON".equals(state) ? context.getString(R.string.oc_web_on) : "OFF".equals(state) ? context.getString(R.string.oc_web_off) : context.getString(R.string.status_waiting)));
+ views.setTextViewText(R.id.oc_web_status, status);
+ views.setTextViewText(R.id.oc_web_toggle, context.getString("ON".equals(state) ? R.string.oc_web_stop : R.string.oc_web_start));
+ views.setOnClickPendingIntent(R.id.oc_web_toggle, control(context, ControlContract.ACTION_OC_WEB_TOGGLE, ControlContract.SCOPE_OC_WEB, 501));
+ views.setOnClickPendingIntent(R.id.oc_web_refresh, control(context, ControlContract.ACTION_REFRESH, ControlContract.SCOPE_OC_WEB, 502));
+ manager.updateAppWidget(ids, views);
+ }
+
+ static void updateOpenCodeModel(Context context) {
+ AppWidgetManager manager = AppWidgetManager.getInstance(context);
+ int[] ids = manager.getAppWidgetIds(new ComponentName(context, OpenCodeModelWidgetProvider.class));
+ RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_model);
+ String busy = WidgetState.busy(context, ControlContract.SCOPE_OC_MODEL);
+ String error = WidgetState.error(context, ControlContract.SCOPE_OC_MODEL);
+ String target = WidgetState.get(context, "oc_model_alias", "none");
+ String name = WidgetState.get(context, "oc_model_name", context.getString(R.string.waiting_for_refresh));
+ views.setTextViewText(R.id.model_title, context.getString(R.string.oc_model_title));
+ views.setTextViewText(R.id.model_note, context.getString(R.string.oc_model_note));
+ views.setTextViewText(R.id.model_current, !busy.isEmpty() ? busy : (!error.isEmpty() ? context.getString(R.string.error_format, error) : context.getString(R.string.current_format, name)));
+ views.setOnClickPendingIntent(R.id.model_refresh, control(context, ControlContract.ACTION_REFRESH, ControlContract.SCOPE_OC_MODEL, 601));
+ for (int i = 0; i < MODEL_TARGETS.length; i++) {
+ String readiness = WidgetState.get(context, "oc_readiness_" + stateSuffix(MODEL_TARGETS[i]), "unavailable");
+ String providerName = WidgetState.get(context, "oc_provider_" + stateSuffix(MODEL_TARGETS[i]), context.getString(MODEL_DEFAULT_NAMES[i]));
+ String label = MODEL_TARGETS[i].equals(target) ? "✓ " + providerName : providerName;
+ views.setTextViewText(MODEL_IDS[i], label);
+ views.setBoolean(MODEL_IDS[i], "setEnabled", "token-ready".equals(readiness));
+ Intent action = new Intent(context, ControlActivity.class);
+ action.setAction(ControlContract.ACTION_OC_MODEL);
+ action.putExtra(ControlContract.EXTRA_MODEL, MODEL_TARGETS[i]);
+ views.setOnClickPendingIntent(MODEL_IDS[i], PendingIntent.getActivity(context, 700 + i, action, immutableUpdateFlags()));
+ }
+ manager.updateAppWidget(ids, views);
+ }
+
private static String stateSuffix(String target) {
return target.replace("-", "");
}
@@ -108,10 +157,11 @@ private static String providerName(Context context, String target, int fallbackI
return name;
}
- private static PendingIntent broadcast(Context context, Class> receiver, String action, int requestCode) {
- Intent intent = new Intent(context, receiver);
+ private static PendingIntent control(Context context, String action, String scope, int requestCode) {
+ Intent intent = new Intent(context, ControlActivity.class);
intent.setAction(action);
- return PendingIntent.getBroadcast(context, requestCode, intent, immutableUpdateFlags());
+ intent.putExtra(ControlContract.EXTRA_SCOPE, scope);
+ return PendingIntent.getActivity(context, requestCode, intent, immutableUpdateFlags());
}
private static int immutableUpdateFlags() {
diff --git a/config/widget-profiles.example b/config/widget-profiles.example
index 532c7f2..3e9db95 100644
--- a/config/widget-profiles.example
+++ b/config/widget-profiles.example
@@ -1,6 +1,8 @@
# slot|profile|label|readiness
# Copy to ~/.config/android-ai-stack/widget-profiles and chmod 600.
-# The profile column must match a safe name returned by `ai-model list`.
+# For different ordering, split this into claude-widget-profiles and
+# opencode-widget-profiles. Names must be returned by the matching selector:
+# ai-model list for Claude/Happy, oc-model list for OpenCode.
local|local-openai|Local Qwen|token-ready
slot-01|cloud-direct|Cloud Direct|token-ready
slot-02|cloud-relay|Private Relay|token-ready
diff --git a/docs/architecture.md b/docs/architecture.md
index caaa41f..a74a164 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -8,8 +8,8 @@
| OpenCode | phone/Kali | TUI, web UI, OpenAI-compatible clients |
| Claude Code | phone/Kali | CLI agent and Anthropic-compatible clients |
| Happy server + daemon | phone | mobile session control and Claude bridge |
-| Mobile Lab Control | phone/Android | independent Happy, llama.cpp, and profile controls |
-| Provider profile | phone | route/model selection without embedding secrets |
+| Mobile Lab Control | phone/Android | five independent service, Web, and model controls |
+| Provider profiles | phone | separate Claude/Happy and OpenCode route selection |
| Model inference | external by default | cloud API, private relay, or optional loopback model |
The phone remains operational without a remote Happy server. It does not remain
@@ -19,8 +19,9 @@ optional local model profile is active.
## Provider profiles
Profiles describe routing, not credentials. The token lives in a file named by
-the profile. Activating a profile updates the OpenCode model choice and exports
-Claude's provider variables for managed processes.
+the profile. `ai-model` selects the Claude Code + Happy route; `oc-model`
+selects OpenCode. They use different active files, so changing one cannot
+silently rewrite the other.
Three profile classes are supported:
@@ -39,13 +40,14 @@ Local services are explicit:
- `stack up|down|status` manages the phone-local Happy server and daemon.
- `llama-local start|stop|status` manages optional local inference.
-- `ai-model use PROFILE` changes configuration only. It never starts, stops, or
- restarts either service.
+- `ai-model use PROFILE` changes Claude Code + Happy configuration only.
+- `oc-model use PROFILE` changes OpenCode configuration only.
-The native Mobile Lab Control app exposes the same split as three independent
+The native Mobile Lab Control app exposes the same split as five independent
AppWidgets. It has no Internet permission and reaches Termux only through a
-fixed allow-list bridge. Termux:Widget is an optional terminal fallback. This
-prevents route selection from becoming a hidden service-lifecycle action.
+fixed allow-list bridge. On Android 14, a short-lived user-initiated activity
+bridges widget taps to Termux's service without violating background-start
+restrictions. Termux:Widget remains an optional fallback.
Nothing is configured to start at Android boot by default. This limits battery,
thermal, and exposure costs.
diff --git a/docs/phone-quickstart.en.md b/docs/phone-quickstart.en.md
index 0b91a89..7da8168 100644
--- a/docs/phone-quickstart.en.md
+++ b/docs/phone-quickstart.en.md
@@ -9,6 +9,7 @@ Termux. It contains no token or private endpoint.
```bash
ai-model status
+oc-model status
stack status
ai-stack-check quick
ai-stack-check tui
@@ -25,6 +26,8 @@ startup assertion.
```bash
ai-model list
ai-model use cloud-direct
+oc-model list
+oc-model use cloud-direct
ai-stack-check opencode # at most one provider call
ai-stack-check claude # at most one provider call
```
@@ -32,21 +35,24 @@ ai-stack-check claude # at most one provider call
Stop at the first failure. Keep at most 60 redacted log lines and change one
suspected cause before one retry.
-Profile selection changes configuration only. It does not start or stop
-llama.cpp and does not restart Happy. A running Happy daemon shows `STALE` after
-a profile change until you explicitly restart it.
+`ai-model` controls Claude Code + Happy; `oc-model` controls OpenCode. The two
+active files are independent. Neither selector starts or stops llama.cpp, Web,
+or Happy. A running Happy daemon shows `STALE` after its profile changes until
+you explicitly restart it.
-## Three independent native widgets
+## Five independent native widgets
Install the release APK and fixed Termux bridge as described in
[`control-widgets.md`](../skills/deploy-android-ai-stack/references/control-widgets.md).
-Mobile Lab Control then provides three separate home-screen widgets:
+Mobile Lab Control then provides five separate home-screen widgets:
- Happy status, start/stop, restart, and refresh;
- llama.cpp status, start/stop, memory release, and refresh;
-- readable model-profile status plus direct configuration buttons.
+- Claude Code + Happy cloud-profile status and buttons;
+- OpenCode Web status and loopback start/stop;
+- independent OpenCode cloud/relay/local profile status and buttons.
-The profile widget never starts or stops a service. The app has no Internet
+The model widgets never start or stop a service. The app has no Internet
permission and the bridge returns no token or endpoint. Termux:Widget is an
opt-in terminal fallback, not the default UI.
@@ -66,12 +72,13 @@ stack down
A connected badge is insufficient. Acceptance requires one complete app message
round trip. The server should remain on `127.0.0.1:3005`, with metrics port 9090
-closed.
+closed. In Android PRoot, the wrapper sets `IS_SANDBOX=1` only for Happy's
+Claude process; Android still confines it to Termux's unprivileged app UID.
## Optional llama.cpp
```bash
-ai-model use local
+oc-model use local
LLAMA_MODEL=/root/models/small-instruct-q4.gguf llama-local start
llama-local status
ai-stack-check local # bounded auth + one-call acceptance
diff --git a/docs/phone-quickstart.zh-CN.md b/docs/phone-quickstart.zh-CN.md
index f00dc20..b8ef8c3 100644
--- a/docs/phone-quickstart.zh-CN.md
+++ b/docs/phone-quickstart.zh-CN.md
@@ -17,6 +17,7 @@
```bash
ai-model status
+oc-model status
stack status
ai-stack-check quick
ai-stack-check tui
@@ -39,28 +40,34 @@ PRoot 下 Add Project 选择器可能为空。checker 会创建最小 Git 项目
## 3. 模型切换
```bash
-ai-model list
+ai-model list # Claude Code + Happy
ai-model use cloud-direct
+oc-model list # OpenCode
+oc-model use cloud-direct
ai-model status
+oc-model status
```
profile 在 Kali 的 `/root/.config/android-ai-stack/profiles/`,token 在
-`/root/.secrets/`;两者均为 mode `0600`。切换只改配置,不会启动/停止
-llama.cpp,也不会重启 Happy。Happy 已运行时切换 profile 会显示 `STALE`,
-直到操作者明确重启。
+`/root/.secrets/`;两者均为 mode `0600`。`ai-model` 只控制 Claude Code +
+Happy,`oc-model` 只控制 OpenCode,两份 active 文件互相独立。切换不启停
+llama.cpp、OpenCode Web 或 Happy。Happy 已运行时切换自身 profile 会显示
+`STALE`,直到操作者明确重启。
本地 token 也必须使用随机值:它同时交给 OpenCode 与 llama-server;server
只允许 localhost CORS,不要使用公开示例字符串。
-## 4. 三个独立原生微件
+## 4. 五个独立原生微件
按照[原生微件说明](../skills/deploy-android-ai-stack/references/control-widgets.zh-CN.md)
安装 Release APK 与固定 Termux 桥接。Mobile Lab Control 随后提供:
- Happy 状态、启动/关闭、重启与刷新;
- llama.cpp 状态、启动/关闭、释放模型内存与刷新;
-- 当前模型可读名称和直接配置按钮。
+- Claude Code + Happy 云端配置的状态和切换按钮;
+- OpenCode Web 状态与 loopback 启停;
+- 独立的 OpenCode 云端、中转、本地配置状态和切换按钮。
-模型微件只切配置,不启停任何服务。App 没有联网权限,桥接也不会回传 Token
+两个模型微件只切各自配置,不启停任何服务。App 没有联网权限,桥接也不会回传 Token
或 Endpoint。Termux:Widget 只作为可选终端回退,不再是默认界面。
## 5. 有调用预算的验收
@@ -86,11 +93,14 @@ stack down
成功标准是 Happy App 发出一条消息并收到最终回复;仅显示“已连接”不算端到
端通过。默认 server 为 `127.0.0.1:3005`,metrics 9090 应关闭。
+Android PRoot 内虽然显示 uid 0,实际仍受 Termux 普通 App UID 约束;wrapper
+只为 Happy 拉起的 Claude 子进程设置 `IS_SANDBOX=1`,避免 Yolo 模式被误判为
+不受约束的宿主机 root。
## 7. 可选 llama.cpp
```bash
-ai-model use local
+oc-model use local
LLAMA_MODEL=/root/models/small-instruct-q4.gguf llama-local start
llama-local status
ai-stack-check local # 有上限的鉴权 + 单次调用验收
diff --git a/scripts/verify.sh b/scripts/verify.sh
index 86f9d78..2e2787e 100755
--- a/scripts/verify.sh
+++ b/scripts/verify.sh
@@ -35,6 +35,18 @@ if grep -q 'android.permission.INTERNET' apps/mobile-lab-control/AndroidManifest
fail=1
fi
+if grep -RIn 'PendingIntent.getBroadcast' \
+ apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/*WidgetProvider.java; then
+ echo "error: widget controls must retain the Android 14 user-initiated Activity boundary" >&2
+ fail=1
+fi
+
+if grep -RIn 'TermuxRunner\.' \
+ apps/mobile-lab-control/src/io/github/toolazytoname/mobilelabcontrol/*WidgetProvider.java; then
+ echo "error: AppWidget receivers must not start Termux services directly" >&2
+ fail=1
+fi
+
if grep -RInE '(cc-[0-9][0-9]|Agnes AI|Kimi 官方|中转站)' \
apps/mobile-lab-control/src apps/mobile-lab-control/res/layout \
apps/mobile-lab-control/res/values/strings.xml; then
diff --git a/skills/deploy-android-ai-stack/SKILL.md b/skills/deploy-android-ai-stack/SKILL.md
index 4e80cca..f474987 100644
--- a/skills/deploy-android-ai-stack/SKILL.md
+++ b/skills/deploy-android-ai-stack/SKILL.md
@@ -52,8 +52,8 @@ preflight.
Copy `config/opencode.example.json` from the repository to Kali's
`/root/.config/opencode/opencode.json` and set it to mode `0600`. The single
-`profile` provider reads its URL, key, and current model from the active profile;
-`ai-model use` replaces the allowed model entry without exposing credentials.
+`profile` provider reads its URL, key, and current model from OpenCode's active
+profile; `oc-model use` replaces the allowed model entry without exposing credentials.
### 3. Create a provider profile
@@ -68,19 +68,23 @@ Create the referenced token files under `/root/.secrets/`, set all involved
files to `0600`, and activate with:
```bash
-ai-model use PROFILE
+ai-model use PROFILE # Claude Code + Happy
+oc-model use PROFILE # OpenCode
```
-Use a new OpenCode/Claude session after switching. Profile selection is
-configuration-only: it never starts, stops, or restarts Happy or llama.cpp.
-If Happy is already running, `stack status` reports `STALE` until the operator
-explicitly restarts it with the selected profile.
+Use a new client session after switching. The two selectors and active files
+are independent. Selection is configuration-only: it never starts, stops, or
+restarts Happy, OpenCode Web, or llama.cpp. If Happy is already running,
+`stack status` reports `STALE` until the operator explicitly restarts it with
+the selected Claude/Happy profile.
The native widget UI mirrors this separation:
- Happy manages only the Happy service;
- llama.cpp manages only local inference and releases model memory when stopped;
-- model profiles select configuration without touching either service.
+- Claude Code + Happy models select only that client route;
+- OpenCode Web manages only the loopback Web process;
+- OpenCode models select only the OpenCode route, including optional local inference.
Read [references/control-widgets.md](references/control-widgets.md), copy the
redacted slot template to mode `0600`, then install the public release APK.
@@ -174,7 +178,7 @@ Deployment is complete only when:
- OpenCode and Claude versions execute from the real Termux/Kali path;
- Happy server and daemon are both local and healthy;
- metrics and unintended public listeners are absent;
-- the active profile is explicit;
-- the three native controls report independent, readable state and the bridge
+- both active client profiles are explicit;
+- the five native controls report independent, readable state and the bridge
emits no endpoint or credential;
- validation records distinguish zero-call checks from paid model calls.
diff --git a/skills/deploy-android-ai-stack/references/control-widgets.md b/skills/deploy-android-ai-stack/references/control-widgets.md
index bc9e77f..e32cd5a 100644
--- a/skills/deploy-android-ai-stack/references/control-widgets.md
+++ b/skills/deploy-android-ai-stack/references/control-widgets.md
@@ -4,8 +4,9 @@
## Boundary
-Mobile Lab Control is an Android app with three independent AppWidget
-providers: Happy, llama.cpp, and model profiles. It requests no Internet
+Mobile Lab Control is an Android app with five independent AppWidget
+providers: Happy, llama.cpp, Claude/Happy models, OpenCode Web, and OpenCode
+models. It requests no Internet
permission and holds no root privilege. Its only privileged edge is Termux's
`RUN_COMMAND` permission targeting the exact executable
`$PREFIX/bin/mobile-lab-widget-action`.
@@ -13,8 +14,14 @@ permission and holds no root privilege. Its only privileged edge is Termux's
Termux must set `allow-external-apps=true` for this edge to work. The installer
therefore requires an explicit `--enable-external-apps` flag, creates a backup
of the prior properties file, and installs the bridge mode `0700`. The bridge
-accepts only status, Happy toggle/restart, llama toggle, and mapped profile
-selection. It returns names and readiness, never tokens or endpoints.
+accepts only status, Happy toggle/restart, llama toggle, loopback OpenCode Web
+control, and mapped profile selection. It returns names and readiness, never
+tokens or endpoints.
+
+Android 14 forbids an AppWidget broadcast receiver from directly starting
+Termux's service. Each tap therefore opens a translucent, short-lived activity
+that retains the user-initiated execution boundary. A broadcast-only widget is
+expected to fail with `BackgroundServiceStartNotAllowedException`.
## Install
@@ -27,12 +34,14 @@ selection. It returns names and readiness, never tokens or endpoints.
4. Download the APK and its SHA-256 file from the repository's GitHub Release,
verify the checksum, and install with `adb install -r`.
5. Open the app, grant the Termux command permission, refresh once, and add the
- three widgets separately.
+ five widgets separately.
The mapping format is `slot|profile|label|readiness`. Supported slots are
`local` and `slot-01` through `slot-09`; readiness is `token-ready`,
-`login-required`, or `unavailable`. Profile names must already pass
-`ai-model list`. Labels are display-only and capped by the bridge.
+`login-required`, or `unavailable`. Claude/Happy names must pass `ai-model
+list`; OpenCode names must pass `oc-model list`. A single legacy map works, or
+use separate `claude-widget-profiles` and `opencode-widget-profiles` files.
+Labels are display-only and capped by the bridge.
If no map exists, the bridge exposes safe profiles in sorted order. An explicit
mode-600 map is preferred because it makes ordering and friendly labels stable.
@@ -40,9 +49,11 @@ mode-600 map is preferred because it makes ordering and friendly labels stable.
## Validate without a model call
Run `mobile-lab-widget-action status`, then inspect the package permissions and
-exercise the three widgets. Starting and stopping services is a zero-model-call
-operation. Profile switching is also configuration-only. Record the listener,
-process, and active-profile delta instead of repeatedly taking screenshots.
+exercise the five widgets. Starting and stopping services is a zero-model-call
+operation. Profile switching is also configuration-only. Verify that changing
+Claude/Happy leaves `opencode-active` and the OpenCode config unchanged, and
+vice versa. Record listener, process, and active-file deltas instead of
+repeatedly taking screenshots.
An existing debug-signed installation cannot be upgraded to the public release
signature. Ask before uninstalling it because that removes widget placement and
diff --git a/skills/deploy-android-ai-stack/references/control-widgets.zh-CN.md b/skills/deploy-android-ai-stack/references/control-widgets.zh-CN.md
index 24170cd..22f26c8 100644
--- a/skills/deploy-android-ai-stack/references/control-widgets.zh-CN.md
+++ b/skills/deploy-android-ai-stack/references/control-widgets.zh-CN.md
@@ -4,14 +4,19 @@
## 安全边界
-Mobile Lab Control 包含 Happy、llama.cpp、模型配置三个独立 AppWidget。App
+Mobile Lab Control 包含 Happy、llama.cpp、Claude/Happy 模型、OpenCode Web、
+OpenCode 模型五个独立 AppWidget。App
不申请联网权限、不持有 root;唯一的特权边是 Termux 的 `RUN_COMMAND` 权限,
且目标固定为 `$PREFIX/bin/mobile-lab-widget-action`。
Termux 需要设置 `allow-external-apps=true`。安装器因此要求显式传入
`--enable-external-apps`,先备份原 properties,再以 mode `0700` 安装桥接。
-桥接只接受状态、Happy 启停/重启、llama 启停和已映射的 Profile 切换;回传
-内容只有名称、状态和就绪类型,不包含 Token 或 Endpoint。
+桥接只接受状态、Happy 启停/重启、llama 启停、loopback OpenCode Web 控制和
+已映射的 Profile 切换;回传内容只有名称、状态和就绪类型,不包含 Token 或 Endpoint。
+
+Android 14 不允许 AppWidget 广播接收器直接启动 Termux 服务。每次微件点击会
+先打开透明的短生命周期 Activity,保留“用户明确触发”的执行边界;只用广播
+的实现会出现 `BackgroundServiceStartNotAllowedException`。
## 安装
@@ -21,21 +26,23 @@ Termux 需要设置 `allow-external-apps=true`。安装器因此要求显式传
并设置 mode `0600`;
3. 理解边界后执行 `install-control-bridge --enable-external-apps`;
4. 从 GitHub Release 下载 APK 与 SHA-256 文件,核对后用 `adb install -r` 安装;
-5. 打开 App,授予 Termux 命令权限,刷新一次,再分别添加三个微件。
+5. 打开 App,授予 Termux 命令权限,刷新一次,再分别添加五个微件。
映射格式是 `slot|profile|label|readiness`。槽位支持 `local` 与
`slot-01` 至 `slot-09`;就绪类型支持 `token-ready`、`login-required`、
-`unavailable`。Profile 必须已通过 `ai-model list`,展示名只用于 UI,桥接会
-限制长度并清理控制字符。
+`unavailable`。Claude/Happy Profile 必须通过 `ai-model list`,OpenCode
+Profile 必须通过 `oc-model list`。可以继续使用单个旧映射,也可以拆为
+`claude-widget-profiles` 和 `opencode-widget-profiles`。展示名只用于 UI。
没有映射文件时,桥接会按安全 Profile 名排序显示;仍建议提供 mode-600 的
显式映射,以固定顺序和可读名称。
## 零模型调用验收
-执行 `mobile-lab-widget-action status`,核对 APK 权限,再分别操作三个微件。
-服务启停和 Profile 切换都不产生模型调用。记录进程、监听端口与活动 Profile
-的变化即可;除非专门验证渲染,不需要反复截图。
+执行 `mobile-lab-widget-action status`,核对 APK 权限,再分别操作五个微件。
+服务启停和 Profile 切换都不产生模型调用。还要验证 Claude/Happy 切换不会
+改变 `opencode-active` 或 OpenCode 配置,反向也一样。记录进程、监听端口和
+active 文件变化即可;除非专门验证渲染,不需要反复截图。
debug 签名版本不能直接覆盖升级到公开 release 签名。卸载会移除微件位置与
App 本地状态,必须先征得确认;完成一次迁移后,后续正式版本可以原位升级。
diff --git a/skills/deploy-android-ai-stack/references/provider-profiles.md b/skills/deploy-android-ai-stack/references/provider-profiles.md
index 6277916..c09ea9e 100644
--- a/skills/deploy-android-ai-stack/references/provider-profiles.md
+++ b/skills/deploy-android-ai-stack/references/provider-profiles.md
@@ -22,8 +22,10 @@ ANTHROPIC_TOKEN_FILE
Names must match `[a-z0-9][a-z0-9._-]*`. Never accept a path from an untrusted
profile name. Token files must be regular, non-symlink files with mode `0600`.
`OPENCODE_MODEL` must use the fixed `profile/MODEL_ID` provider namespace. The
-switcher updates the one allowed model entry while `OPENCODE_BASE_URL` changes
-the backing OpenAI-compatible endpoint.
+`oc-model` switcher updates the one allowed model entry while
+`OPENCODE_BASE_URL` changes the backing OpenAI-compatible endpoint. `ai-model`
+loads only the Anthropic fields for Claude Code + Happy. The commands use
+separate active files and never update each other.
## CC Switch migration
diff --git a/skills/deploy-android-ai-stack/scripts/ai-model b/skills/deploy-android-ai-stack/scripts/ai-model
index 0965cf8..d765490 100755
--- a/skills/deploy-android-ai-stack/scripts/ai-model
+++ b/skills/deploy-android-ai-stack/scripts/ai-model
@@ -8,7 +8,6 @@ active_file="$config_dir/active"
rootfs="$PREFIX/var/lib/proot-distro/installed-rootfs/kali"
[ -d "$rootfs/root" ] || rootfs="$PREFIX/var/lib/proot-distro/containers/kali/rootfs"
profiles_dir="$rootfs/root/.config/android-ai-stack/profiles"
-opencode_config="$rootfs/root/.config/opencode/opencode.json"
mkdir -p "$config_dir"
chmod 700 "$config_dir"
@@ -40,17 +39,22 @@ current() {
}
list_profiles() {
- local file name
+ local file name claude_enabled
shopt -s nullglob
for file in "$profiles_dir"/*.env; do
name=${file##*/}; name=${name%.env}
- if profile_ok "$file"; then printf '%s\n' "$name"; else printf '%s (unsafe)\n' "$name"; fi
+ if ! profile_ok "$file"; then
+ printf '%s (unsafe)\n' "$name"
+ continue
+ fi
+ claude_enabled=$(bash -c '. "$1"; printf "%s" "${CLAUDE_ENABLED:-0}"' bash "$file")
+ [ "$claude_enabled" = 1 ] && printf '%s\n' "$name"
done
}
use_profile() {
- local name="$1" file model model_id profile_kind claude_enabled pending
- local opencode_url opencode_token anthropic_url anthropic_model anthropic_token
+ local name="$1" file claude_enabled pending
+ local anthropic_url anthropic_model anthropic_token
valid_name "$name" || { printf 'error: invalid profile name\n' >&2; exit 2; }
file=$(profile_path "$name")
profile_ok "$file" || {
@@ -58,62 +62,28 @@ use_profile() {
exit 1
}
- model=$(bash -c '. "$1"; printf "%s" "${OPENCODE_MODEL:-}"' bash "$file")
- profile_kind=$(bash -c '. "$1"; printf "%s" "${PROFILE_KIND:-}"' bash "$file")
- opencode_url=$(bash -c '. "$1"; printf "%s" "${OPENCODE_BASE_URL:-}"' bash "$file")
- opencode_token=$(bash -c '. "$1"; printf "%s" "${OPENCODE_TOKEN_FILE:-}"' bash "$file")
claude_enabled=$(bash -c '. "$1"; printf "%s" "${CLAUDE_ENABLED:-0}"' bash "$file")
- [[ "$model" =~ ^profile/[A-Za-z0-9._-]+$ ]] || {
- printf 'error: OPENCODE_MODEL must be profile/MODEL_ID\n' >&2
+ [ "$claude_enabled" = 1 ] || {
+ printf 'error: profile is OpenCode-only; select it with oc-model instead\n' >&2
exit 1
}
- case "$profile_kind" in
- cloud-direct|cloud-relay|local-openai) ;;
- *) printf 'error: unsupported PROFILE_KIND\n' >&2; exit 1 ;;
- esac
- model_id=${model#profile/}
- safe_url "$opencode_url" || { printf 'error: unsafe OpenCode base URL\n' >&2; exit 1; }
- kali_private_file_ok "$opencode_token" || {
- printf 'error: OpenCode token file is missing, symlinked, or not mode 600\n' >&2
+ anthropic_url=$(bash -c '. "$1"; printf "%s" "${ANTHROPIC_BASE_URL:-}"' bash "$file")
+ anthropic_model=$(bash -c '. "$1"; printf "%s" "${ANTHROPIC_MODEL:-}"' bash "$file")
+ anthropic_token=$(bash -c '. "$1"; printf "%s" "${ANTHROPIC_TOKEN_FILE:-}"' bash "$file")
+ safe_url "$anthropic_url" || { printf 'error: unsafe Anthropic base URL\n' >&2; exit 1; }
+ [ -n "$anthropic_model" ] || { printf 'error: ANTHROPIC_MODEL is empty\n' >&2; exit 1; }
+ kali_private_file_ok "$anthropic_token" || {
+ printf 'error: Anthropic token file is missing, symlinked, or not mode 600\n' >&2
exit 1
}
- case "$claude_enabled" in 0|1) ;; *) printf 'error: CLAUDE_ENABLED must be 0 or 1\n' >&2; exit 1 ;; esac
- if [ "$claude_enabled" = 1 ]; then
- anthropic_url=$(bash -c '. "$1"; printf "%s" "${ANTHROPIC_BASE_URL:-}"' bash "$file")
- anthropic_model=$(bash -c '. "$1"; printf "%s" "${ANTHROPIC_MODEL:-}"' bash "$file")
- anthropic_token=$(bash -c '. "$1"; printf "%s" "${ANTHROPIC_TOKEN_FILE:-}"' bash "$file")
- safe_url "$anthropic_url" || { printf 'error: unsafe Anthropic base URL\n' >&2; exit 1; }
- [ -n "$anthropic_model" ] || { printf 'error: ANTHROPIC_MODEL is empty\n' >&2; exit 1; }
- kali_private_file_ok "$anthropic_token" || {
- printf 'error: Anthropic token file is missing, symlinked, or not mode 600\n' >&2
- exit 1
- }
- fi
-
- [ -s "$opencode_config" ] || {
- printf 'error: OpenCode config is missing: %s\n' "$opencode_config" >&2
- exit 1
- }
- command -v jq >/dev/null 2>&1 || { printf 'error: Termux jq is required\n' >&2; exit 1; }
- pending="$opencode_config.pending"
- if ! jq --arg model "$model" --arg model_id "$model_id" \
- '.model = $model | .provider.profile.models = {($model_id): {"name": $model_id}}' \
- "$opencode_config" > "$pending"; then
- rm -f "$pending"
- printf 'error: OpenCode config is not valid JSON\n' >&2
- exit 1
- fi
- chmod 600 "$pending"
- mv "$pending" "$opencode_config"
pending="$config_dir/active.pending"
printf '%s\n' "$name" > "$pending"
chmod 600 "$pending"
mv "$pending" "$active_file"
- printf 'active=%s\nopencode_model=%s\nclaude_enabled=%s\n' \
- "$name" "$model" "$claude_enabled"
- printf '%s\n' 'Services were not restarted. Restart Happy explicitly if it is stale; manage llama.cpp separately.'
+ printf 'active=%s\nclaude_enabled=1\n' "$name"
+ printf '%s\n' 'OpenCode was not changed. Restart Happy explicitly if it is stale.'
}
case "${1:-status}" in
diff --git a/skills/deploy-android-ai-stack/scripts/ai-model-profile.sh b/skills/deploy-android-ai-stack/scripts/ai-model-profile.sh
index e6d2420..647d4f5 100755
--- a/skills/deploy-android-ai-stack/scripts/ai-model-profile.sh
+++ b/skills/deploy-android-ai-stack/scripts/ai-model-profile.sh
@@ -8,8 +8,7 @@ AI_CLAUDE_AVAILABLE=0
_ai_mode() { stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1"; }
# A profile can be reloaded in an existing shell. Clear the previous route and
-# credentials before reading the new one so an OpenCode-only local profile
-# cannot inherit stale Anthropic access.
+# credentials before reading the new one.
unset OPENCODE_API_KEY OPENCODE_BASE_URL OPENCODE_MODEL
unset ANTHROPIC_AUTH_TOKEN ANTHROPIC_API_KEY ANTHROPIC_BASE_URL ANTHROPIC_MODEL
unset ANTHROPIC_DEFAULT_HAIKU_MODEL ANTHROPIC_DEFAULT_SONNET_MODEL
@@ -32,15 +31,6 @@ case "$AI_PROFILE" in
# shellcheck disable=SC1090
. "$_ai_profile_file"
- if [ -n "${OPENCODE_TOKEN_FILE:-}" ] && \
- [ -f "$OPENCODE_TOKEN_FILE" ] && [ ! -L "$OPENCODE_TOKEN_FILE" ] && \
- [ "$(_ai_mode "$OPENCODE_TOKEN_FILE")" = 600 ]; then
- OPENCODE_API_KEY=$(cat "$OPENCODE_TOKEN_FILE")
- export OPENCODE_API_KEY OPENCODE_BASE_URL OPENCODE_MODEL
- else
- printf 'WARN: OpenCode token file is missing or unsafe\n' >&2
- fi
-
if [ "${CLAUDE_ENABLED:-0}" = 1 ] && \
[ -n "${ANTHROPIC_TOKEN_FILE:-}" ] && \
[ -f "$ANTHROPIC_TOKEN_FILE" ] && [ ! -L "$ANTHROPIC_TOKEN_FILE" ] && \
@@ -52,6 +42,10 @@ case "$AI_PROFILE" in
export ANTHROPIC_MODEL ANTHROPIC_DEFAULT_HAIKU_MODEL
export ANTHROPIC_DEFAULT_SONNET_MODEL ANTHROPIC_DEFAULT_OPUS_MODEL
fi
+ # Profiles may contain routes for both clients, but this loader is
+ # deliberately Claude-only. OpenCode has its own active file and
+ # loader so changing one channel cannot silently change the other.
+ unset PROFILE_KIND OPENCODE_MODEL OPENCODE_BASE_URL OPENCODE_TOKEN_FILE
else
printf 'WARN: profile is missing, symlinked, or not mode 600: %s\n' \
"$_ai_profile_file" >&2
diff --git a/skills/deploy-android-ai-stack/scripts/install-runtime.sh b/skills/deploy-android-ai-stack/scripts/install-runtime.sh
index 8c79792..870098d 100755
--- a/skills/deploy-android-ai-stack/scripts/install-runtime.sh
+++ b/skills/deploy-android-ai-stack/scripts/install-runtime.sh
@@ -22,6 +22,7 @@ done
}
install -m 700 "$script_dir/ai-model" "$PREFIX/bin/ai-model"
+install -m 700 "$script_dir/oc-model" "$PREFIX/bin/oc-model"
install -m 700 "$script_dir/stack" "$PREFIX/bin/stack"
install -m 700 "$script_dir/llama-local" "$PREFIX/bin/llama-local"
install -m 700 "$script_dir/oc" "$PREFIX/bin/oc"
@@ -41,6 +42,8 @@ install -d -m 700 "$rootfs/root/.secrets"
install -d -m 755 "$rootfs/etc/profile.d"
install -m 644 "$script_dir/ai-model-profile.sh" \
"$rootfs/etc/profile.d/ai-model.sh"
+install -m 644 "$script_dir/opencode-model-profile.sh" \
+ "$rootfs/etc/profile.d/opencode-model.sh"
# Remove legacy one-action model shortcuts during migration. The new UI has
# exactly one model selector plus separate Happy and llama.cpp controls.
@@ -66,6 +69,6 @@ if [ "${INSTALL_TERMUX_WIDGET_FALLBACK:-0}" = 1 ]; then
fi
printf 'installed operator scripts; no provider, model, or upstream binary was downloaded\n'
-printf 'next: create a mode-600 profile and token files, then run ai-model use PROFILE\n'
+printf 'next: select clients independently with ai-model use PROFILE and oc-model use PROFILE\n'
printf 'native control: install Mobile Lab Control, then run install-control-bridge --enable-external-apps\n'
printf 'fallback: set INSTALL_TERMUX_WIDGET_FALLBACK=1 only when using Termux:Widget\n'
diff --git a/skills/deploy-android-ai-stack/scripts/mobile-lab-widget-action b/skills/deploy-android-ai-stack/scripts/mobile-lab-widget-action
index f64e835..3809a84 100755
--- a/skills/deploy-android-ai-stack/scripts/mobile-lab-widget-action
+++ b/skills/deploy-android-ai-stack/scripts/mobile-lab-widget-action
@@ -8,8 +8,14 @@ PATH="$PREFIX/bin:/system/bin:/usr/bin:/bin"
export PREFIX HOME PATH
config_dir="$HOME/.config/android-ai-stack"
-map_file="$config_dir/widget-profiles"
+legacy_map_file="$config_dir/widget-profiles"
+claude_map_file="$config_dir/claude-widget-profiles"
+opencode_map_file="$config_dir/opencode-widget-profiles"
+[ -e "$claude_map_file" ] || claude_map_file="$legacy_map_file"
+[ -e "$opencode_map_file" ] || opencode_map_file="$legacy_map_file"
active_file="$config_dir/active"
+selector=ai-model
+map_file="$claude_map_file"
targets=(local slot-01 slot-02 slot-03 slot-04 slot-05 slot-06 slot-07 slot-08 slot-09)
slot_profiles=()
slot_labels=()
@@ -52,7 +58,7 @@ load_available_profiles() {
[[ "$profile" == *"$suffix" ]] && continue
valid_profile "$profile" || continue
available_profiles+=("$profile")
- done < <(ai-model list 2>/dev/null || true)
+ done < <("$selector" list 2>/dev/null || true)
}
initialize_slots() {
@@ -85,10 +91,10 @@ load_explicit_map() {
printf 'error=invalid readiness on widget profile map line %s\n' "$line_number" >&2
return 1 ;;
esac
- profile_available "$profile" || {
- printf 'error=unknown profile on widget profile map line %s\n' "$line_number" >&2
- return 1
- }
+ # One legacy map can still be shared during migration. Entries that do
+ # not belong to the current client (for example a local OpenCode-only
+ # profile in the Claude map) are simply unavailable in that widget.
+ profile_available "$profile" || continue
index=$(target_index "$target")
slot_profiles[$index]=$profile
slot_labels[$index]=$(clean_label "$label")
@@ -149,8 +155,34 @@ target_for_profile() {
printf 'none'
}
-print_status() {
- local active target selected label key index
+set_route() {
+ case "$1" in
+ claude)
+ selector=ai-model
+ map_file="$claude_map_file"
+ active_file="$config_dir/active"
+ ;;
+ opencode)
+ selector=oc-model
+ map_file="$opencode_map_file"
+ active_file="$config_dir/opencode-active"
+ ;;
+ *) return 1 ;;
+ esac
+}
+
+oc_web_state() {
+ if (exec 3<>/dev/tcp/127.0.0.1/4096) 2>/dev/null; then
+ exec 3>&- 3<&-
+ printf 'ON'
+ else
+ printf 'OFF'
+ fi
+}
+
+print_model_status() {
+ local prefix="$1" state_prefix active target selected label key index
+ case "$prefix" in model) state_prefix= ;; oc_model) state_prefix=oc_ ;; *) return 1 ;; esac
active=$(head -n 1 "$active_file" 2>/dev/null || printf 'none')
valid_profile "$active" || active=none
selected=$(target_for_profile "$active")
@@ -159,18 +191,29 @@ print_status() {
index=$(target_index "$selected")
label=${slot_labels[$index]}
fi
- printf 'happy_state=%s\n' "$(happy_state)"
- printf 'llama_state=%s\n' "$(llama_state)"
- printf 'model_alias=%s\n' "$selected"
- printf 'model_name=%s\n' "$(clean_label "$label")"
+ printf '%s_alias=%s\n' "$prefix" "$selected"
+ printf '%s_name=%s\n' "$prefix" "$(clean_label "$label")"
for index in {0..9}; do
target=${targets[$index]}
key=${target//-/}
- printf 'provider_%s=%s\n' "$key" "$(clean_label "${slot_labels[$index]}")"
- printf 'readiness_%s=%s\n' "$key" "${slot_readiness[$index]}"
+ printf '%sprovider_%s=%s\n' "$state_prefix" "$key" "$(clean_label "${slot_labels[$index]}")"
+ printf '%sreadiness_%s=%s\n' "$state_prefix" "$key" "${slot_readiness[$index]}"
done
}
+print_status() {
+ printf 'happy_state=%s\n' "$(happy_state)"
+ printf 'llama_state=%s\n' "$(llama_state)"
+ set_route claude
+ load_slots
+ print_model_status model
+ printf 'oc_web_state=%s\n' "$(oc_web_state)"
+ set_route opencode
+ load_slots
+ print_model_status oc_model
+}
+
+set_route claude
load_slots
operation=${1:-status}
case "$operation" in
@@ -190,13 +233,32 @@ case "$operation" in
case "$(llama_state)" in ON) llama-local stop ;; ERROR) llama-local stop || true; llama-local start ;; *) llama-local start ;; esac
;;
model-use)
- [ "$#" -eq 2 ] && valid_target "$2" || { printf 'error=invalid model target\n' >&2; exit 2; }
+ [ "$#" -eq 2 ] && valid_target "$2" && [ "$2" != local ] || { printf 'error=invalid Claude model target\n' >&2; exit 2; }
index=$(target_index "$2")
[ "${slot_readiness[$index]}" != unavailable ] && [ -n "${slot_profiles[$index]}" ] || {
printf 'error=model target is not configured\n' >&2
exit 2
}
- ai-model use "${slot_profiles[$index]}"
+ "$selector" use "${slot_profiles[$index]}"
+ ;;
+ oc-model-use)
+ [ "$#" -eq 2 ] && valid_target "$2" || { printf 'error=invalid OpenCode model target\n' >&2; exit 2; }
+ set_route opencode
+ load_slots
+ index=$(target_index "$2")
+ [ "${slot_readiness[$index]}" = token-ready ] && [ -n "${slot_profiles[$index]}" ] || {
+ printf 'error=OpenCode model target is not token-ready\n' >&2
+ exit 2
+ }
+ "$selector" use "${slot_profiles[$index]}"
+ ;;
+ oc-web-toggle)
+ [ "$#" -eq 1 ] || { printf 'error=unexpected argument\n' >&2; exit 2; }
+ case "$(oc_web_state)" in ON) ai-stack-check web-stop ;; *) ai-stack-check web-start ;; esac
+ ;;
+ oc-web-start|oc-web-stop)
+ [ "$#" -eq 1 ] || { printf 'error=unexpected argument\n' >&2; exit 2; }
+ ai-stack-check "${operation#oc-}"
;;
*)
printf 'error=operation is outside the widget allow-list\n' >&2
@@ -204,5 +266,4 @@ case "$operation" in
;;
esac
-load_slots
print_status
diff --git a/skills/deploy-android-ai-stack/scripts/oc b/skills/deploy-android-ai-stack/scripts/oc
index b26f833..498327d 100755
--- a/skills/deploy-android-ai-stack/scripts/oc
+++ b/skills/deploy-android-ai-stack/scripts/oc
@@ -2,4 +2,4 @@
set -euo pipefail
: "${PREFIX:=/data/data/com.termux/files/usr}"
exec "$PREFIX/bin/proot-distro" login kali -- \
- bash -lc '. /etc/profile.d/ai-model.sh; exec opencode "$@"' bash "$@"
+ bash -lc '. /etc/profile.d/opencode-model.sh; exec opencode "$@"' bash "$@"
diff --git a/skills/deploy-android-ai-stack/scripts/oc-model b/skills/deploy-android-ai-stack/scripts/oc-model
new file mode 100644
index 0000000..78339cb
--- /dev/null
+++ b/skills/deploy-android-ai-stack/scripts/oc-model
@@ -0,0 +1,101 @@
+#!/data/data/com.termux/files/usr/bin/bash
+# Select OpenCode's route without changing Claude Code or Happy.
+set -euo pipefail
+
+: "${PREFIX:=/data/data/com.termux/files/usr}"
+: "${HOME:=/data/data/com.termux/files/home}"
+config_dir="$HOME/.config/android-ai-stack"
+active_file="$config_dir/opencode-active"
+rootfs="$PREFIX/var/lib/proot-distro/installed-rootfs/kali"
+[ -d "$rootfs/root" ] || rootfs="$PREFIX/var/lib/proot-distro/containers/kali/rootfs"
+profiles_dir="$rootfs/root/.config/android-ai-stack/profiles"
+opencode_config="$rootfs/root/.config/opencode/opencode.json"
+
+mkdir -p "$config_dir"
+chmod 700 "$config_dir"
+
+valid_name() { [[ "$1" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; }
+profile_path() { printf '%s/%s.env\n' "$profiles_dir" "$1"; }
+mode_of() { stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1"; }
+profile_ok() {
+ [ -f "$1" ] && [ ! -L "$1" ] && [ "$(mode_of "$1")" = 600 ]
+}
+kali_private_file_ok() {
+ local kali_path="$1" host_path
+ [[ "$kali_path" = /root/.secrets/* && "$kali_path" != *..* ]] || return 1
+ host_path="$rootfs$kali_path"
+ [ -f "$host_path" ] && [ ! -L "$host_path" ] && \
+ [ "$(mode_of "$host_path")" = 600 ]
+}
+safe_url() {
+ [[ "$1" =~ ^https://[^[:space:]]+$ ]] || \
+ [[ "$1" =~ ^http://(127\.0\.0\.1|localhost):[0-9]+(/[^[:space:]]*)?$ ]]
+}
+current() {
+ [ -s "$active_file" ] && head -n 1 "$active_file" || printf 'none\n'
+}
+list_profiles() {
+ local file name
+ shopt -s nullglob
+ for file in "$profiles_dir"/*.env; do
+ name=${file##*/}; name=${name%.env}
+ if profile_ok "$file"; then printf '%s\n' "$name"; else printf '%s (unsafe)\n' "$name"; fi
+ done
+}
+use_profile() {
+ local name="$1" file model model_id profile_kind base_url token_file pending
+ valid_name "$name" || { printf 'error: invalid profile name\n' >&2; exit 2; }
+ file=$(profile_path "$name")
+ profile_ok "$file" || {
+ printf 'error: profile must be a regular mode-600 file: %s\n' "$file" >&2
+ exit 1
+ }
+ model=$(bash -c '. "$1"; printf "%s" "${OPENCODE_MODEL:-}"' bash "$file")
+ profile_kind=$(bash -c '. "$1"; printf "%s" "${PROFILE_KIND:-}"' bash "$file")
+ base_url=$(bash -c '. "$1"; printf "%s" "${OPENCODE_BASE_URL:-}"' bash "$file")
+ token_file=$(bash -c '. "$1"; printf "%s" "${OPENCODE_TOKEN_FILE:-}"' bash "$file")
+ [[ "$model" =~ ^profile/[A-Za-z0-9._-]+$ ]] || {
+ printf 'error: OPENCODE_MODEL must be profile/MODEL_ID\n' >&2
+ exit 1
+ }
+ case "$profile_kind" in cloud-direct|cloud-relay|local-openai) ;; *)
+ printf 'error: unsupported PROFILE_KIND\n' >&2; exit 1 ;;
+ esac
+ safe_url "$base_url" || { printf 'error: unsafe OpenCode base URL\n' >&2; exit 1; }
+ kali_private_file_ok "$token_file" || {
+ printf 'error: OpenCode token file is missing, symlinked, or not mode 600\n' >&2
+ exit 1
+ }
+ [ -s "$opencode_config" ] || {
+ printf 'error: OpenCode config is missing: %s\n' "$opencode_config" >&2
+ exit 1
+ }
+ command -v jq >/dev/null 2>&1 || { printf 'error: Termux jq is required\n' >&2; exit 1; }
+ model_id=${model#profile/}
+ pending="$opencode_config.pending"
+ if ! jq --arg model "$model" --arg model_id "$model_id" \
+ '.model = $model | .provider.profile.models = {($model_id): {"name": $model_id}}' \
+ "$opencode_config" > "$pending"; then
+ [ ! -e "$pending" ] || unlink "$pending"
+ printf 'error: OpenCode config is not valid JSON\n' >&2
+ exit 1
+ fi
+ chmod 600 "$pending"
+ mv "$pending" "$opencode_config"
+ pending="$active_file.pending"
+ printf '%s\n' "$name" > "$pending"
+ chmod 600 "$pending"
+ mv "$pending" "$active_file"
+ printf 'active=%s\nopencode_model=%s\n' "$name" "$model"
+ printf '%s\n' 'Claude Code, Happy, and all service lifecycles were unchanged.'
+}
+
+case "${1:-status}" in
+ status) printf 'active=%s\n' "$(current)" ;;
+ list) list_profiles ;;
+ use)
+ [ "$#" -eq 2 ] || { printf 'usage: oc-model use PROFILE\n' >&2; exit 2; }
+ use_profile "$2"
+ ;;
+ *) printf 'usage: oc-model [status|list|use PROFILE]\n' >&2; exit 2 ;;
+esac
diff --git a/skills/deploy-android-ai-stack/scripts/opencode-model-profile.sh b/skills/deploy-android-ai-stack/scripts/opencode-model-profile.sh
new file mode 100644
index 0000000..18e6493
--- /dev/null
+++ b/skills/deploy-android-ai-stack/scripts/opencode-model-profile.sh
@@ -0,0 +1,32 @@
+# shellcheck shell=bash
+# Load only the independently selected OpenCode profile in Kali.
+
+_oc_termux_home=${AI_TERMUX_HOME:-/data/data/com.termux/files/home}
+_oc_active_file="$_oc_termux_home/.config/android-ai-stack/opencode-active"
+_oc_profiles_dir=${AI_PROFILE_DIR:-/root/.config/android-ai-stack/profiles}
+_oc_mode() { stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1"; }
+
+unset OPENCODE_API_KEY OPENCODE_BASE_URL OPENCODE_MODEL
+if [ -s "$_oc_active_file" ]; then IFS= read -r OPENCODE_PROFILE < "$_oc_active_file"; else OPENCODE_PROFILE=; fi
+case "$OPENCODE_PROFILE" in
+ ''|*[!a-z0-9._-]*) printf 'WARN: no valid OpenCode profile is active\n' >&2 ;;
+ *)
+ _oc_profile_file="$_oc_profiles_dir/$OPENCODE_PROFILE.env"
+ if [ -f "$_oc_profile_file" ] && [ ! -L "$_oc_profile_file" ] && \
+ [ "$(_oc_mode "$_oc_profile_file")" = 600 ]; then
+ # shellcheck disable=SC1090
+ . "$_oc_profile_file"
+ if [ -n "${OPENCODE_TOKEN_FILE:-}" ] && [ -f "$OPENCODE_TOKEN_FILE" ] && \
+ [ ! -L "$OPENCODE_TOKEN_FILE" ] && [ "$(_oc_mode "$OPENCODE_TOKEN_FILE")" = 600 ]; then
+ OPENCODE_API_KEY=$(cat "$OPENCODE_TOKEN_FILE")
+ export OPENCODE_PROFILE OPENCODE_API_KEY OPENCODE_BASE_URL OPENCODE_MODEL
+ else
+ printf 'WARN: OpenCode token file is missing or unsafe\n' >&2
+ fi
+ else
+ printf 'WARN: OpenCode profile is missing or unsafe: %s\n' "$_oc_profile_file" >&2
+ fi
+ ;;
+esac
+unset _oc_termux_home _oc_active_file _oc_profiles_dir _oc_profile_file
+unset -f _oc_mode
diff --git a/skills/deploy-android-ai-stack/scripts/stack b/skills/deploy-android-ai-stack/scripts/stack
index cd5f79a..5c5b759 100755
--- a/skills/deploy-android-ai-stack/scripts/stack
+++ b/skills/deploy-android-ai-stack/scripts/stack
@@ -47,8 +47,11 @@ agent_up() {
printf 'error: active profile is not Claude-compatible\n' >&2
return 1
fi
+ # PRoot presents uid 0, but Android still confines the process to Termux's
+ # unprivileged app UID. Mark that boundary explicitly so Happy's Yolo mode
+ # is not rejected as unrestricted host root by Claude Code.
printf -v launch '%q ' "$PREFIX/bin/proot-distro" login kali -- env \
- "HAPPY_SERVER_URL=$url" HAPPY_HOME_DIR=/root/.happy bash -lc \
+ "HAPPY_SERVER_URL=$url" HAPPY_HOME_DIR=/root/.happy IS_SANDBOX=1 bash -lc \
'. /etc/profile.d/ai-model.sh; exec happy daemon start-sync'
tmux new-session -d -s "$agent_session" "exec $launch"
sleep 2
diff --git a/skills/validate-android-ai-stack/scripts/ai-stack-check b/skills/validate-android-ai-stack/scripts/ai-stack-check
index 042e4a4..9423ad8 100755
--- a/skills/validate-android-ai-stack/scripts/ai-stack-check
+++ b/skills/validate-android-ai-stack/scripts/ai-stack-check
@@ -44,10 +44,15 @@ active_profile() {
[ -s "$active" ] && head -n 1 "$active" || printf 'none\n'
}
+opencode_active_profile() {
+ local active="$HOME/.config/android-ai-stack/opencode-active"
+ [ -s "$active" ] && head -n 1 "$active" || printf 'none\n'
+}
+
quick_checks() {
local version profile profile_file rootfs
printf '\n[quick | zero model calls]\n'
- for command_name in proot-distro tmux oc ai-model stack; do
+ for command_name in proot-distro tmux oc ai-model oc-model stack; do
command -v "$command_name" >/dev/null 2>&1 && \
pass "$command_name available" || fail "$command_name missing"
done
@@ -66,6 +71,15 @@ quick_checks() {
else
fail "active profile is missing or unsafe: $profile"
fi
+ profile=$(opencode_active_profile)
+ profile_file="$rootfs/root/.config/android-ai-stack/profiles/$profile.env"
+ if [[ "$profile" =~ ^[a-z0-9][a-z0-9._-]*$ ]] && \
+ [ -f "$profile_file" ] && [ ! -L "$profile_file" ] && \
+ [ "$(stat -c %a "$profile_file")" = 600 ]; then
+ pass "active OpenCode profile $profile is a private regular file"
+ else
+ fail "active OpenCode profile is missing or unsafe: $profile"
+ fi
if port_open 3005 && listener_is_exact 127.0.0.1:3005; then
pass "Happy API is bound exactly to 127.0.0.1:3005"
@@ -120,7 +134,7 @@ claude_check() {
local_check() {
local profile profile_file profile_kind rootfs auth_status nonce result status
printf '\n[llama.cpp + opencode local-lite | one local model call]\n'
- profile=$(active_profile)
+ profile=$(opencode_active_profile)
rootfs="$PREFIX/var/lib/proot-distro/installed-rootfs/kali"
[ -d "$rootfs/root" ] || rootfs="$PREFIX/var/lib/proot-distro/containers/kali/rootfs"
profile_file="$rootfs/root/.config/android-ai-stack/profiles/$profile.env"
@@ -168,7 +182,7 @@ web_start() {
fi
if ! tmux has-session -t "$web_session" 2>/dev/null; then
: > "$web_server_log"; chmod 600 "$web_server_log"
- printf -v inner 'printf "%%s\\n" "$$" > %q; . /etc/profile.d/ai-model.sh; exec opencode web --hostname 127.0.0.1 --port %q --print-logs' "$web_pid_file" "$web_port"
+ printf -v inner 'printf "%%s\\n" "$$" > %q; . /etc/profile.d/opencode-model.sh; exec opencode web --hostname 127.0.0.1 --port %q --print-logs' "$web_pid_file" "$web_port"
printf -v launch '%q ' "$PREFIX/bin/proot-distro" login -w "$web_project" kali -- bash -lc "$inner"
tmux new-session -d -s "$web_session" "$launch >> '$web_server_log' 2>&1"
fi
diff --git a/tests/control-bridge.sh b/tests/control-bridge.sh
index 9cf27ff..a3e609d 100755
--- a/tests/control-bridge.sh
+++ b/tests/control-bridge.sh
@@ -36,7 +36,24 @@ case "${1:-status}" in
*) exit 2 ;;
esac
EOF
-chmod 700 "$test_prefix/bin/stack" "$test_prefix/bin/llama-local" "$test_prefix/bin/ai-model"
+cat > "$test_prefix/bin/oc-model" <<'EOF'
+#!/usr/bin/env bash
+case "${1:-status}" in
+ list) printf 'claude-login\ncloud-direct\nlocal-openai\n' ;;
+ status) printf 'active=%s\n' "$(cat "$HOME/.config/android-ai-stack/opencode-active" 2>/dev/null || printf none)" ;;
+ use)
+ printf '%s\n' "$2" > "$TEST_ROOT/oc-model-action"
+ printf '%s\n' "$2" > "$HOME/.config/android-ai-stack/opencode-active"
+ ;;
+ *) exit 2 ;;
+esac
+EOF
+cat > "$test_prefix/bin/ai-stack-check" <<'EOF'
+#!/usr/bin/env bash
+printf '%s\n' "$1" > "$TEST_ROOT/ai-stack-check-action"
+EOF
+chmod 700 "$test_prefix/bin/stack" "$test_prefix/bin/llama-local" \
+ "$test_prefix/bin/ai-model" "$test_prefix/bin/oc-model" "$test_prefix/bin/ai-stack-check"
cat > "$test_home/.config/android-ai-stack/widget-profiles" <<'EOF'
local|local-openai|Local Qwen|token-ready
@@ -45,7 +62,9 @@ slot-02|claude-login|Claude Official|login-required
EOF
chmod 600 "$test_home/.config/android-ai-stack/widget-profiles"
printf 'cloud-direct\n' > "$test_home/.config/android-ai-stack/active"
+printf 'local-openai\n' > "$test_home/.config/android-ai-stack/opencode-active"
chmod 600 "$test_home/.config/android-ai-stack/active"
+chmod 600 "$test_home/.config/android-ai-stack/opencode-active"
bridge="$repo_root/skills/deploy-android-ai-stack/scripts/mobile-lab-widget-action"
status=$(PREFIX="$test_prefix" HOME="$test_home" bash "$bridge" status)
@@ -56,11 +75,24 @@ grep -qx 'model_name=Cloud Direct' <<< "$status"
grep -qx 'provider_slot02=Claude Official' <<< "$status"
grep -qx 'readiness_slot02=login-required' <<< "$status"
grep -qx 'readiness_slot09=unavailable' <<< "$status"
+grep -qx 'oc_web_state=OFF' <<< "$status"
+grep -qx 'oc_model_alias=local' <<< "$status"
+grep -qx 'oc_model_name=Local Qwen' <<< "$status"
+grep -qx 'oc_readiness_local=token-ready' <<< "$status"
PREFIX="$test_prefix" HOME="$test_home" bash "$bridge" model-use slot-02 >/dev/null
grep -qx 'claude-login' "$test_root/model-action"
PREFIX="$test_prefix" HOME="$test_home" bash "$bridge" happy-toggle >/dev/null
grep -qx 'up' "$test_root/stack-action"
+PREFIX="$test_prefix" HOME="$test_home" bash "$bridge" oc-model-use local >/dev/null
+grep -qx 'local-openai' "$test_root/oc-model-action"
+PREFIX="$test_prefix" HOME="$test_home" bash "$bridge" oc-web-start >/dev/null
+grep -qx 'web-start' "$test_root/ai-stack-check-action"
+
+if PREFIX="$test_prefix" HOME="$test_home" bash "$bridge" model-use local >/dev/null 2>&1; then
+ echo 'Claude widget accepted the OpenCode-only local target' >&2
+ exit 1
+fi
if PREFIX="$test_prefix" HOME="$test_home" bash "$bridge" model-use slot-99 >/dev/null 2>&1; then
echo 'invalid slot was accepted' >&2
diff --git a/tests/profile-switch.sh b/tests/profile-switch.sh
index 319448c..33816cc 100755
--- a/tests/profile-switch.sh
+++ b/tests/profile-switch.sh
@@ -63,32 +63,60 @@ run_switch() {
bash "$repo_root/skills/deploy-android-ai-stack/scripts/ai-model" use "$1"
}
+run_opencode_switch() {
+ PREFIX="$test_prefix" HOME="$test_home" PATH="$test_prefix/bin:$PATH" \
+ bash "$repo_root/skills/deploy-android-ai-stack/scripts/oc-model" use "$1"
+}
+
cloud_output=$(run_switch cloud)
-printf '%s\n' "$cloud_output" | grep -Fq 'Services were not restarted.'
+printf '%s\n' "$cloud_output" | grep -Fq 'OpenCode was not changed.'
test "$(cat "$test_home/.config/android-ai-stack/active")" = cloud
-test "$(jq -r '.model' "$rootfs/root/.config/opencode/opencode.json")" = profile/cloud-model
-test "$(jq -r '.provider.profile.models | keys[0]' "$rootfs/root/.config/opencode/opencode.json")" = cloud-model
+test "$(jq -r '.model' "$rootfs/root/.config/opencode/opencode.json")" = '{env:OPENCODE_MODEL}'
+
+if run_switch local >/dev/null 2>&1; then
+ echo 'Claude selector accepted an OpenCode-only local profile' >&2
+ exit 1
+fi
-local_output=$(run_switch local)
-printf '%s\n' "$local_output" | grep -Fq 'Services were not restarted.'
-test "$(cat "$test_home/.config/android-ai-stack/active")" = local
+local_output=$(run_opencode_switch local)
+printf '%s\n' "$local_output" | grep -Fq 'Claude Code, Happy, and all service lifecycles were unchanged.'
+test "$(cat "$test_home/.config/android-ai-stack/opencode-active")" = local
test "$(jq -r '.model' "$rootfs/root/.config/opencode/opencode.json")" = profile/model-id
+cloud_oc_output=$(run_opencode_switch cloud)
+printf '%s\n' "$cloud_oc_output" | grep -Fq 'Claude Code, Happy'
+test "$(cat "$test_home/.config/android-ai-stack/opencode-active")" = cloud
+test "$(cat "$test_home/.config/android-ai-stack/active")" = cloud
+test "$(jq -r '.model' "$rootfs/root/.config/opencode/opencode.json")" = profile/cloud-model
+test "$(jq -r '.provider.profile.models | keys[0]' "$rootfs/root/.config/opencode/opencode.json")" = cloud-model
+
loader_home="$test_root/loader-home"
loader_profiles="$test_root/loader-profiles"
mkdir -p "$loader_home/.config/android-ai-stack" "$loader_profiles"
-printf 'local\n' > "$loader_home/.config/android-ai-stack/active"
+printf 'cloud\n' > "$loader_home/.config/android-ai-stack/active"
+printf 'local\n' > "$loader_home/.config/android-ai-stack/opencode-active"
+cp "$profiles/cloud.env" "$loader_profiles/cloud.env"
cp "$profiles/local.env" "$loader_profiles/local.env"
+sed -i.bak "s#/root/.secrets/cloud-token#$secrets/cloud-token#" "$loader_profiles/cloud.env"
sed -i.bak "s#/root/.secrets/local-token#$secrets/local-token#" "$loader_profiles/local.env"
-rm -f "$loader_profiles/local.env.bak"
-chmod 600 "$loader_profiles/local.env"
+rm -f "$loader_profiles/cloud.env.bak" "$loader_profiles/local.env.bak"
+chmod 600 "$loader_profiles/cloud.env" "$loader_profiles/local.env"
export AI_TERMUX_HOME="$loader_home" AI_PROFILE_DIR="$loader_profiles"
-export ANTHROPIC_API_KEY=stale ANTHROPIC_BASE_URL=https://stale.example.invalid
+export OPENCODE_API_KEY=stale OPENCODE_BASE_URL=https://stale.example.invalid
# shellcheck disable=SC1091
. "$repo_root/skills/deploy-android-ai-stack/scripts/ai-model-profile.sh"
-test "$AI_CLAUDE_AVAILABLE" = 0
-test -z "${ANTHROPIC_API_KEY:-}"
-test -z "${ANTHROPIC_BASE_URL:-}"
+test "$AI_CLAUDE_AVAILABLE" = 1
+test -n "${ANTHROPIC_AUTH_TOKEN:-}"
+test -z "${OPENCODE_API_KEY:-}"
+test -z "${OPENCODE_BASE_URL:-}"
+
+# shellcheck disable=SC1091
+. "$repo_root/skills/deploy-android-ai-stack/scripts/opencode-model-profile.sh"
+test "$OPENCODE_PROFILE" = local
+test "$OPENCODE_MODEL" = profile/model-id
+test "$OPENCODE_BASE_URL" = http://127.0.0.1:8080/v1
+test -n "$OPENCODE_API_KEY"
+test "$AI_PROFILE" = cloud
printf 'profile switch tests passed\n'