乐于分享
好东西不私藏

万字长文,OpenMinis 源码深度解析:在 iPhone 上给 AI Agent 装一台真 Linux 电脑

万字长文,OpenMinis 源码深度解析:在 iPhone 上给 AI Agent 装一台真 Linux 电脑

2026 年的 iOS 平台上出现了一个评价极高的独立应用:MacStories 主编 Federico Viticci 称它是"最近一段时间见过最令人印象深刻的独立 App",中文社区则给它贴上了"iPhone 最强 Agent 软件"的标签。这个应用就是 OpenMinis——一个把 Claude、GPT、Gemini 等主流大模型装进原生移动体验,并且给它们一台真实计算机用的开源项目。

几个月前我也写过一篇文章介绍这个项目Open Minis: iOS 端的 AI Agent 新物种,当时作者还没有开源,现在作者已经把它开源出来,而且功能更加完善和稳定。

所谓"真实的计算机"不是修辞:OpenMinis 在你的手机里跑了一个完整的 Alpine Linux 沙箱。Agent 可以在里面 apk add 装包、跑 Python 和 Node 脚本、读写真实文件;同时还能通过一组 apple-* / android-* 命令直接调用 HealthKit、日历、提醒事项、HomeKit、NFC 等系统能力。更难得的是,整个项目以 GPLv3 完全开源,源码里沉淀了大量"被真实用户和真实 bug 教育过"的工程细节。

本文基于对 OpenMinis/OpenMinis 仓库(iOS 侧约 18.7 万行 Swift、Android 侧约 15.4 万行 Kotlin)的完整通读,从背景、功能到技术原理做一次深度拆解,重点落在它的三方依赖运用与底层实现机制上。

项目地址:https://github.com/OpenMinis/OpenMinis官网:https://openminis.appLicense:GPLv3(因链接 GPLv3 的 iSH 与 GPLv2 的 PRoot)


一、背景:为什么需要这样一个东西

今天的手机 AI 应用,绝大多数是"聊天框 + 云端 API":你说话,它打字,完。少数能做"工具调用"的,工具也跑在云端服务器上——访问不了你的健康数据,碰不到你的日历,更没法操作你手机里的文件。

OpenMinis 的立场恰好相反:Agent 应该在设备本地拥有一台真电脑。README 里给出的典型用法很能说明问题:

  • 拍一张餐食照片,Agent 识别菜品、估算热量和宏量营养素,写入 Apple Health;
  • 通过快捷指令定时触发:抓取你的 X 时间线、总结、合成语音,当作闹钟播给你听;
  • 挂载你的 Obsidian 仓库,Agent 像操作普通工作区一样研究、整理、回写 Markdown 笔记;
  • 把任意分享内容(网页、消息)经 iOS 分享面板交给 Agent,自动建成带时间地点的日历事件。

这些场景的共同点是:需要真实执行环境 + 深度系统集成。而 iOS/Android 的沙箱模型恰恰最不允许这两件事。OpenMinis 的全部技术含金量,就在于它如何在两个平台的镣铐之下把这两件事做成了。

项目还有一个很鲜明的态度(写进 README):"在 AI 时代,技术设计和代码不再是产品的护城河,最好的 Agent 来自与使用者的高频反馈循环。"所以仓库干脆全量开源——它是私有开发树的公开镜像,不接受 PR,但接受 Issue。(很个性啊)


二、功能总览与整体架构

功能层面,OpenMinis 可以概括为六块:

  1. 自带模型(BYOM)
    Claude、GPT、Gemini、xAI、Kimi、OpenRouter 等,支持 API Key 与 OAuth 账号登录双通道;
  2. 真 Linux shell
    设备端沙箱化 Alpine Linux,Agent 可装包、跑脚本、操作真实文件;
  3. 设备集成
    健康、日历、提醒、通讯录、HomeKit、蓝牙、剪贴板、媒体、闹钟等,全部以工具形式暴露给 Agent;
  4. 浏览器自动化
    Agent 可以代替你浏览和操作网页(WKWebView 池 + JS 注入);
  5. Skills 与记忆
    兼容 Anthropic Agent Skills 规范的技能系统(SKILL.md 按需加载),加跨会话持久记忆;
  6. 工作区(Workspaces)
    以 minis://workspace/ 寻址的独立上下文,还实现了 MCP 客户端、Siri/快捷指令集成、iCloud/LAN 同步、灵动岛 Live Activity 等。

整体架构上,双端共享同一套设计:UI 层(SwiftUI / Compose)→ Agent 运行时(各自平台的大 ViewModel)→ Provider 层(各家 LLM 的 SSE 流式客户端)→ 沙箱层(iOS 用 iSH 模拟内核,Android 用 PRoot)→ 原生卸载层(NativeOffloads)。

这张图的左右两侧揭示了全项目最重要的一个技术分野:同样是"在手机上跑 Alpine Linux",iOS 和 Android 用了两种本质不同的方案——iOS 是用户态 CPU 模拟 + 自实现内核 ABI,Android 是基于 ptrace 的 user-space chroot。我们逐一拆开看。


三、核心黑科技(一):iOS 上的 iSH-ARM64 嵌入式沙箱

3.1 不是原版 iSH,而是 ARM64 fork + Asbestos 引擎

原版 iSH 是著名的"x86 用户态模拟器",用 gadget 汇编 JIT 在 iOS 上模拟 i386 Linux。OpenMinis 用的是自家 fork OpenMinis/ish-arm64,关键变化有三:

  • Guest 架构从 i386 换成 ARM64 (aarch64),guest OS 是 Alpine Linux aarch64——guest 与 host 同架构,大量指令可以直译执行,这是它敢在手机上跑 Python/Node 的前提;
  • 执行引擎换成自研的 Asbestos(threaded-code 解释器)。因为 iOS 禁止 RWX 内存,不可能做真正的二进制翻译 JIT,只能是 dispatch-loop 解释器;
  • 静态库libish.a 内核 + libish_emu.a 模拟器 + libfakefs.a 虚拟文件系统)形态链进 App 进程,而不是独立进程。

构建脚本里的 meson 配置是最直接的证据(deps/build_ish.sh:203-210):

meson setup "$BUILD_DIR" \    --cross-file "$CROSS_FILE" \    --buildtype="$MESON_BUILDTYPE" \    -Dlog="" \    -Dlog_handler=nslog \    -Dkernel=ish \    -Dengine=asbestos \    -Dguest_arch=arm64

"iSH 内核与 App 同进程"这一个决定,是整个 iOS 侧工程复杂度的源头——后面要讲的 JIT 崩溃恢复、FFmpeg 全局状态复位、stdio 线程局部重定向,全都是在为这个决定"还债"。

