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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ This changelog tracks **Minis for Android** as an independently maintained Andro
- Fixed `400 reasoning.effort: 'none' is not supported` on the Responses path with thinking off (for example context compaction on GPT-6 Astra): the "off" value is now clamped to the lowest effort tier the model declares. Ported from OpenMinis 1.14.
- The virtual screen runs in a root service (libsu) instead of a Shizuku UserService. Run its compatibility check once more to switch it back on.
- Accessibility repair, clearing restricted settings and one-tap authorize use Root; Settings > System has a Root page in place of "Root & Shizuku", and the readiness checklist no longer lists Shizuku.
- Terminals belong to the app, not to the Terminal page: leaving the page, going to another app or locking the screen no longer ends the shell or the program in it, and the screen and scrollback are as you left them. A shell ends when you close its tab, when it exits, from "End all" on the notification, or when the runtime is stopped for maintenance (you are told).
- While any terminal (yours or the agent's) has a live process, a foreground service keeps Minis running and shows "N terminals running" with an "End all" button.
- Tabs in the Terminal page: new, switch, close, long-press to rename, a status dot from OSC 7501, up to 8. Closing a tab with a program running asks once. The agent's terminals appear as read-only "Agent" tabs; "Take over" lets you type into one.
- Touch: mouse reporting (DECSET 1000 / 1002 / 1003, SGR 1006) so vim, htop, tmux and TUIs can be clicked and scrolled; a swipe on a full-screen program that did not ask for the mouse sends arrow keys (application cursor mode respected); pinch changes the text size and the terminal is resized; Paste in the selection menu and on a long press on empty space, wrapped in bracketed paste when the program asked for it.
- The agent's terminal idle clean-up no longer closes a terminal that has a program running in the foreground.
- Fast Mode works for GPT models again on the Responses path (Codex login and Responses relays): with the toggle on, requests carry `service_tier: "priority"`, as OpenMinis 1.14 does. A change that kept only the xAI case had turned the toggle into a no-op for GPT.

## 0.0.2 — 2026-10-10
Expand Down
15 changes: 15 additions & 0 deletions docs/AGENT-FOREGROUND-SERVICE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,21 @@ Repository CI compiles the instrumentation APK with `:app:assembleDebugAndroidTe

JVM tests cover the lifecycle policy: presence-only state, active execution, stop/resume transition, and `START_NOT_STICKY` process-death behavior.

## Terminal sessions (Issue #183)

A second, separate service, `TerminalForegroundService`, keeps Minis resident while a terminal has a live process. It is not the Agent service: an Agent turn and a terminal job are independent kinds of real work.

- Type: `specialUse` with the subtype "Interactive terminal sessions with a live shell process started by the user or the agent". Never `mediaPlayback` or `dataSync`, for the same reasons as above.
- Lifecycle: the count is the user's terminals (`TerminalSessionManager`) plus the agent's (`AgentTerminals`) whose shell is alive. `TerminalServicePolicy.shouldRun(count)` is `count > 0`. An ended shell, or a tab left open after `exit`, does not keep the app resident. The service stops after the last one ends.
- Notification: "N terminals running"; tapping opens `minis://open_terminal`; the "End all" action closes both sets of terminals and stops the service.
- `START_NOT_STICKY`: if the system kills the process, nothing is replayed.
- Stopping right behind a pending `startForegroundService()` would kill the app (start deadline), so a stop request waits for the start to settle and re-checks the count.
- Separate from this, `TerminalSession.stopAll*` (rootfs, mount or proxy changes) can still end terminals. The terminal manager is told how many had a program running and shows a dialog in the Terminal page and one notification.

What this does not protect against: Android or the OEM killing the Minis process, and an app upgrade. The PTY master closes with the process and the shell gets SIGHUP. Surviving that needs the PTY holder to live outside the app process (for example tmux or dtach in the guest, re-attached after a restart); the design keeps `TerminalSessionManager` as the single owner so that can be added later, but it is not part of #183.

Not verified on a device: background survival for 30 minutes or more on HyperOS (Xiaomi 24129PN74C), and how OEM background policies treat this service.

## Device and OEM limitations

Foreground-service start restrictions still apply. `specialUse` is a type declaration, not an exemption from Android background-start rules. Exact-alarm/scheduled execution and OEM background policies may behave differently across devices.
Expand Down
21 changes: 21 additions & 0 deletions docs/contracts/06-CURRENT-GAPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,27 @@ config registry 暴露为 `prompt.custom`(带确认与审计回退)。内置

- 远程 MCP 调用方可以经 `linux.shell` 在 guest 里调用 `android-root-cli exec`。维护者决定保留:每次调用都需要用户在手机上批准一次性 confirm(`MCPServer.handleToolCall` 的 MCP_CONFIRM 门,`AutonomyMode` 不参与,「全自动」不会跳过它),且 `root_cli` 开关默认关闭。这与 `root.shell`(本地专用、MCP 不可见)是两回事;改为拒绝远程调用需另开 PR。

## 终端会话归 App 所有(#183,2026-10-10)

已实现(宿主测试覆盖逻辑,未上真机):`TerminalSessionManager`(App 级单例)持有用户终端及其 emulator,Terminal 页只附着/分离;`TerminalForegroundService`(`specialUse`)在有存活终端进程时常驻;标签页(上限 8);鼠标上报 1000/1002/1003 + SGR 1006;全屏程序里滑动转方向键(遵守 DECCKM);捏合缩放并重算行列;括号粘贴;`AgentTerminals` 空闲回收不再关闭前台有程序的终端。

判断“终端里有程序在跑”靠两个信号:PTY 主端的 `tcgetpgrp()` 与 shell 自身进程组的比较;以及 guest 每个提示符输出的 `OSC 133;A`(回车后没看到新提示符 = 仍在运行)。无法判断时按“在运行”处理(关闭前确认、不回收)。

**未验证(需要真机):**

- 小米 24129PN74C / HyperOS 切到后台 30 分钟以上,终端进程是否被系统或 OEM 杀掉;前台服务通知在该机型上的表现。
- 原生 `tcgetpgrp` JNI:生产 C 代码已在 Linux 宿主用真实 JVM 和子进程验证(`scripts/test_pty_bridge.py`:空闲 shell 等于自身进程组、前台有程序时不等、坏 fd 返回 -1);Android NDK / bionic 构建与真机结果未验证。
- 当前 `su`(KernelSU / Magisk / APatch)是否把 PTY 直接交给 shell。如果某个 `su` 用自己的 pty 中继,PTY 主端看到的前台进程组不会变化,这时只剩 `OSC 133;A` 一个信号;用户在 `.bashrc` 里替换了 `PROMPT_COMMAND` 时两个信号都会失效,终端会被当成空闲(关闭标签不提示,Agent 空闲回收可能关掉它)。
- vim(`:set mouse=a`)、htop、tmux(`set -g mouse on`)、less / man 中点击与滚动的实际手感;捏合后 `tput cols` 随之变化。
- 往 bash 粘贴多行内容不逐行执行(括号粘贴在真实 bash/readline 上的表现)。

**已知限制:**

- 系统或 OEM 杀掉 Minis 进程、升级 App 时,PTY 主端随进程关闭,shell 收到 SIGHUP,任务照样断。这是第二阶段(PTY 持有者放到 App 进程之外,如 guest 里的 tmux / dtach,重启后重新附着),#183 不做;`TerminalSessionManager` 作为唯一持有者,不堵这条路。
- 运行时维护(`TerminalSession.stopAll*`,由 rootfs 修复、挂载变更触发)调用方在非 UI 的运行时层,无法在那里弹出确认。实现为“停止前通知管理器”:Terminal 页弹一次对话框,页面不在前台时发一条通知。需要“先确认再维护”的流程要由发起维护的 UI 自己先检查 `TerminalSessionManager.busyCount()`,目前没有这样的入口。
- Agent 终端的标签是文本快照(没有颜色、光标位置),不是第二个 emulator 视图;“接管”后输入直达该终端。
- 剩余的后台 job(`cmd &`)不算前台程序:关闭标签不会提示,也会被当作空闲。

## 2026-10-08 之后新增的未验证项

下列行为已有单元测试或模拟器测试,**没有**在真机上完整验证,不得据此声称设备结论:
Expand Down
24 changes: 24 additions & 0 deletions scripts/test_pty_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
static native int writeBytes(int fd, byte[] bytes, int off, int len);
static native int closeFd(int fd);
static native int terminateAndWait(int pid);
static native int foregroundPgid(int fd);

static void check(boolean ok, String message) {
if (!ok) throw new AssertionError(message);
Expand Down Expand Up @@ -52,6 +53,29 @@
terminateAndWait(pid[0]);
}

// Issue #183: the PTY's foreground group is the shell's own while it waits, and another group while a
// program it started holds the terminal (job control puts the program in its own group).
fd = forkExec("/bin/sh", new String[]{"sh", "-c", "set -m; printf ready; read x; sleep 3; printf finished"},
new String[]{"PATH=/usr/bin:/bin"}, "/", 80, 24, pid);
check(fd >= 0, "foreground fork failed");
try {
check(readUntil(fd, "ready").contains("ready"), "foreground fixture did not become ready");
check(foregroundPgid(fd) == pid[0], "an idle shell must hold the foreground: " + foregroundPgid(fd));
byte[] enter = "\n".getBytes();
check(writeBytes(fd, enter, 0, enter.length) == enter.length, "could not send Enter");
long until = System.nanoTime() + 2_000_000_000L;
int seen = foregroundPgid(fd);
while (seen == pid[0] && System.nanoTime() < until) {
try { Thread.sleep(20); } catch (InterruptedException e) { throw new AssertionError(e); }
seen = foregroundPgid(fd);
}
check(seen > 0 && seen != pid[0], "a running program must hold the foreground, not the shell: " + seen);
check(foregroundPgid(-1) == -1, "a bad descriptor must report -1");
} finally {
closeFd(fd);
terminateAndWait(pid[0]);
}

fd = forkExec("/bin/sh", new String[]{"sh", "-c", "trap '' HUP TERM; printf ready; exec sleep 60"},
new String[]{"PATH=/usr/bin:/bin"}, "/", 80, 24, pid);
check(fd >= 0, "second fork failed");
Expand Down
12 changes: 12 additions & 0 deletions src/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,18 @@
android:value="Active AI agent turn with model streaming and bounded tool execution" />
</service>

<!-- Issue #183: terminals with a live process (the user's tabs and the agent's) keep the app
alive while it is in the background. Only while a process runs; specialUse, never
mediaPlayback or dataSync. See docs/AGENT-FOREGROUND-SERVICE.md. -->
<service
android:name=".service.TerminalForegroundService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Interactive terminal sessions with a live shell process started by the user or the agent" />
</service>

<!-- Alarm broadcast receiver (also handles BOOT_COMPLETED for re-scheduling).
exported=false: BOOT_COMPLETED is a protected broadcast that the
system still delivers to non-exported receivers, while exported=true
Expand Down
12 changes: 12 additions & 0 deletions src/android/app/src/main/cpp/pty_bridge.c
Original file line number Diff line number Diff line change
Expand Up @@ -248,3 +248,15 @@ Java_com_openminis_app_sandbox_PtyBridge_terminateAndWait(
if (WIFSIGNALED(status)) return (jint) -(128 + WTERMSIG(status));
return -1;
}

// Foreground process group of the PTY (tcgetpgrp on the master fd), or -1 on error.
// The terminal manager compares it with the login shell's own group to tell whether a
// program is running, so a long job is not closed or reclaimed as idle.
JNIEXPORT jint JNICALL
Java_com_openminis_app_sandbox_PtyBridge_foregroundPgid(
JNIEnv *env, jclass clazz, jint fd)
{
(void) env; (void) clazz;
pid_t pgid = tcgetpgrp(fd);
return pgid < 0 ? -1 : (jint) pgid;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import com.openminis.app.ui.terminal.emulator.TerminalEmulator
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
Expand All @@ -18,7 +21,8 @@ import java.util.concurrent.atomic.AtomicInteger
* REPL) the way a person at the keyboard would; plain commands stay on the pipe-based shell, which is cheaper.
*
* A few terminals per chat session and in total; a terminal ends with its session, with a runtime stop, or when it has
* sat unused for a long time.
* sat unused for a long time. "Unused" never includes a terminal with a program still running in the foreground: a long
* build or coding agent the agent has not looked at for an hour is still working, so only an idle shell is reclaimed.
*/
class AgentTerminals(
private val newSession: () -> TerminalSession,
Expand Down Expand Up @@ -56,6 +60,12 @@ class AgentTerminals(
private val counter = AtomicInteger(0)
private val admission = Any()

private val _changes = MutableStateFlow(0)

/** Ticks whenever a terminal is opened or closed, so the Terminal page can list the agent's terminals. */
val changes: StateFlow<Int> = _changes.asStateFlow()
private fun changed() { _changes.value += 1 }

fun get(id: String): Terminal? = terminals[id]

fun list(sessionId: String? = null): List<Terminal> =
Expand All @@ -79,6 +89,7 @@ class AgentTerminals(
terminal = Terminal("t${counter.incrementAndGet()}", sessionId, session, emulator, now())
terminals[terminal.id] = terminal
}
changed()
// The terminal answers the queries a program makes (device attributes, cursor position, OSC 7501 support).
terminal.emulator.onResponse = { bytes -> terminal.session.sendRawBytes(bytes) }
// Collect before the shell starts: the output flow has no replay, so the first prompt would be lost.
Expand Down Expand Up @@ -155,6 +166,7 @@ class AgentTerminals(
fun close(id: String): Boolean {
val terminal = terminals.remove(id) ?: return false
stop(terminal)
changed()
return true
}

Expand All @@ -173,10 +185,15 @@ class AgentTerminals(
terminal.jobs.forEach { it.cancel() }
}

/** Terminals nobody has used for [IDLE_LIMIT_MS] are closed when a new one is asked for. */
/**
* Terminals nobody has used for [IDLE_LIMIT_MS] are closed when a new one is asked for, but only a shell at its
* prompt (or one that has already ended): anything with a foreground program, or whose state cannot be told, stays.
*/
private fun sweepIdle() {
val t = now()
terminals.values.filter { t - it.lastUsedAtMs > IDLE_LIMIT_MS }.forEach { close(it.id) }
terminals.values
.filter { t - it.lastUsedAtMs > IDLE_LIMIT_MS && it.session.foregroundState() == TerminalSession.ForegroundState.IDLE }
.forEach { close(it.id) }
}

companion object {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.openminis.app.runtime.terminal

/**
* Finds the guest's prompt marker (OSC 133;A, `ESC ] 133 ; A`) in the output stream, including when it is cut
* between two reads. Pure apart from the few bytes it remembers, so it is unit-tested.
*/
internal class PromptMarkerScanner {
private var tail = ByteArray(0)

fun reset() { tail = ByteArray(0) }

/** Whether [length] bytes of [chunk], together with what came before, contain the marker. */
fun feed(chunk: ByteArray, length: Int): Boolean {
val window = ByteArray(tail.size + length)
tail.copyInto(window)
chunk.copyInto(window, tail.size, 0, length)
val found = indexOf(window) >= 0
tail = window.copyOfRange(maxOf(0, window.size - (MARKER.size - 1)), window.size)
return found
}

private fun indexOf(window: ByteArray): Int {
outer@ for (i in 0..window.size - MARKER.size) {
for (j in MARKER.indices) if (window[i + j] != MARKER[j]) continue@outer
return i
}
return -1
}

companion object {
val MARKER = byteArrayOf(0x1B, ']'.code.toByte(), '1'.code.toByte(), '3'.code.toByte(), '3'.code.toByte(), ';'.code.toByte(), 'A'.code.toByte())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,7 @@ internal interface PtyBackend {
fun resize(fd: Int, cols: Int, rows: Int)
fun close(fd: Int)
fun terminateAndWait(pid: Int)

/** Foreground process group of the PTY (`tcgetpgrp`), or -1 when it cannot be read. */
fun foregroundPgid(fd: Int): Int = -1
}
Loading
Loading