3.2 冷启动:一次内核 boot 都干了什么

ISHKernel.boot()src/ios/iSH/ISHKernel.m:334-451)的序列非常"操作系统的味道":

install_jit_crash_handler();                 // 先于一切 JIT 代码安装崩溃恢复die_handler = embedded_die_handler;          // 替换 iSH 的 die()(原版会 abort 杀掉整个 App)err = mount_root(&fakefs, _dataPath.fileSystemRepresentation);  // 挂载 fakefserr = become_first_process();                // 让自己成为 guest 的 PID 1current->thread = pthread_self();[self createDeviceNodes];                    // 手工造 /dev/tty1-7、/dev/ptmx、/dev/null …FsApplyOverlay();                            // 应用 RootfsPatch.bundle 补丁(幂等)do_mount(&procfs,   "proc",   "/proc",   "", 0);do_mount(&devptsfs, "devpts", "/dev/pts", "", 0);[self mountDnsConfig];                       // /etc/resolv.conf 文件级 bind mount 到宿主exit_hook = handle_process_exit;tty_drivers[TTY_CONSOLE_MAJOR] = &ish_console_driver;set_console_device(TTY_CONSOLE_MAJOR, 1);err = create_stdio("/dev/console", TTY_CONSOLE_MAJOR, 1);// 27 个 native offload 全部在这里注册ffmpeg_offload_register();calendar_offload_register();// … weather / healthkit / homekit / nfc / bluetooth …debug_offload_register();

几个值得展开的点:

  • fakefs
    guest 的根文件系统落在 ~/Documents/alpine-rootfs/,其中 data/ 是真实文件树,meta.db(SQLite,表 paths(path, inode) + stats(stat))存权限、属主、符号链接等 POSIX 元数据——因为 iOS 文件系统存不了这些。副作用是:App 侧直接往 rootfs 写文件时,必须同步登记 meta.db,否则内核"看不见"这个文件(RootfsManager.ensureFakefsMetadataRootfsManager.swift:299-349)。
  • DNS
    /etc/resolv.conf 以文件级 bind mount 指向宿主目录,内容从 iOS 的 res_ninit/res_getservers 实时读出写入,兜底 8.8.8.8。
  • PID 1
    become_first_process() 把调用线程变成 guest 的 init 进程,后续所有 guest 进程都 fork 自它。

3.3 嵌入式改造的精髓:JIT 崩溃恢复

原版 iSH 是独立进程,guest 崩了 _exit 就完事;嵌进 App 后,模拟器线程的一次 SIGSEGV 会杀掉整个应用。OpenMinis 的解法是一个硬核信号处理器(ISHKernel.m:88-124):用 ucontext 改写寄存器,把崩溃的 JIT 线程弹回 trampoline:

static void jit_crash_handler(int sig, siginfo_t *info, void *ctx) {#ifdef __aarch64__    if ((sig == SIGSEGV || sig == SIGBUS) && in_jit) {        ucontext_t *uc = (ucontext_t *)ctx;        uint64_t cpu_ptr = uc->uc_mcontext->__ss.__x[1];   // _cpu 结构体在 x1        uint64_t x7  = uc->uc_mcontext->__ss.__x[7];        uint64_t x10 = uc->uc_mcontext->__ss.__x[10];        uint64_t guest_addr = (x7 - x10) & 0xffffffffffffULL;        uint64_t esr = uc->uc_mcontext->__es.__esr;        int was_write = (esr & 0x40) != 0;        *(uint64_t *)(cpu_ptr + CRASH_CPU_segfault_addr) = guest_addr;        *(int *)(cpu_ptr + CRASH_CPU_segfault_was_write) = was_write;        *(uint64_t *)(cpu_ptr + CRASH_CPU_pc) = (uint64_t)jit_saved_pc;        uint64_t exit_sp = *(uint64_t *)(cpu_ptr + CRASH_LOCAL_jit_exit_sp);        uc->uc_mcontext->__ss.__sp = exit_sp;        uc->uc_mcontext->__ss.__pc = (uint64_t)jit_crash_trampoline;        ...

这段代码硬编码了 cpu_state 的字段偏移,从宿主 ESR 寄存器推断这次访存是读还是写,然后改写 SP/PC 让执行流回到 fiber 的退出点——把一次致命的段错误降格成一次"guest 内存访问异常"。

配套的还有两个细节设计(ISHKernel.m:146-198):非 JIT 的 iSH 线程崩溃时,只冻结线程不杀进程——用 __thread int ish_thread_marker 区分 iSH 线程与 App 其它线程,是 iSH 线程就屏蔽所有信号后 select() 永久休眠(因为实测 pthread_exit() 在某些线程状态下会触发 SIGTRAP 连累整个进程);不是 iSH 线程则恢复默认 handler 重新 raise,保证 Swift 运行时自己的崩溃仍能被系统 crash reporter 正常捕获。

3.4 命令执行:不走 PTY,直接操纵内核 fd 表

Agent 执行 shell 命令的链路是:AIChatViewModel → ISHExecutionCoordinator(Swift actor,负责超时/取消/会话挂载)→ ISHShellExecutor(ObjC,进程级执行)→ iSH 内核。

最精妙的一步在 ISHShellExecutor.m:236-282:它不使用 PTY,而是创建一个 guest 进程后,直接操纵内核 fd 表,把 guest 的 fd 0/1/2 绑到 host 的 pipe 上:

struct fd *stdin_fd = adhoc_fd_create(&realfs_fdops);if (stdin_fd) {    int real_fd = stdinData ? dup(stdinPipe[0]) : open("/dev/null", O_RDONLY);    ...    stdin_fd->real_fd = real_fd;    task->files->files[0] = stdin_fd;     // guest 的 fd0 = host pipe 的读端}// stdout/stderr 同理,dup 写端stdout_fd->real_fd = real_fd;task->files->files[1] = stdout_fd;...task->files->files[2] = stderr_fd;

realfs_fdops 让 guest 的 read/write 直接落到 host fd 上。好处是输出零 TTY 噪音(不回显、无 ANSI 提示符),stdout/stderr 天然分离——这正是 shell_execute 工具能拿到干净输出的原因。reader 线程用 poll() 轮询两根 pipe(500ms 超时),做 UTF-8 安全截断(不完整的多字节序列留到下一批),逐行回调 UI。

取消链路同样讲究(ISHShellExecutor.m:555-628):杀进程组时按 pgid + 祖先链双重匹配(busybox ash 会给子 shell setpgid(0,0) 做 job control,只按 pgid 杀会漏掉 sleep 之类的孙进程——注释里点名"这就是早期 Stop 按钮杀不掉 sleep 的原因"),拒绝 pid ≤ 1(防止误删整个内核),SIGTERM 后 200ms 升级 SIGKILL,且升级前重验 pid 未被回收复用。

另一个"被 busybox ash 教做人"的例子(ISHExecutionCoordinator.swift:299 + ISHShellExecutor.m:123-132):命令不是作为参数而是通过 stdin 喂给 /bin/sh,脚本被包装成:

let scriptContent = "cd /root\n({ exec 0</dev/null; } 2>/dev/null || true; \(command)\n)\n"

这样既绕开 shell 引号地狱,又让子进程拿到立即 EOF(防止 stdio 型 MCP server 继承已耗尽的 pipe 永远挂起)。末尾的 \n 也不能省——busybox ash 的 heredoc 解析器在终止行后缺换行时 100% 报 unexpected end of file

3.5 每会话文件路由:fs_context 钩子

多会话并发时,每个会话的 /var/minis/workspace/ 必须指向各自的宿主目录,又不能靠切换 bind mount(那样会话切换就要卸载重挂)。OpenMinis 的方案是给每个会话分配一个 u64 的 fs_context token,fork 时打在进程组上并随 fork 继承;然后在 fakefs 的路径翻译热路径上挂一个 C 层钩子(Agent/ISH/MinisFsRouter.swift:16-113):

final class MinisFsRouter: @unchecked Sendable {    /// 每会话独立路由的 guest 前缀;memory/skills/shared 是全局的    private let perSessionBuckets: [(linuxPrefix: String, hostSubdir: String)] = [        (AIChatViewModel.minisOffloadsLinuxDir,    "offloads"),        (AIChatViewModel.minisAttachmentsLinuxDir, "attachments"),        (AIChatViewModel.minisWorkspaceLinuxDir,   "workspace"),        (AIChatViewModel.minisBrowserLinuxDir,     "browser"),    ]    private var contextToSid: [UInt64: String] = [:]    func installHook() {        ISHKernel.shared.installPathTranslateHandler { [weak self] guestPath, fsContext in            return self?.translate(guestPath: guestPath, fsContext: fsContext)        }        ISHKernel.shared.installPathReverseHandler { [weak self] hostPath in            return self?.reverse(hostPath: hostPath)        }    }    // 热路径:必须无阻塞——哈希查表 + 几个字符串操作    private func translate(guestPath: String, fsContext: UInt64) -> String? {        guard fsContext != 0, let sid = sid(for: fsContext) else { return nil }        return hostPath(forGuest: guestPath, sid: sid)    }}

于是 guest 进程访问 /var/minis/workspace/report.csv 时,被透明改写到 ~/Library/MinisChat/minis/<sid>/workspace/report.csv会话切换不用动任何挂载,多个会话的 guest 进程并发跑也不串数据。 一个有趣的实现细节:ObjC 桥接层的注释(ISHKernel.m:1277-1303)坦白这些 block 是"有意泄漏"的——trampoline 跑在无锁 worker 线程热路径上,释放被替换的 block 有竞态,而安装次数屈指可数,泄漏可控。

iOS 侧沙箱的全貌总结成一张图:


四、核心黑科技(二):Android 上的 PRoot 方案

Android 侧不需要 CPU 模拟——直接上 PRoot(user-space chroot,fork 自 Termux)。guest 跑的是原生 aarch64 指令,PRoot 只用 ptrace 拦截子进程的系统调用,重写路径和 uid。没有模拟开销,这是它比 iOS 方案"占便宜"的地方;但把 PRoot 塞进一个合规的 Android App 里,工程技巧一点不少。

4.1 把一个可执行文件伪装成 .so

Android 应用私有目录默认不可执行(noexec),那 proot 二进制放哪?OpenMinis 用了一个非常巧的打包技巧:把 proot 编译产物改名成 libproot.so 放进 jniLibs/arm64-v8a/。APK 里的 .so 会被 PackageManager 自动解压到 nativeLibraryDir——一个拥有 exec 权限的目录。配合 manifest 的 android:extractNativeLibs="true",运行时直接 execve 这个"共享库":

// RootfsManager.kt:43val prootBinary: File = File(context.applicationInfo.nativeLibraryDir, "libproot.so")

同一份二进制还会放一份到 assets/proot-aarch64 兜底,且 build.gradle.kts:97 用 noCompress += ["tar.gz", "proot-aarch64"] 保证它们不被 AAPT 压缩。构建侧(deps/build_proot.sh)还有个细节:PRoot 依赖 Samba 的 talloc 内存分配器,而 Samba 用 waf 构建系统——脚本干脆只单文件编译 talloc.c,并手写一个 80 行的 replace.h 兼容 shim,让 talloc 脱离 Samba 其余部分直接对 bionic 编译。

4.2 PRootKernel:一个"不是内核的内核"

iOS 的 ISHKernel 是一个持久内核对象;Android 的 PRootKernel(920 行)则只是配置中心 + 命令行构造器——因为 PRoot 是 process-per-command 模型。文件头注释写得很直白:

/** * PRoot configuration holder and command builder. * Corresponds to iOS ISHKernel — but since PRoot is process-per-command * (not a persistent kernel), this object holds configuration state * and builds proot command lines. */

它拼出的命令行长这样(PRootKernel.kt:586-648):

<proot> -0 --link2symlink -r <rootfs> -b /dev -b /proc -b /sys -w /root        [-b <host>:<linux> …]        --native-offload=<socket>:<handler1,handler2,…>        /bin/sh -c "<command>"

两个值得单独说的 flag:

  • -0
    :伪装 uid 0(root),让 apk 等工具开心;
  • --link2symlink
    :Android /data 分区拒绝 app uid 的跨目录 hardlink,而 Alpine 的 apk 装 binutils/gcc 时全靠把 ar/ld/nm hardlink 到 busybox——不加这个 flag 每次安装都是 "Permission denied"。

PRoot 的 -b 挂载没有只读修饰符,用户挂载的 Obsidian 仓库又想只读保护怎么办?OpenMinis 的做法是用户态 trick 补能力缺口:在 guest 的 /usr/local/bin/ 里给 touch/tee/cp/mv/mkdir/rm 等命令装一层 sh wrapper,wrapper 运行时 source /var/minis/.mount-readonly-prefixes 配置,命中只读前缀即 exit 1 并给出可读错误;纯 > 重定向则靠宿主文件系统的 EACCES 自然兜底(PRootKernel.kt:852-909)。

4.3 双执行通道与 marker 协议

Android 侧有三条执行通道:

  • 一次性命令
    ShellExecutor):ProcessBuilder 直接跑,超时 destroyForcibly()
  • 每会话长驻 shell
    PersistentShell):agent 的 shell_execute 真正走的通道——每个 chat session 一个 proot … /bin/sh 进程,环境变量、cwd、已装包在会话内持续存在;
  • 交互式 PTY
    pty_bridge.c JNI 封装 bionic forkpty()):App 内终端页用,vi/top/gh auth login 靠它工作。

长驻 shell 最大的难题是:怎么知道一条命令跑完了、退出码是多少? 答案是 marker 协议(PersistentShell.kt:277-278):

val marker = UUID.randomUUID().toString().take(8)val wrappedCommand = "$command\necho \"__MINIS_DONE_${marker}_EXIT_\$?__\"\n"

读线程扫描输出流里的 __MINIS_DONE_<id>_EXIT_<code>__,既界定了输出边界又拿到了退出码。配套还有一个隐蔽工程点:长驻 shell 会残留上次注入的环境变量,所以每次执行前按上次的 key 集合先 unset 再 export,模拟 iOS 侧"每条命令一个新进程"的天然隔离。

4.4 Native Offload:socket 协议 + execve 改写

Android 的原生卸载机制甚至比 iOS 更绕一层。OpenMinis 给 PRoot 打了 native_offload 扩展补丁:guest 里 execve("android-calendar", …) 被拦截,argv/env/cwd 通过 abstract unix socket 发给 App 进程内的 Kotlin 服务器;服务器执行对应 handler,把输出写进 rootfs 的 /tmp/.native-offload-<pid>-<seq>,回复 (exit_code, tmpfile_path);然后 PRoot 把原 execve 改写成 /bin/cat <tmpfile>——于是 guest 进程"看到"的就是一次普通命令执行。协议是自定义二进制的(sandbox/NativeOffload.kt:56-58):

private const val MAGIC_REQ = 0x46464F4E  // 'N' 'O' 'F' 'F' little-endianprivate const val MAGIC_RSP = 0x52464F4E  // 'N' 'O' 'F' 'R'private const val VERSION = 1

socket bind 还带指数退避重试(0/50/100/200/400/800ms)——因为抽象 socket 在上一个 App 进程被 OOM 杀掉后不会立刻释放。

Android 侧沙箱全貌:

4.5 Shizuku:Android 独有的特权面

Android 端还有一层 iOS 没有的能力面:通过 Shizuku(dev.rikka.shizuku:api:13.1.5,MIT),以 uid 2000(shell)甚至 root 身份执行 pm/am/cmd/settings/dumpsys/input 等特权命令,包装成沙箱内 CLI android-shizuku-cli,12 个子命令组,支持 --format json|text|csv。每一次调用还要先过 OffloadGate.allow() 三态权限门(ASK_ONCE 会弹 UI 对话框挂起等待用户确认)。manifest 注释里还提到:AXManager/Axeron 是 Shizuku 协议的 drop-in 实现,复用 rikka SDK 即可零额外依赖兼容。


五、Agent 运行时:一个被真实 bug 反复捶打的 loop

5.1 双端"人肉 parity"

OpenMinis 没有跨平台共享代码的 Agent 层——iOS 的 Agent 运行时住在 AIChatViewModel.swift(5830 行主文件 + 19 个 extension 拆分文件),Android 住在 ChatViewModel.kt(9844 行)。两端靠大量行为级注释做对齐,Android 代码里到处是 // Mirrors iOS AIChatViewModel.swift:6229-6259 这样的行号互引,形成一套独特的"人肉 parity 协议"。连同一份 bashism 检测规则都是双端共享的 JSON(src/shared/bashism/bashism_rules.json,构建期分别拷进 iOS bundle 和 Android assets)。

5.2 统一抽象:一套事件协议罩住所有厂商

Provider 层的关键设计是把 Anthropic/Gemini/OpenAI 三家的流式协议映射到同一组规范类型上(Providers/AgentProvider.swift:16-219):

// 统一流事件:三家厂商都映射到这里enum AgentStreamEvent: @unchecked Sendable {    case contentBlockStart(AgentBlockStart)    case textDelta(String)    case toolInputDelta(name: String, accumulated: String)    case toolCallComplete(id: String, name: String, args: [String: Any],                          metadata: ToolCallMetadata?)    case usage(LLMUsage)    case thinkingDelta(String)    case reasoningContent(String)     // Kimi/DeepSeek 的 opaque reasoning,须回显    case reasoningEcho(ReasoningEcho) // OpenAI Responses 加密 reasoning    case done(stopReason: AgentStopReason)  // endTurn / toolUse / maxTokens / refusal}protocol AgentProvider {    var name: String { get }    var model: LLMModel { get }    var defaultMaxTokens: Int { get }    func streamAgentMessageClamped(messages: [AgentMessage], systemPrompt: String?,        tools: [AgentToolDefinition], maxTokens: Int,        thinkingLevel: ThinkingLevel) async throws -> AsyncThrowingStream<AgentStreamEvent, Error>}

Android 侧是完全对称的 sealed class LLMStreamChunkdata/model/LLMStreamChunk.kt:5-32),工具定义 AgentToolDefinition 自带 toAnthropicJson()/toGeminiJson()/toOpenAIJson() 三个序列化器。各厂商的"怪癖"都在各自的映射层消化:Gemini 不给工具调用 id(客户端合成 UUID)、Gemini 即使带 function call 也发 STOP(有工具调用时必须改判为 .toolUse)、Gemini 3 的 thoughtSignature 必须持久化并在会话恢复时回填、某些 OpenAI 兼容网关会给并行 tool_calls 发重复 id(客户端改名 <id>-2<id>-3)……

5.3 主循环骨架

一次请求的完整循环(双端结构一致):

iOS 侧骨架(AIChatViewModel.swift:4477-4550):

loopLabel: while turnCount < Self.maxAgentTurns {   // 上限 200    defer { turnCount += 1 }    try Task.checkCancellation()    // 裁剪旧图片 → offload 旧 tool 输出 → 循环内 auto-compact 决策    trimOldImagesFromHistory()    offloadContextIfNeeded(model: activeModelForOffload,                           lastContextTokens: turnUsage.latestContextTokens)    switch checkContextBeforeSend() {    case .ok: break    case .needsCompact:        if compactionsThisLoop < Self.maxInLoopCompactions, let anchorId = compactAnchorId {            compactionsThisLoop += 1            await compactBefore(anchorId, allowDuringProcessing: true)            turnCount -= 1   // compact 是空间管理,不消耗 turn 配额            continue        }        fallthrough    case .exhausted:        appendSystemInfo(String(localized: "Context is full and could not be compacted further..."))        canResume = true        break loopLabel    }    let stream = try await streamWithGroupFallback(provider: provider, ...)    // … 消费 SSE → 工具派发 → tool_result 回灌 → continue …}

看点有三:turn 上限 200 写在 while 条件里而不是事后判断,并用 hitTurnLimit 标志位区分"打满上限"与"正常 break"(注释自承 v1.4.0-dev 曾因此误报);compact 不占 turn 配额但总上限不重置,防"无限 compact 循环";上下文管理按窗口大小分四档(<32K 不动、32-64K 剩 10K 触发 offload、64-128K 剩 20K offload/10K compact、≥128K 剩 40K/20K),旧工具输出落盘到 /var/minis/offloads/,模型需要时可用 file_read 找回。

5.4 并发工具执行 + 按序缝合

模型一轮可能返回多个工具调用。执行是并发的,但 Anthropic API 要求 tool_result 与 tool_use 严格同序配对——所以是"并发执行、按序交付"(AIChatViewModel.swift:5267-5320):

var outcomesByIndex: [Int: ToolExecOutcome] = [:]await withTaskGroup(of: (Int, ToolExecOutcome).self) { group in    var added = 0    var harvested = 0    for (idx, tu) in toolEntries.enumerated() {        // 限速:在途数低于 maxConcurrentTools(=10) 才放行        while added - harvested >= Self.maxConcurrentTools {            if let pair = await group.next() {                outcomesByIndex[pair.0] = pair.1                harvested += 1            } else { break }        }        group.addTask { [weak self] in            let outcome = await self.executeSingleToolUse(                tu: tu, msgIdx: msgIdx, tools: toolsSnapshot, batchBudget: imageBudgetActor)            return (idx, outcome)        }        added += 1    }    for await pair in group { outcomesByIndex[pair.0] = pair.1; harvested += 1 }}// 按原始 idx 缝回,保证 tool_use/tool_result 配对顺序for idx in 0..<toolEntries.count {    guard let outcome = outcomesByIndex[idx] else { continue }    toolResultParts.append(outcome.resultPart)}

5.5 ToolLoopDetector:防跑飞熔断器

Agent 产品最怕模型"跑飞"——对着一个失败命令无限重试烧 token。OpenMinis 的熔断器有四类策略(Agent/ToolLoopDetector.swift:74-129,Android 侧 ToolLoopDetector.kt:98-159 完全对称):

func check(toolName: String, params: [String: Any]) -> LoopCheckResult {    let argsHash = argsHashFor(toolName, params)    // 策略1:unknown_tool_repeat —— 连续调用不存在的工具(幻觉工具)    let unknownStreak = countUnknownStreakFromTail(toolName: toolName)    if unknownStreak >= config.unknownToolThreshold { ... return .critical }    // 策略2:global_circuit_breaker —— 同参同结果的"无进展"连续调用    let noProgressStreak = getNoProgressStreak(toolName: toolName, argsHash: argsHash)    if noProgressStreak >= config.globalCircuitBreakerThreshold { ... return .critical }    // 策略3:known_poll_no_progress —— 轮询类工具双阈值(10 警告 / 20 阻断)    if isPollTool(toolName, params) { ... }    // 策略4:generic_repeat —— 非轮询工具的累计同参次数    ...}

配套的工程技巧也很细:算 args 哈希前剔除 tool_title(纯 UI 展示字段,模型每次都写得不一样);算结果哈希前用正则剥离 ISO 时间戳、elapsed/durationrequest-id 等易变字段再 SHA256——否则"同样的失败"永远哈希不同,熔断永远不会触发。WARNING 级会把提示追加到 tool_result 文本里让模型自己看到,CRITICAL 级直接合成 [LOOP BLOCKED] 错误结果短路,同时保持 tool_use/tool_result 配平不破坏 API 协议。

5.6 流式性能:每一层都有节流梯子

流式渲染是移动端的性能重灾区(注释里记录了真实事故:Anthropic 慢轮次每秒 50-100 个 delta,早期版本每个 delta 都重组整个聊天列表直接 ANR;Pixel 4a 上 String += 的 O(n²) GC 曾打爆 256MB 堆)。修复是分层的:

  • 累积器
    :用 [String] 数组惰性 join 或 StringBuilder,杜绝 String +=(iOS 注释算了笔账:eager input streaming 下 150-200 chunk/秒,50KB 输入会被 += 复制成约 25MB memcpy);
  • 事件层
    :tool input delta 的 yield 节流 80ms(最多约 12 事件/秒);
  • UI 层
    :Android 按文本长度分级节流(ChatViewModel.kt:5843-5850):
fun textDeltaThrottleMs(len: Int): Long = when {    len < 500     -> 150L    len < 2_000   -> 300L    len < 32_000  -> 500L    len < 64_000  -> 1_000L    len < 128_000 -> 1_500L    else          -> 2_000L}
  • 渲染层
    :iOS 消息列表用 UICollectionViewCollectionViewMessageListV3)而非 SwiftUI LazyVStack 扛流式长会话。

5.7 弱网防御:静默断流检测

SSE 在弱网下最阴险的故障是 TCP 半开——URLSession/OkHttp 层不报错,流就这么"安静"了。两端的解法异曲同工。iOS 用每个事件 120 秒的"看门狗竞速"(AIChatViewModel+SSEStream.swift:271-285):

let stallTimeoutSeconds: TimeInterval = 120let iterBox = StreamIteratorBox(stream)   // AsyncIterator 不 Sendable,装盒while true {    let maybeEvent: AgentStreamEvent? = try await withThrowingTaskGroup(of: AgentStreamEvent?.self) { group in        group.addTask { try await iterBox.next() }        group.addTask {            try await Task.sleep(nanoseconds: UInt64(stallTimeoutSeconds * 1_000_000_000))            throw StreamStallError(seconds: Int(stallTimeoutSeconds))        }        let result = try await group.next()!        group.cancelAll()        return result    }    guard let event = maybeEvent else { break }

Android 则用一个 Flow 操作符把"中继断连但 HTTP 正常结束的空气回复"转成可重试错误(provider/LLMProvider.kt:138-161):

fun Flow<LLMStreamChunk>.failOnSilentEmptyCompletion(providerName: String): Flow<LLMStreamChunk> = flow {    var sawContent = false    var sawFinishReason = false    collect { chunk -> …; emit(chunk) }    if (!sawContent && !sawFinishReason) {        throw LLMError.TransientError("Server returned an empty response (connection dropped or upstream error)")    }}

两者殊途同归:把"静默失败"变成"显式错误",交给上层的自动重试(间隔 3/5/10/15/30 秒,UI 上有倒计时)和组内 fallback(一个 ModelGroup 配多个模型/端点,rate limit 或鉴权失败自动切下一个,同 modelId 切换时 UI 模型胶囊不闪烁)。


六、Native Offloads:把 iOS 框架伪装成 Linux CLI

6.1 机制:execve 拦截 + JSON envelope

这是全项目最优雅的设计之一。iOS 侧在内核 boot 时注册 27 个 handler(ISHKernel.m:413-445),每个 handler 对应 guest 里的一个命令:

// CalendarOffload.m 尾部void calendar_offload_register(void) {    int err = native_offload_add_handler("apple-calendar", calendar_handler);    if (err == 0) {        noff_ensure_guest_stub("/usr/local/bin/apple-calendar");  // guest 里建可执行占位桩        NSLog(@"NativeOffloads: apple-calendar handler registered");    }}

noff_ensure_guest_stub 在 fakefs 里创建 /usr/local/bin/apple-calendar 占位文件让 PATH 解析成立;guest 进程对它 execve 时被内核的 native_offload 层拦截,路由到 ObjC handler 直接调 EventKit/HealthKit/HomeKit/Vision 等原生框架,结果以统一 JSON envelope 写回 guest 的 stdout:

// NativeOffloadUtils.h/// 成功信封: {ok:true, tool, action, data, timestamp}NSDictionary *noff_json_envelope(NSString *tool, NSString *action, id data);/// 错误信封: {ok:false, tool, action, error:{code,message}, timestamp}NSDictionary *noff_json_error(...);

对 LLM 来说这一切都是普通 CLI——system prompt 只教它一句:"apple-* 工具输出 JSON,--help 看用法"。iOS 侧有 apple-{calendar, location, weather, vision, open, clipboard, healthkit, photos, maps, nlp, alarm, media, speak, speech, device, homekit, notification, player, reminders, bluetooth, nfc} 等 22 个,外加 ffmpeg 和 minis-{model-use, sessions, browser-use, config, debug} 等元命令;Android 侧对应 android-* 家族 23 个 handler。HealthKit 还设计了 types 自发现 + batch 一次取多指标的渐进披露。需要 offload 的原因有二:能力(这些框架 API 只在原生侧可用)和性能(下一节的 FFmpeg 是最好的例子)。

6.2 案例深挖:FFmpeg 的三层 patch

Agent 想在沙箱里跑 ffmpeg -i a.mov b.mp4。如果在 Asbestos 解释器里执行 CPU 密集的转码,会慢一到两个数量级,而且用不了 VideoToolbox 硬编。所以 OpenMinis 把 FFmpeg 6.1.2 编成 iOS framework,guest 里敲 ffmpeg 时被拦截,直接调进程内的 ffmpeg_main()。代价是要把"一次性命令行进程"驯化成"App 进程内可反复调用的库函数",为此打了三层 patch:

Patch 1:main() 改名build_ffmpeg.sh:141-158,sed 实现)——避免与 App 入口符号冲突:

patch_main_symbol() {    FFMPEG_C="$FFMPEG_DIR/fftools/ffmpeg.c"    if grep -q 'int ffmpeg_main(' "$FFMPEG_C"; then        log_info "Already patched, skipping"; return    fi    sed -i '' 's/^int main(int argc, char \*\*argv)/int ffmpeg_main(int argc, char **argv)/' "$FFMPEG_C"}

Patch 2:让 ffmpeg_main 可重入deps/ffmpeg-patch/0001-ffmpeg-reset-statics.patch)——ffmpeg 靠大量全局/静态状态工作,第二次调用会看到上次残留的 received_sigterm 立即误退出。补丁把函数内 static 提升为文件级变量,并新增复位函数:

static int64_t print_report_last_time = -1;static int     print_report_first     = 1;static int64_t kbd_last_time          = 0;void ffmpeg_reset_statics(void){    received_sigterm       = 0;    received_nb_signals    = 0;    atomic_store(&transcode_init_done, 0);    ffmpeg_exited          = 0;    copy_ts_first_pts      = AV_NOPTS_VALUE;    print_report_last_time = -1;    ...}

Patch 3:线程局部 stdio 重定向fftools_stdio_redirect.h,工程含金量最高的一层)——ffmpeg 用 fprintf(stderr, …) 打进度,常规做法是 dup2() 劫持 fd 1/2,但那会把全进程的 NSLog 一起劫走。OpenMinis 不改一行 ffmpeg 源码,而是用 -include 强制注入头文件,宏覆盖所有输出函数,再按 _Thread_local fd 决定写到管道还是透传:

/* -1 means "use the real stdio function" (passthrough). */extern _Thread_local int noff_stdout_fd;extern _Thread_local int noff_stderr_fd;...#define fprintf  noff_fprintf#define printf   noff_printf#define fputs    noff_fputs#define vfprintf noff_vfprintf

消费端 FFmpegOffload.m 再把闭环补上:pthread_mutex 串行化(全局状态非线程安全)、8MB 栈的专用 pthread、以及 argv 重写——-c:v libx264 → h264_videotoolboxlibx265 → hevc_videotoolbox-crf N 按 bitrate ≈ 8000 * 2^((18-crf)/6) kbps 的经验公式折算成 -b:v(VideoToolbox 只认码率不认 CRF),剥掉 -preset/-tune/-pass 等 VT 不认识的选项。LAME 3.100 静态链入提供 MP3 编码(所以构建顺序必须是 LAME → FFmpeg,否则 MP3 支持被静默丢弃)。


七、Skills、记忆与 minis:// 协议

7.1 Skills:完整对齐 Anthropic Agent Skills 规范

OpenMinis 的 Skills 系统完全按 Anthropic 的规范实现:一个 skill = 一个目录,必有 SKILL.md(YAML frontmatter 的 name/description + Markdown body),可选 scripts/references/assets/。精髓是三级渐进披露:元数据常驻 system prompt → SKILL.md body 由模型用 file_read 按需加载 → bundled 资源用到再读。官方 README 甚至声称"为 Claude、Codex、OpenClaw、Hermes Agent 写的 skills 一般可直接在 Minis 里跑"。

元数据注入的实现(Agent/Session/SkillStore.swift:1257-1359)在技能数量超限时会做三级优先挑选(内置优先 → 7 天内新建/修改 → 按使用频率),并且诚实地告诉模型"还有 N 个没列出来,自己去 ls /var/minis/skills/":

var xml = "<available_skills>\n"for skill in selected {    xml += "  <skill>\n"    xml += "    <name>\(escapedName)</name>\n"    xml += "    <description>\(escapedDesc)</description>\n"      // 截断到 200 字符    xml += "    <path>/var/minis/skills/\(skill.id)/SKILL.md</path>\n"    xml += "  </skill>\n"}xml += "</available_skills>"fragment += "Reusable instruction sets stored at /var/minis/skills/<name>/SKILL.md. "         + "Read the SKILL.md file to load full instructions before using a skill.\n\n"...if hasMore {    fragment += "\n\n\(omitted.count) more skills not shown above: \(undisclosedNames). "             + "List /var/minis/skills/ or grep to search all."}

存储设计也有讲究:SKILL.md 文件在磁盘保持原样(与官方格式兼容,shell 里也能改),元数据单独存 skills.db(裸 SQLite3,含 session_skill_overrides 表做每会话开关);frontmatter 解析还容忍"用户粘贴时丢了开头 ---"的残缺文件。

7.2 记忆:就是 Markdown 文件

持久记忆没有向量数据库,没有任何花哨的东西——就是 /var/minis/memory/ 下的 Markdown 文件:GLOBAL.md(用户维护的只读全局记忆)+ YYYY-MM-DD.md 每日日志。注入策略是 GLOBAL.md 全文 + 最近 3 天有内容的日志(各取前 200 行,最多回溯 30 天)。写入时以时间戳注释开头前插到当天文件,同时登记进 iSH 的 meta.db 让 shell 侧可见、打 dirty 标记给 iCloud 同步:

// memory_write:条目以时间戳注释开头,前插到当天文件let entry = "<!-- \(timestamp) -->\n\(content)\n\n"let newContent = entry + existingtry writeData.write(to: fileURL)ensureFakefsMetadata(for: linuxPath, isDirectory: false)   // 注册进 iSH meta.db,shell 可见

检索(memory_get)是按时间戳切成条目后逐条打分:0.5 × 关键词命中率 + 0.5 × 归一化时间新近度,输出上限 60 条或 30KB。memoryEnabled 会话级开关同时门控工具注册(直接不放这两个工具定义)和 prompt 注入,执行侧还有 defense-in-depth 二次拒绝——能力收敛靠"工具定义不下发"而非"执行时拒绝",这是很正的安全工程姿势。

7.3 minis:// 的双义设计

minis:// 在 OpenMinis 里身兼两职:一是会话资源协议minis://workspace/report.csv 在 agent 工具、shell、Markdown 渲染三处解析到同一个宿主文件,URL 本身不含 session id——永远只解析"当前会话",从协议层面消灭跨会话引用);二是应用深链minis://settings/...minis://open_terminal?init_command=minis://sessions/<id> 等,与 Android 端路由表对齐)。深链解析有个细节很贴心:未知的 settings 路径会降级到设置首页而不是报错——因为 LLM 生成的链接可能有幻觉,"不能把用户带丢"。


八、三方依赖全景与构建链

8.1 依赖分层

OpenMinis 的依赖可以分成三层,license 策略也由此决定:

沙箱层(GPL,是全仓 GPLv3 的根因)

组件
版本/来源
License
用途
iSH-ARM64(OpenMinis fork)
feature-arm64
 分支
GPLv3 + App Store 豁免(LICENSE.IOS
iOS 沙箱内核
PRoot(fork of termux/proot)
5.1.107 系
GPLv2
Android 沙箱
talloc(Samba)
2.4.2 单文件构建
LGPLv3+
PRoot 的内存分配器
Alpine Linux minirootfs
3.21.0(iOS)/ 3.21.3(Android)
聚合(musl MIT、BusyBox GPLv2…)
双端 rootfs

原生卸载/媒体层

组件
版本
License
用途
FFmpeg
6.1.2
LGPL-2.1+(刻意不开 --enable-gpl
沙箱内 ffmpeg 命令的宿主进程内执行
LAME
3.100(vendored)
LGPL-2.0+
MP3 编码,--enable-libmp3lame 链进 FFmpeg
cppjieba
vendored header-only
MIT
中文分词(语音纠错/文本分词)
KaTeX
内置全套离线资源
MIT
公式渲染(与 SwiftMath 双路径)

应用层(iOS SPM,只有 4 个直接依赖)

约束
License
用途
SwiftAnthropic
exactVersion 2.2.0
MIT
Anthropic API 客户端
swift-cmark
≥ 0.4.0
BSD-2-Clause
Markdown 解析
SwiftMath
≥ 1.7.3
MIT
LaTeX 原生渲染
RealTimeCutVADLibrary
≥ 1.0.0
MIT
实时语音端点检测

应用层(Android Gradle,无 version catalog,版本写死在 build.gradle.kts:Compose BOM 2025.09.00、Room 2.6.1、OkHttp + okhttp-sse 4.12.0、kotlinx-serialization 1.7.3、kotlinx-coroutines 1.9.0、Coil 2.7.0、mikepenz multiplatform-markdown-renderer 0.33.0、androidx.security-crypto 1.1.0-alpha06(EncryptedSharedPreferences 存 API key)、Shizuku 13.1.5、ACRA 5.12.0(本地崩溃报告,无网络 sender)、Reorderable 2.4.0。

一个值得注意的选型哲学:能不依赖就不依赖。OpenAI/Gemini/OpenRouter/xAI/Kimi 全部手写 URLSession/OkHttp SSE 解析(Android 明明依赖了 okhttp-sse 却不用它的 EventSource,因为要按行容错、兼容厂商私有事件、精确映射错误);SQLite 全部裸 C API 直调,不用 GRDB/Room 之外的封装(iOS 侧连 Room 等价物都没有,ChatStore 6211 行手写 sqlite3_prepare_v2);rootfs 解压自写 ZIP/tar 解析器而不引 ZIPFoundation。

8.2 SwiftAnthropic 的"传输层打补丁"用法

SwiftAnthropic 是唯一被精确锁版(exactVersion 2.2.0)的 SPM 依赖,原因是 OpenMinis 对它的用法远超普通调用:三种凭证对应三个自写 HTTPClient 实现注入 SDK(Providers/Anthropic/AnthropicProvider.swift:130-184):

// API Key 通道self.service = AnthropicServiceFactory.service(    apiKey: apiKey,    basePath: resolvedBase,    betaHeaders: nil,    httpClient: EagerStreamingHTTPClient(customUserAgent: customUserAgent))// OAuth (Claude Code) 通道self.service = AnthropicServiceFactory.service(    apiKey: "oauth-placeholder",    basePath: resolvedBase,    betaHeaders: ["oauth-2025-04-20"],    httpClient: OAuthHTTPClient(tokenProvider: oauthTokenProvider))

SDK 不支持的能力,全靠 RequestBodyPatcher 在 URLProtocol 层改 JSON 请求体注入:tools 的 cache_control、eager input streaming、tool_result 内嵌图片、扩展 cache TTL、新旧两版 thinking 配置、给 Anthropic 兼容代理回显未签名 thinking 块……这是"SDK 不够用就在传输层打补丁"的典型工程,锁精确版本就是为了保证 patch 的 JSON 结构假设不被 SDK 升级破坏。

8.3 构建链

iOS 构建有严格顺序(FFmpeg 链 LAME,顺序错了 MP3 支持静默消失):

./deps/build_lame.sh    && ./deps/build_ffmpeg.sh   # → libmp3lame.a → *.framework./deps/build_ish.sh     && ./deps/prepare_alpine_rootfs.sh   # → *.a → alpine-rootfs.zipopen src/ios/Minis.xcodeproj

Android:

./deps/build_proot.sh && ./scripts/prepare_android_sandbox.shcd src/android && ./gradlew :app:assembleDebug

Alpine rootfs 的定制刻意保持"纯净":不预装任何 apk 包,定制全部落在配置文件(/etc/profile 的 PS1/别名、/etc/apk/repositories 锁 v3.21 源、/etc/passwd 改 root shell),运行时按需 apk add。双端还有一份逐字节相同的 default_mount/ 出厂覆盖层,里面最有意思的是 minis-open:用 OSC 1337 终端转义序列做沙箱→宿主的带外通信,把 Linux 生态的 xdg-open https://… 习惯调用转成 App 内预览——xdg-openx-www-browsersensible-browsergnome-openkde-open 六个名字全是它的别名,覆盖 Linux 世界所有"打开浏览器"入口:

# Protocol: ESC ] 1337 ; MinisOpenURL = <url> BELemit_marker() {    printf '%s]1337;MinisOpenURL=%s%s\n' "$ESC" "$1" "$BEL"}

九、踩坑集锦:注释里的实战编年史

读 OpenMinis 的源码注释本身就是一种享受——几乎每个 workaround 都附着事故日期、症状描述和任务号(T-xxx/GH#xxx)。摘几个最有代表性的:

  • 模拟器里跑 Node
    :V8 的 JIT 在 guest 自我修改代码时超出 Asbestos 能力,必须注入 --jitless --no-lazy --no-expose-wasm --max-old-space-size=512,还要 LD_PRELOAD=/lib/zero_free.so 修 V8 对 free() 清零语义的假设(ISHKernel.m:697-716)。同类还有 GODEBUG=asyncpreemptoff=1(Go 的异步抢占信号干扰模拟器)、PYTHONMALLOC=mallocGOMAXPROCS=2
  • musl 的 TZ 解析器
    不认带 +/- 的缩写(GMT+8 会被误解析),所以双端都自造 POSIX 格式 TZ=LCL+8
  • heredoc 缺尾换行
    :busybox ash 对 …\nEOF 结尾缺换行的命令 100% 报 unexpected end of file,executor 层强制补 \n
  • iOS 26 TextKit1 重入崩溃
    :markdown 表格流式渲染触发 NSTextContainer setSize: 重入,被 watchdog 以 0x8BADF00D 杀进程——解法是在 App 启动时 swizzle 掉它(MinisApp.swift:106-146 的 NSTextContainerSetSizeGuard.install()),顺便还有 AttributeQueryRecorder 供 HangDetector dump、Bundle 语言 swizzle 实现免重启换语言、LAContext 预热(首次冷启 XPC 约 500ms)等一整套"防御性预热"。
  • bashism 规则库
    src/shared/bashism/bashism_rules.json 是双端唯一事实源,21 条规则按危害分级——最阴险的是 S 级(silent-wrong):(( x > y )) 在 busybox ash 里会被解析成嵌套子 shell、x+=1 变成"命令未找到"但脚本照常跑。每条规则都在真机 Alpine 3.21.0 / BusyBox 1.37.0 上验证过,且模式必须待在 ICU/java.util.regex 的公共子集内、禁止嵌套量词(防 ReDoS)。命中 T1 级(显式 bash 语法)时按需 apk add bash,配哨兵退出码 119 的自愈逻辑。
  • 系统 prompt 也是工程产物
    :prompt 里固化着大量 iSH 实战经验——"BusyBox ash 无 globstar"、"PyPI 缺 musllinux wheel 要用 apk add py3-*"、"matplotlib 必须 use('Agg')"、"后台进程必须重定向防 SIGPIPE";"执行纪律"段落的注释直接引用 2026-07-17 的设备日志事故(agent 承诺"我每 1-2 分钟检查一次"然后沉默了——turn 结束后没有任何东西在跑)。
  • file_read 的 80KB 硬上限
    (Android FileReadTool.kt:45-46):注释记录了一次真实 hang——820KB 的 base64 图片内联进 tool_result 后,Compose 的 StaticLayout/LineBreaker 卡了 43 秒。
  • WebApp 快捷方式全部存沙箱相对路径
    WebAppPathResolver.swift:44-70):因为 iOS 重装会轮换容器 UUID,绝对路径全得报废。

十、总结

通读完 OpenMinis 双端约 34 万行代码,我对它的评价可以浓缩成三句话:

第一,它的技术护城河恰恰在 README 说"不重要"的地方。 把 iSH 改造成 ARM64 进程内嵌内核(JIT 崩溃恢复、fd 表直连、fakefs meta.db、fs_context 路由)、把 PRoot 伪装成 libproot.so、把 FFmpeg 驯化成可重入库函数(三层 patch + argv 重写 + 线程局部 stdio)——每一处都是对平台限制的正面硬刚,没有一处能靠"调 API"实现。

第二,Agent 运行时的成熟度是被真实 bug 喂出来的。 孤儿 tool_use 修复、tool_call_id 去重、ToolJsonRepair、四级熔断、静默断流检测、组内 fallback、分级节流梯子……这些防御性代码的存在本身就说明它经历了大规模真实使用。双端"Mirrors iOS 行号"的人肉 parity 文化,也为"如何维护双端行为一致"提供了一个罕见的实战样本。

第三,依赖策略极其克制。 沙箱层用 GPL 重器(iSH/PRoot)并因此全仓 GPLv3,应用层却只用 4 个 SPM 包 + 一组标准 AndroidX/OkHttp 组件,能手写就不引入——手写 SSE、裸调 SQLite、自写 ZIP/tar 解析。这种"关键处重投入、边缘处零依赖"的搭配,让 34 万行的代码库保持了惊人的可构建性(BUILDING.md 声称首次构建 30-60 分钟全脚本化)。

如果你想研究"移动端 AI Agent 到底能做成什么样",这个仓库几乎是当下最好的教材:它把 Agent loop、Linux 沙箱、系统能力集成、双端对齐、流式性能优化这五个难题,在同一个产品里全部给出了解法。


本文基于 2026 年 8 月检出的 OpenMinis 公开镜像源码(Android 侧 versionName 0.20-preview)撰写,所有代码片段均标注了仓库内文件路径与行号,可对照原文核实。文中架构图为笔者根据源码绘制。

这篇文章是我让 Kimi k3 去深度分析源码写出来的。我也想把这篇文章分享给大家,大家一起共同学习这么优秀的开源项目。Kimi K3 真能写~