diff --git a/.github/workflows/opengui-codex-ci.yml b/.github/workflows/opengui-codex-ci.yml
index 1e2d3df..07301a5 100644
--- a/.github/workflows/opengui-codex-ci.yml
+++ b/.github/workflows/opengui-codex-ci.yml
@@ -3,10 +3,12 @@ name: Standalone OpenGUI Codex CI
on:
pull_request:
paths:
+ - 'packages/device-runtime/**'
- 'plugins/opengui/**'
- '.github/workflows/opengui-codex-*.yml'
push:
paths:
+ - 'packages/device-runtime/**'
- 'plugins/opengui/**'
- '.github/workflows/opengui-codex-*.yml'
@@ -33,16 +35,16 @@ jobs:
- name: Copy only the standalone source into an isolated build root
shell: bash
run: |
- cp -R plugins/opengui "$RUNNER_TEMP/opengui"
+ node packages/device-runtime/build.mjs stage codex "$RUNNER_TEMP/opengui-build"
test ! -e "$RUNNER_TEMP/deepseek-harness-plugin"
- name: Install without lifecycle hooks
- working-directory: ${{ runner.temp }}/opengui
+ working-directory: ${{ runner.temp }}/opengui-build/plugins/opengui
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Test and validate the isolated package
- working-directory: ${{ runner.temp }}/opengui
+ working-directory: ${{ runner.temp }}/opengui-build/plugins/opengui
run: pnpm check
- name: Produce the allowlisted upload and archive
- working-directory: ${{ runner.temp }}/opengui
+ working-directory: ${{ runner.temp }}/opengui-build/plugins/opengui
run: |
pnpm package
bash -n scripts/install-macos.command
@@ -50,7 +52,7 @@ jobs:
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: opengui-codex-${{ matrix.os }}
- path: ${{ runner.temp }}/opengui/.artifacts/
+ path: ${{ runner.temp }}/opengui-build/plugins/opengui/.artifacts/
include-hidden-files: true
if-no-files-found: error
retention-days: 7
diff --git a/.github/workflows/opengui-codex-release.yml b/.github/workflows/opengui-codex-release.yml
index b0bb2b5..63cc236 100644
--- a/.github/workflows/opengui-codex-release.yml
+++ b/.github/workflows/opengui-codex-release.yml
@@ -33,9 +33,9 @@ jobs:
with:
node-version: 22.23.2
- name: Copy the independent package
- run: cp -R plugins/opengui "$RUNNER_TEMP/opengui"
+ run: node packages/device-runtime/build.mjs stage codex "$RUNNER_TEMP/opengui-build"
- name: Build and validate without DSH
- working-directory: ${{ runner.temp }}/opengui
+ working-directory: ${{ runner.temp }}/opengui-build/plugins/opengui
run: |
pnpm install --frozen-lockfile --ignore-scripts
pnpm check
@@ -43,7 +43,7 @@ jobs:
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: opengui-codex-release
- path: ${{ runner.temp }}/opengui/.artifacts/
+ path: ${{ runner.temp }}/opengui-build/plugins/opengui/.artifacts/
include-hidden-files: true
if-no-files-found: error
release-draft:
diff --git a/.github/workflows/workbuddy-plugin-ci.yml b/.github/workflows/workbuddy-plugin-ci.yml
index c740dfa..ccf595a 100644
--- a/.github/workflows/workbuddy-plugin-ci.yml
+++ b/.github/workflows/workbuddy-plugin-ci.yml
@@ -3,10 +3,12 @@ name: WorkBuddy Plugin CI
on:
push:
paths:
+ - 'packages/device-runtime/**'
- 'workbuddy-plugin/**'
- '.github/workflows/workbuddy-plugin-*.yml'
pull_request:
paths:
+ - 'packages/device-runtime/**'
- 'workbuddy-plugin/**'
- '.github/workflows/workbuddy-plugin-*.yml'
diff --git a/docs/plans/2026-09-17-codex-workbuddy-runtime-convergence.zh-CN.md b/docs/plans/2026-09-17-codex-workbuddy-runtime-convergence.zh-CN.md
new file mode 100644
index 0000000..b6c127c
--- /dev/null
+++ b/docs/plans/2026-09-17-codex-workbuddy-runtime-convergence.zh-CN.md
@@ -0,0 +1,301 @@
+# Codex / WorkBuddy 执行内核收敛方案
+
+日期:2026-09-17。状态:供实施的设计方案,尚未实施,也未通过新版本验收。
+
+## 1. 决策与交付目标
+
+采用 **一份设备执行内核源码、两个宿主适配层、两个独立安装包和运行实例**。
+
+Codex、WorkBuddy 都保留“聊天提出任务 → 展示手机 → 宿主模型看图决策 → OpenGUI 执行一个动作 → 返回新截图 → 验证结果”的使用方式。共享设备发现、动作约束、执行队列、会话资源管理、实时画面和错误语义。安装、权限、模型调用、任务生命周期、图片呈现由各宿主负责。
+
+用户只安装对应宿主的插件;不增加公共服务安装步骤、模型 API Key、账号或配置项。两端可各自升级和回滚。本轮不把两个后台进程合成一个全局后台服务。
+
+完成标准:
+
+1. 两端的共用设备执行代码只有一个人工维护来源;修复公共规则时,同一套契约测试验证两个消费者。
+2. 两端现有入口和安装方式继续可用;安装包脱离源码仓库仍能独立运行。
+3. 实际宿主中完成设备展示、看图执行、停止、清理及重新开始;分别保留可核验的验收证据。
+4. WorkBuddy 新版安装无需默认退出宿主;能区分配置写入、MCP 工具可见、Hook 生效、设备可用四个状态。
+5. 共用代码有可追溯的来源版本;不会因升级某一端而改写另一端或 DSH 的配置、进程、安装目录。
+
+### 范围
+
+纳入:Codex 独立插件、WorkBuddy MCP + Skill + Hook、本地 Android ADB/scrcpy 执行与实时查看器、构建发布和验收。
+
+本轮不纳入:DSH 迁移,豆包工作/千问工作的接入实现,新的 Web 工作台,独立模型规划服务,Artemis 集成,UIAutomator2/A11y 树推理,云设备调度,全量录屏回放和跨宿主任务接力。它们不能成为本轮完成的前置条件。
+
+**一个明确的支持边界:本轮不支持两个宿主同时控制同一台手机。** 当前设备锁在各自进程内;共享源码不会自动提供跨进程互斥。两端同时使用的验收场景是各控不同设备;切换同一设备时先结束原宿主任务。DSH 或其他 ADB 客户端同样不受该进程内锁约束。若必须支持同机并发争用,应另立跨宿主设备仲裁方案,不能给当前方案补一句“共享锁”就宣称解决。
+
+## 2. 代码依据与现状
+
+本方案按已刷新远端的 `origin/main` 编写,审阅基线为 `737c6255c893a2c6a5779866f60f2bfee6efca3c`。当前本地工作分支不是该基线;实施应从最新 main 建立 `codex/` 工作分支,保留现有未提交研究文件。
+
+| 项目 | Codex | WorkBuddy | 收敛处理 |
+|---|---|---|---|
+| 包版本 / IPC 协议 | 0.2.0 / 3 | 0.3.1 / 8 | 继续分别版本化,禁止强行统一数字 |
+| 宿主入口 | Skill → CLI → Unix socket daemon | Skill/MCP stdio → 本地 TCP broker,Hook 提供任务上下文 | 保留 |
+| 模型与任务推进 | Codex 宿主 | WorkBuddy 宿主,自动续跑由 Hook 协作 | 不新增模型客户端或自己的第二套 Agent 循环 |
+| 图片交付 | 本地受限权限 JPEG 文件 | MCP image 内容 + 结构化元数据 | 适配器转换,同一内部观察结构 |
+| 生命周期 | thread owner,短命 CLI 请求,daemon 常驻 | MCP 连接归属 + root task / agent 上下文 | 共用资源机制,保留宿主事件解释 |
+| 观察/会话默认时限 | `SESSION_IDLE_MS` 为 30 分钟 | 控制租约默认 10 分钟 | 先保留实际语义,用明确策略参数注入 |
+| 图片实现 | macOS `sips` | `sharp`,含像素采样 | 留在适配器;公共模块不依赖 sharp |
+| 构建 | tsc 检查 + tsdown 0.22.2 打包 | tsc 从 src 发射 lib | WorkBuddy 改为检查 + 打包,保持现有 lib 入口 |
+
+依据:[Codex 源码目录](https://github.com/Core-Mate/OpenGUI/tree/737c6255c893a2c6a5779866f60f2bfee6efca3c/plugins/opengui/src)、[WorkBuddy 源码目录](https://github.com/Core-Mate/OpenGUI/tree/737c6255c893a2c6a5779866f60f2bfee6efca3c/workbuddy-plugin/src)、两端的 `package.json`、`scripts/validate.mjs`、CI 和发布工作流。
+
+两端有 12 个同名基础模块,其中 `viewer.ts`、`viewer-page.ts`、`websocket.ts` 三个文件在该基线逐字节相同,适合首先提取。`phone-controller.ts`、`forward-registry.ts` 和会话服务已有行为差异,不能选择一端直接覆盖另一端。
+
+需要保留的现有差异:
+
+- WorkBuddy 已有执行前画面重采样、目标区域变化检查、执行后稳定帧等待、断连 epoch 和完成证据检查。Codex 不具备完全相同的像素保护,不能宣称已等价。
+- Codex 对部分外部副作用有既有显式确认流程;WorkBuddy 遵循宿主授权,并且 Hook 上下文只证明任务归属,不代表用户授权。
+- WorkBuddy 没收到 Hook 上下文时会报告 `automation.available=false`;这不等于所有基础 MCP 工具都不可用。已经绑定 Hook 的任务调用缺少上下文则必须拒绝。
+- Codex 当前部分显式 status 调用会更新活动时间;WorkBuddy 的状态轮询不续租。第一轮提取必须通过策略保留,不能在重构中悄悄改变。
+
+### 与旧规则的关系
+
+`docs/plans/simple-plugin-install.md` 的“不共享运行时、配置、版本或设备服务”和 `plugins/opengui/SOURCE.md` 的独立维护边界需同步澄清:本方案引入**构建时共享源码**,因此调整原先“源码只在宿主目录内”的约束;继续保留运行时、配置、版本、设备服务的实例隔离及独立安装要求。
+
+现有验证器禁止越出宿主源码树的 import,CI 也只复制一个宿主目录;这两处需要改为允许且仅允许 `packages/device-runtime` 构建输入,同时加强成品脱仓运行验证。不得直接删除隔离检查。
+
+根 `CLAUDE.md` 的公共发布边界、GUI/vision-first 和禁止恢复旧 A11y 推理路径继续有效;`server/`、`client/`、`deepseek-harness-plugin/` 不在修改范围。
+
+## 3. 目标架构与职责
+
+```text
+ 同一份 packages/device-runtime 源码
+ / 构建打入 \ 构建打入
+ v v
+Codex 宿主模型/任务 WorkBuddy 宿主模型/任务
+ | |
+Skill + CLI + 图片文件适配 Skill + MCP + Hook 适配
+ | |
+Codex daemon WorkBuddy broker
+ 内核实例 A 内核实例 B
+ 独立状态/租约/资源 独立状态/租约/资源
+ | |
+ADB / scrcpy + 只读 Viewer ADB / scrcpy + 只读 Viewer
+ | |
+ 设备 A 设备 B
+```
+
+模型调用发生在宿主里。公共内核不读取模型配置、不保存 Key、不自行向远程模型发送截图。截图如何进入模型上下文仍由宿主决定;“本地执行器”不代表宿主模型离线运行。
+
+### 公共内核
+
+目录:`packages/device-runtime/`,作为仓库内部源码组件,不发布 npm 包,不给用户增加第三个安装件。保留提取文件的版权和已有 MIT/VIDEO-NOTICE 来源说明,在公共目录记录来源;两端成品继续携带原有声明。
+
+| 模块 | 内容 | 不接管的内容 |
+|---|---|---|
+| `src/viewer.ts`、`viewer-page.ts`、`websocket.ts` | 只读页面、解码首帧凭证、token、流量边界 | 宿主打开面板的方法 |
+| `src/actions.ts`、`device-fleet.ts` | 动作校验、坐标映射、发现结果解析、固定目标 | 宿主安装目录和 ADB 二进制定位 |
+| `src/concurrency.ts`、`phone-execution.ts`、`phone-controller.ts` | 单设备动作队列、观察凭证、预算、防止重复执行 | 宿主模型判断和权限弹窗 |
+| `src/errors.ts`、`frame-comparison.ts` | 有类型的执行结果、像素样本比较规则 | 图片解码库、MCP/CLI 序列化 |
+| `src/scrcpy-stream.ts`、`forward-registry.ts` | 视频协议、订阅生命周期、精确资源清理 | 下载器、安装路径、宿主私有缓存 |
+| `src/session-runtime.ts`、`contracts.ts` | owner、session、actor、任务资源归属与释放 | WorkBuddy Hook token 与续跑决策、Codex thread 识别 |
+
+不为每个类再包装一个接口。只有实际存在宿主差异的边界才注入:ADB 执行与二进制定位、截图编码/可选像素采样、Unicode 通道、scrcpy 资源准备、宿主会话策略。目录中不得 import 任一宿主目录、MCP SDK、sharp 或 DSH。
+
+设备端 scrcpy 文件名继续区分 `opengui-codex-*` 与 `opengui-workbuddy-*`;通过资源配置传入公共流实现。forward 注册和删除必须精确到本实例拥有的设备与端口,禁止 `adb kill-server` 或全局移除转发。
+
+### 宿主适配层
+
+Codex 保留 `cli.ts`、`daemon.ts`、`state.ts`、`confirmation.ts`、`codex/screenshot.ts`、安装器和 Skill。`codex/service.ts` 逐步缩为参数映射、策略注入、图片交付及旧接口兼容。
+
+WorkBuddy 保留 `mcp.ts`、`mcp-server.ts`、`broker*.ts`、`wire.ts`、`host-hook.ts`、`automation.ts`、`installation.ts`、`state.ts`、图片编码、原生窗口与旧 mirror 入口。`service.ts` 逐步缩为策略和兼容层。
+
+旧的同名共用文件迁移后可短期保留纯 re-export,以兼容脚本和测试;不能继续保留第二份业务实现。适配器不能复制预算、观察有效性或资源所有权算法。
+
+## 4. 契约与兼容决策
+
+### 4.1 内部数据
+
+所有公共类型只在 `contracts.ts` 定义;这里描述字段约束,不改变宿主已有工具名。
+
+| 对象 | 必需内容和约束 |
+|---|---|
+| 执行归属 | 宿主类型、宿主 owner、任务归属、session、actor、固定 device;由适配器从可信通道构造,不能相信模型自由填写的 owner |
+| 观察 | `observationId`、设备/actor 归属、尺寸、时间、foreground、JPEG 字节与元数据;可携带像素样本与 settled 状态 |
+| 动作 | 沿用现有 tap/swipe/text/key/launch/wait 的校验规则;修改动作必须引用当前观察,禁止裸 shell 或任意 ADB 命令 |
+| 执行结果 | `not_executed`、`executed`、`outcome_unknown`;一次动作成功不等于任务完成 |
+| 错误 | `code`、`message`、`executionState`、`recovery`;恢复建议限 observe/reconnect/wait/replan/stop |
+| 会话关闭 | 资源状态与任务结果分离;兼容 Codex 普通 close 和 WorkBuddy 带 outcome/evidence 的 close |
+
+保持 WorkBuddy 的现有 `screen_changed`、`completion_unverified` 等错误。Codex 可以获得公共内核的类型化错误;其原有外层返回格式继续存在,不要求旧消费者改名。适配器外的逻辑不得依赖解析错误文案。
+
+MCP 继续返回真正的 image 内容、结构化元数据,并通过 `isError` 表示工具执行失败;不要把图片降为模型不可见的路径字符串。Codex 继续生成本地图片文件,并使用其现有呈现方式。这样符合 [MCP 内容与错误结构](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2025-06-18/schema.mdx);本项目不因此升级已锁定的 SDK 1.29.0。
+
+### 4.2 不可退化的规则
+
+1. **归属**:owner/session/device 精确匹配;不同 thread/task 的调用、晚到取消和清理不得影响新的持有者。
+2. **顺序**:同 actor 的动作串行;不同设备允许并行;停止后不再下发排队动作,已发出的动作不能伪装成未执行。
+3. **观察**:旧观察、跨设备观察、断连后的旧观察都不能驱动动作。发生不确定结果后先观察,不自动重放点击、文本等修改动作。
+4. **预算**:保留单设备操作预算和无进展限制;WorkBuddy 重开同一任务的 session 不重置任务预算,Hook 续跑最多 10 次。
+5. **观看与控制**:观看不给控制权限;首次控制前必须收到用户可见的真实解码首帧。准备视频依赖完成后至多等一次 30 秒;`display_timeout` 对当前任务为终止失败,不能重建会话绕过。
+6. **首帧之后**:用户最小化/关闭观看页面不自动停止已建立的任务;任务结束不替用户关闭查看器;新任务不能继承旧任务的首帧授权。
+7. **释放**:先标记终止、阻止新动作并 abort,再等待本任务在途操作结束,最后释放精确匹配的锁和资源。状态查询不应意外执行动作。
+8. **未知状态**:断连、超时发生在 dispatch 后时返回 `outcome_unknown`;没有后续观察证据不能宣称执行失败或完成。
+
+### 4.3 有意保留的策略差异
+
+| 策略 | Codex | WorkBuddy |
+|---|---|---|
+| owner 来源 | 既有 `CODEX_THREAD_ID` 与 daemon 请求校验 | 连接 owner;有 Hook 时绑定 root task / agent |
+| 短连接结束 | 正常 CLI 结束不取消已建立任务;请求中断按原行为取消 | MCP 长连接退出清理它拥有的任务资源 |
+| 租约与续期 | 保留 30 分钟与既有显式 status 活动语义 | 保留 10 分钟,状态/Viewer 轮询不续租 |
+| 外部副作用 | 保留现有确认路径 | 保留宿主授权边界;Hook 不能代替授权 |
+| 图像重采样 | 本轮保留现有能力;不新增 sharp/原生 helper | 保留 sharp 像素检查、目标区域复核、稳定帧等待 |
+| 任务完成证据 | close 不自动提升为“完成”;最终图由宿主核验 | completed 必须引用各设备最新有效观察 |
+| 旧只读入口 | `mode=observe` | `purpose=mirror` 与镜像恢复能力 |
+
+公共控制器提供像素样本/执行前检查的策略注入点,WorkBuddy 现有保护搬入共用算法并保持启用;Codex 不支持的像素采样明确标记为无此能力,不能返回伪造的“画面未变化”。基础契约两端通测,增强视觉契约对 WorkBuddy 单独验收。本轮的“收敛”是消除重复实现和统一基础语义,不是抹掉这些行为差异。
+
+## 5. 构建、隔离与来源追溯
+
+**选用构建时打包共享 TS 源码。** Codex 沿用 tsdown;WorkBuddy 增加与 Codex 相同且锁定版本的 tsdown 0.22.2 开发依赖。运行时依赖集合不增加。保留各自的 pnpm/npm,不做根目录 monorepo 包管理迁移。
+
+WorkBuddy 的 `rootDir: src` 与直接导入共享源码冲突,不能只增加一个 TS alias。[TypeScript rootDir 文档](https://www.typescriptlang.org/tsconfig/rootDir.html)解释了发射路径约束;[project references](https://www.typescriptlang.org/docs/handbook/project-references)解决项目构建关系,也不自动让发布包包含公共源码。具体调整如下:
+
+1. 两端源码通过明确相对路径导入 `packages/device-runtime/src`。公共目录只依赖 Node 内置模块及内部文件。
+2. WorkBuddy tsc 改为 `--noEmit` 检查,移除发射用 rootDir/outDir/declaration 设置,允许 TypeScript 扩展导入;由 tsdown 产出 lib,再执行已有 finalize。
+3. WorkBuddy 现有顶层 src 模块全部作为具名构建入口,维持 `lib/mcp.js`、`broker-main.js`、`host-hook.js`、`automation.js`、`installation.js`、`tools.js`、`state.js` 等现有路径和导出;公共部分由 ESM shared chunks 复用,避免多个 OpenGuiError 类副本影响 instanceof。
+4. MCP SDK、sharp、ajv、tar、yauzl 继续作为外部 npm 依赖,保留 sharp 原生资源的安装方式。shebang、可执行位、Skill 复制、macOS helper 输出和相对 URL 资源定位分别验证。内核不使用 `import.meta.url` 推断宿主资源位置。
+5. Codex 保持单入口、无运行时 npm 安装依赖的现有成品形态。共同代码打入 `lib/cli.js`,不把 WorkBuddy 依赖带进去。
+6. WorkBuddy 本轮不再把 tsc 附带的 `.d.ts` 当作发布契约;该包是 MCP 可执行分发,并非公开 TS SDK。所有现有 JS 入口及脚本导入路径必须保持,发布检查不得依赖丢失的声明文件。
+7. CI 隔离构建目录只复制该宿主目录和 `packages/device-runtime`,保持仓库相对布局;不能靠整仓依赖目录使构建“偶然成功”。产物再移入另一处空目录、移除源码访问后做 smoke。
+8. 调整 import 校验为精确解析目标白名单,拒绝任何其他越界、符号链接逃逸、DSH 路径和私人路径;同时扫描生成 JS 的未解析相对 import。
+
+Viewer 提取时先将 `VideoDevice`、`ScrcpyStreamSink` 等纯类型放入公共 `contracts.ts`,让公共 Viewer 不反向 import 任一宿主的流实现。Codex 浏览器测试当前也有 `rootDir` 限制:将其测试构建改为单独的 `tsdown.viewer.config.ts`,输出 `.artifacts/browser/viewer.js` 并更新现有 `test:viewer` 参数;保持浏览器测试命令不变,避免为了测试重新复制一份 Viewer。
+
+在各包 `lib/runtime-manifest.json` 写入构建来源:宿主名、包版本、来源 commit、公共契约版本、公共源码 digest。digest 按公共目录相对路径排序,对路径与文件字节计算 SHA-256;不含时间戳,不记录机器绝对路径。来源 commit 在复制到隔离构建目录前从原 checkout 生成,随构建输入传递;发布构建缺少来源时直接失败,不在脱仓目录猜测 Git 信息。Codex stage allowlist 明确加入此文件,WorkBuddy lib 随包带入。
+
+共同内核不单独滚动更新。每个宿主版本锁定构建时内核;回滚该宿主包即回滚它的内核。IPC 协议号仅在该宿主线协议实际发生不兼容时增加。
+
+## 6. Skill 与热安装体验
+
+两端保留各自 Skill 文件,因为工具名、图片呈现和生命周期确有差异。共用契约测试检查它们都表达:先可见首帧、一次一个动作、结果不明不重放、观察后结束任务、控制结束不关闭用户画面。不要把一份 WorkBuddy 操作指南原样安装到 Codex。
+
+GitHub 安装 Skill 继续是给用户粘贴的入口,自动选择宿主安装器及可验证版本。版本号、包链接、checksum 和适配器元数据由同一次发布更新,不能再把“安装器运行成功”写成“手机已可控制”。
+
+WorkBuddy 安装流程固定为:
+
+1. 预检宿主版本、选中的产品配置路径与写入条件。
+2. 5.5.6+ 走已有 live install 路径,先 `LIVE_PREFLIGHT_OK`,成功写入后 `LIVE_CONFIG_WRITTEN`;备份和原子更新沿用现有安装器。
+3. 在宿主内通过 `/hooks` 审阅并应用变化、通过 `/skills` 确认可见;验证 MCP 工具发现和 Hook 任务上下文。只有当前任务没有刷新时再新建任务。
+4. 旧兼容版本按 `HOST_RESTART_REQUIRED` 给出重启步骤;不能一概要求退出,也不能一概承诺热加载。
+5. 插件二进制升级与正在执行的任务分开处理:活动任务期间拒绝强行切换该实例;提示结束任务再重试。没有活动任务时只更新/重连本宿主拥有的后台进程。首版不做在途任务跨版本迁移。
+6. 最后做设备只读发现与首帧检查;手机 USB 授权仍由用户在设备端完成。
+
+Codex 不套用 WorkBuddy 的 Hook/热发现机制。沿用自己的安装入口与宿主刷新方式;源码热更新、宿主配置刷新、后台进程重启是三件事。验收报告逐项写明实测宿主版本与加载方式。
+
+## 7. 分阶段实施与交付
+
+预计 **4 个独立可合并 PR、约 10–15 个工程工作日**,是单人实施与复核的估算,不包含等待真机、宿主版本或 CI 下载恢复的时间。整体会触及超过 8 个文件,预计 35–50 个源码、测试、构建和文档文件;不是一次机械搬目录的小改动。
+
+| PR | 具体范围 | 本 PR 独立交付与门禁 | 估算 |
+|---|---|---|---|
+| 1:共享 Viewer 与构建 | 提取 3 个相同模块;引入公共目录;WorkBuddy 打包改造;manifest;CI 路径和隔离检查;更新 SOURCE/旧安装边界说明 | 两端功能保持原状,共用 Viewer 已生效;双方构建、打包、脱仓启动、浏览器首帧测试通过 | 2–3 天 |
+| 2:共用动作执行 | 提取动作/设备/并发/执行状态/错误;控制器注入编码及可选视觉策略;公共采样比较逻辑;保持宿主输入输出 | 两端完成 observe→act→observe,WB 增强视觉保护不退化;停止、不确定结果、旧观察、预算测试通过 | 3–4 天 |
+| 3:共用资源生命周期 | 提取 session-runtime、视频流与 owned forward 清理;两端 service 改为适配;保留 Hook/daemon 生命周期策略 | 两端不同设备同时运行;一端退出/超时/回滚不清理另一端;新任务与旧回调隔离 | 3–4 天 |
+| 4:安装、Skill 与候选验收 | 更新两端 Skill 和安装说明;完成活动任务升级检查;为两端同一公共 commit 产出候选包;运行真实宿主矩阵并记录证据 | 从 GitHub 成品安装、热配置/重启路径、可见首帧、控制、停止、卸载/回滚证据齐全后才准入稳定发布 | 2–4 天 |
+
+每个 PR 合并后两端都必须可用;不能靠下一个 PR 修复构建或入口。每个 PR 都运行双方核心检查,产出可安装候选,不在 PR 4 才首次打包。PR 4 即使受真机门禁阻塞,前面代码仍可保持预发布状态。
+
+### 文件落点
+
+- 公共源码及测试:`packages/device-runtime/src/`、`packages/device-runtime/tests/`、`packages/device-runtime/README.md`。
+- Codex:`plugins/opengui/src/` 中上述共用模块及 `codex/service.ts`;`tsdown.config.ts`、新增 `tsdown.viewer.config.ts` 并替代 `tsconfig.browser.json` 的测试发射用途;`scripts/validate.mjs`、`scripts/stage.mjs`、`scripts/package.mjs`;`SOURCE.md`、Skill、README。
+- WorkBuddy:`workbuddy-plugin/src/` 中上述共用模块及 `service.ts`;新增 `tsdown.config.ts`;调整 `package.json`/lock/tsconfig;保留并检查 `scripts/finalize.mjs`、`validate.mjs`、`package.mjs`、`smoke-packed.mjs` 和安装器;调整 connector Skill。
+- CI:`.github/workflows/opengui-codex-{ci,release}.yml`、`.github/workflows/workbuddy-plugin-{ci,release}.yml`;公共目录变化必须触发双方 CI。
+- 发布证据:沿用各宿主现有 readiness 文件及 `docs/plans/2026-09-13-viewer-candidate-acceptance.md` 的门禁;新增本次验收记录,不能把历史 verified 直接搬到新版本。
+
+公共测试用例通过两个宿主的 Vitest 配置各运行一次,不新增第三套测试依赖树。测试夹具使用可注入时间、虚拟 ADB、合成图片和流;核心夹具不得访问用户真实设备。
+
+## 8. 验收矩阵
+
+| 层级 | 必测路径 | 通过条件 |
+|---|---|---|
+| 契约 | 正常 observe/act;多设备;跨 owner;旧 observation;旋转/尺寸变化;重复动作/预算;取消排队动作 | 期望动作精确执行一次,非法请求零 dispatch,错误分类正确 |
+| 执行失败 | 发送前失败;发送后超时/断连;截图失败;Unicode 部分失败;重连 | 不确定结果不重放;旧观察失效;重新观察后才可继续 |
+| WB 增强视觉 | 点击目标改变、前台应用改变、画面未稳定、像素采样失败 | 保留 `screen_changed` 与 settled 语义,不能静默关闭检查 |
+| 会话/资源 | 旧任务晚到清理、重复 close、lease 到期、进程退出、socket 中断、两端不同设备并行 | 精确释放本 owner;无孤儿子进程/forward;另一端继续工作 |
+| Viewer | 可见真首帧、只有 WS 没画面、解码失败、30 秒超时、关闭/隐藏、换任务 | firstDisplay 由真实显示确认;终止失败不可绕过;生命周期符合第 4 节 |
+| 成品 | 空目录安装、只读发现、无源码/父目录依赖、缓存后离线启动、manifest/checksum | 成品完整;离线只指依赖已缓存,不宣称首次离线安装 |
+| WorkBuddy 宿主 | 新安装、0.3.1 升级、5.5.6+ live、旧版 restart、Hook 缺失/恢复、停止/续跑 | Skill/MCP/Hook 各自可见且语义正确;基础工具可用不冒充自动续跑可用 |
+| Codex 宿主 | 已安装 Skill、CLI 进程退出、请求中断、跨 thread、新任务、图片文件呈现 | 真图进入当前模型上下文;任务隔离;正常短请求不误取消任务 |
+| 用户任务 | 测试设备打开设置只读查看、主页无害滑动并恢复、测试输入框中英文输入后清空 | 用户看到过程;最终图验证目标;无发送/购买/发布等额外副作用 |
+| 发布 | 安装下载、包哈希、真实运行、回滚、另一端配置未变 | commit、包、宿主版本、设备和证据一一对应 |
+
+设备测试先使用专用模拟器;真机阶段使用明确授权的测试设备。**至少两台物理设备的冲突/并行、30 分钟视频稳定性和实际宿主中的动态画面**仍按已有 Viewer 门禁验收;历史合成流结果不充当本版本真机证据。既有动态画面目标为至少 24fps、端到端 P95 延迟不高于 500ms,测量必须包含源事件到实际显示,不拿 WS 到达间隔替代。
+
+macOS 是本轮用户控制体验的交付平台。WorkBuddy 的 Linux/Windows 打包启动 smoke 继续保留;其通过不等于完整桌面控制体验已获支持。国内/海外产品配置路径和版本分别记录;未实测的组合明确标为未验收。
+
+### 实施后的验证命令
+
+以下从仓库根目录执行;本方案没有实际运行这些产品测试。
+
+```bash
+rtk proxy pnpm --dir plugins/opengui check
+rtk proxy pnpm --dir plugins/opengui package
+rtk proxy pnpm --dir plugins/opengui test:viewer
+rtk proxy npm --prefix workbuddy-plugin run check
+rtk proxy npm --prefix workbuddy-plugin run pack:release
+rtk proxy npm --prefix workbuddy-plugin run smoke:packed
+rtk proxy npm --prefix workbuddy-plugin run test:viewer
+rtk proxy npm --prefix workbuddy-plugin run test:browser
+rtk proxy npm --prefix workbuddy-plugin run test:native
+rtk proxy git diff --check
+```
+
+`test:native` 在 macOS 执行。依赖安装使用各自 lockfile 和禁用 lifecycle 的既有 CI 流程;不要为了跑打包 smoke 接管用户默认 ADB server。WorkBuddy 已有测试专属 ADB 端口/空发现适配器,必须继续使用。
+
+真实宿主结果另外记入验收记录:来源 commit、宿主版本、插件版本、公共 digest、用例、开始/结束时间、结果、证据路径。截图/录屏仅用于已授权测试,公开记录不得包含私人屏幕、设备序列号、token URL 或任务文本。
+
+## 9. 发布、升级与回滚
+
+1. 各 PR 经双方检查后合并;公共变化的候选成品必须来自同一提交,并验证两份 manifest 的公共 digest 相同。
+2. 版本分别递增、沿用 `opengui-codex-v*` / `opengui-workbuddy-v*` tag 体系。正式候选发包时以 main 的最新版本决定下一个 minor,不沿用本文基线版本硬编码发布,禁止覆盖旧 tag/附件。
+3. 先预发布。GitHub 发布后重新下载资产并核对 checksum,再从下载包安装验收;本地 dist 成功不能代替 GitHub 资产成功。
+4. 稳定发布须两端各自门禁齐全;任一宿主缺真机/首帧/停止/回滚证据时,该宿主保持 prerelease。某一端已经通过不要求另一端跟着升级。
+5. 安装器保留原配置备份和独立版本目录。升级前检查该实例的活动任务;有活动任务则停止切换,保留运行版本。依赖下载/校验失败也保留原配置,不自动放宽校验或无上限重试。
+6. 回滚先结束目标宿主活动任务,再恢复它自己的上一版本与配置备份,重连该宿主运行实例;不复用新版本在途 session/observation,不操作另一端配置或 DSH。
+7. 核心状态继续为进程内态,不引入数据库迁移。回滚无需数据转换;正在执行的手机动作不可事务回滚,因此不能把“回滚插件”写成“撤销手机操作”。
+
+## 10. 成本、风险与取舍
+
+**最小方案**是只同步修订两端 Skill、保留两份代码,再加一组行为对照测试,约 1–2 天。它能修正文案和暴露差异,但以后动作、视频和清理问题仍需修两遍。本方案先从三个完全一致文件开始,逐步减少这个维护风险。
+
+更重的方案是一个跨宿主 daemon + 一个任务 API,能进一步统一设备仲裁和任务状态,但会新增共享安装、全局升级、授权隔离、单点故障和旧版本协议协商。本轮不选择它;用户不必承担这些部署成本。
+
+**最脆弱的假设**:两个宿主的差异主要能通过现有生命周期与图片交付边界隔离。如果必须在公共内核到处判断宿主名字,这个假设就不成立。应保留该部分宿主实现,仅提取已有共同机制;不要为了提高共用比例牺牲已验证行为。第 1 个 PR 的独立 Viewer 提取即使后续停止,仍有维护收益。
+
+| 风险 | 处理与停止条件 |
+|---|---|
+| WB 打包改变动态文件定位或 native 依赖 | 保持全部原 JS 入口,外置 native 依赖,成品 smoke 必须包含 Hook/broker/installer,不只 `--help` |
+| 公共 Bug 同时影响两端 | 同一契约双方运行;分别固定成品版本;先候选再发布,允许独立回滚 |
+| 为了代码统一削弱 WB 保护 | 增强视觉与完成证据专项测试是阻断门禁;不通过则不迁移该模块 |
+| Codex 新增截图/比较延迟 | 本轮不添加其缺少的像素采样;记录 observe/act P50/P95,相同夹具下 P95 超过基线 20% 时阻止发布并定位 |
+| 依赖网络不可达 | 下载失败终止升级、保留旧版;已缓存启动单独验证,禁止把 offline-cache 说成完全离线 |
+| 多设备负载扩大 | 保留当前最多 4 设备、并发媒体限制与背压;不因抽公共内核宣称支持更多设备 |
+| 跨宿主同机争用 | 明确不支持,不声称进程内锁是全局保护;各控不同设备作为交付边界 |
+
+### 新增维护面清单
+
+| 类别 | 增量 | 维护责任与回滚成本 |
+|---|---|---|
+| 用户命令、env、账号、API Key、配置页面 | +0 | 用户沿用原入口;没有新增凭据要求 |
+| 常驻服务、端口、数据库 | +0 | 保留现有两端 daemon/broker,不引入第三个服务 |
+| 内部源码组件 | +1:device-runtime | 插件维护者负责;随消费者 PR 审查,回滚对应包即可 |
+| 构建开发依赖 | WB +1:tsdown,版本与 Codex 对齐 | 构建维护者负责 native/入口检查;不进入用户运行依赖 |
+| 成品元数据 | 每包 +1:runtime-manifest.json | 构建生成、安装诊断读取;无用户配置和运行状态迁移 |
+| 公共内部契约 | +1:contracts.ts | 配对适配器测试约束;不作为第三方可独立升级的公共 SDK |
+
+除现有 Node、ADB、scrcpy、GitHub 分发与宿主工具链外不依赖新服务。本轮已核对现有源码与 GitHub main,可用性验证不涵盖未来发布时的下载端点、两端实际宿主和 USB 设备;这些列为实施/发布的明确门禁,不能表述为已通过。
+
+## 11. 给后续宿主留下的边界
+
+DSH、豆包工作、千问工作将来应复用公共执行契约,分别实现工具发现、真实图片交付、可信任务身份、取消/结束信号、用户授权和安装更新适配。当前没有证据证明所有宿主都具备这些能力,因此不宣传“装同一个 Skill 就全部支持”。
+
+本轮只需保证内核不引用 Codex/WorkBuddy 私有配置或模型 API;不提前创建空适配器、不增加宿主注册中心。未来接入以能力验收为依据,避免把今天两端的特殊 Hook 机制变成所有产品的强制要求。
+
+实施入口:按 PR 1 → PR 2 → PR 3 → PR 4 顺序推进。每一步的评审重点分别是成品独立性、执行语义、生命周期隔离、实际安装与使用证据;以完成上述交付目标为终点,不以“代码已移动”作为收敛完成。
diff --git a/docs/plans/simple-plugin-install.md b/docs/plans/simple-plugin-install.md
index 72034f7..687939e 100644
--- a/docs/plans/simple-plugin-install.md
+++ b/docs/plans/simple-plugin-install.md
@@ -23,3 +23,8 @@
- `workbuddy-plugin`: `npm run pack:release`、`npm run smoke:packed`、使用官方 Node 发行包执行 `node scripts/test-release-installer.mjs`(该测试需要发行包内的 npm)。
- 安装 Skill:Skill Creator 的 `quick_validate.py skills/opengui-plugin-install`。
- macOS 人工安装验收:使用临时 HOME / CODEX_HOME 和真实 Codex CLI 验证首装、重复安装;WorkBuddy 使用空 HOME 和不含 Node 的 PATH 验证私有运行时下载及三个配置入口。宿主桌面和真实手机验收仍沿用原发布清单。
+
+
+2026-09-17 implementation update: Codex and WorkBuddy share build-time source in
+`packages/device-runtime`; runtime instances, configuration, versions, device
+services and rollback remain independent.
diff --git a/packages/device-runtime/LICENSE b/packages/device-runtime/LICENSE
new file mode 100644
index 0000000..c1f7a78
--- /dev/null
+++ b/packages/device-runtime/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 DeepSeek
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/device-runtime/README.md b/packages/device-runtime/README.md
new file mode 100644
index 0000000..4931315
--- /dev/null
+++ b/packages/device-runtime/README.md
@@ -0,0 +1,12 @@
+# Device runtime
+
+Shared build-time source for the standalone Codex and WorkBuddy packages. This is
+not a separately installed service or npm package. Host runtimes retain their own
+state, processes, configuration, versions and rollback boundaries.
+
+Source: extracted from the two adapters at 737c6255c893a2c6a5779866f60f2bfee6efca3c.
+Preserve LICENSE and VIDEO-NOTICE.md in consuming distributions.
+
+The core may import Node built-ins and its own source only. Host resources,
+codecs, model decisions and lifecycle events belong to the adapters. Device locks
+are per instance, not a machine-wide guarantee across hosts.
diff --git a/packages/device-runtime/VIDEO-NOTICE.md b/packages/device-runtime/VIDEO-NOTICE.md
new file mode 100644
index 0000000..68b6ae8
--- /dev/null
+++ b/packages/device-runtime/VIDEO-NOTICE.md
@@ -0,0 +1,34 @@
+# Video implementation provenance
+
+The scrcpy stream parser/transport and Annex-B decoding were adapted from
+`deepseek-harness-plugin/src/scrcpy-stream.ts`, its WebSocket transport and its
+browser decoder in this repository. That subtree supplies the MIT license below.
+Each host builds and runs its own copy; there is no runtime dependency on DSH.
+
+scrcpy 4.1 is by Genymobile and contributors under Apache-2.0. The installer
+retrieves the pinned official distribution and verifies its checksum. Its license
+is retained in that distribution: https://github.com/Genymobile/scrcpy/blob/v4.1/LICENSE.
+
+## Upstream subtree license
+
+MIT License
+
+Copyright (c) 2026 DeepSeek
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/device-runtime/build.mjs b/packages/device-runtime/build.mjs
new file mode 100644
index 0000000..b6fe63c
--- /dev/null
+++ b/packages/device-runtime/build.mjs
@@ -0,0 +1,114 @@
+import assert from 'node:assert/strict'
+import { createHash } from 'node:crypto'
+import { execFileSync } from 'node:child_process'
+import { cp, mkdir, readFile, readdir, realpath, writeFile } from 'node:fs/promises'
+import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const core = dirname(fileURLToPath(import.meta.url))
+const repository = resolve(core, '../..')
+const inside = (root, path) => path === root || path.startsWith(root + sep)
+
+async function files(root) {
+ const result = []
+ for (const entry of await readdir(root, { withFileTypes: true })) {
+ const path = resolve(root, entry.name)
+ assert(!entry.isSymbolicLink(), `Symlink in build input: ${entry.name}`)
+ if (entry.isDirectory()) result.push(...await files(path))
+ else result.push(path)
+ }
+ return result.sort()
+}
+
+async function digest() {
+ const hash = createHash('sha256')
+ for (const path of await files(resolve(core, 'src'))) {
+ const data = await readFile(path)
+ hash.update(relative(core, path).split(sep).join('/')).update('\0')
+ hash.update(String(data.length)).update('\0').update(data)
+ }
+ return hash.digest('hex')
+}
+
+async function sourceCommit() {
+ try {
+ const metadata = JSON.parse(await readFile(resolve(core, 'build-source.json'), 'utf8'))
+ assert.match(metadata.sourceCommit, /^[a-f0-9]{40}$/)
+ return metadata.sourceCommit
+ } catch (error) {
+ if (error.code !== 'ENOENT') throw error
+ }
+ return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repository, encoding: 'utf8' }).trim()
+}
+
+export async function validateSourceBoundary(hostRoot) {
+ const host = await realpath(resolve(hostRoot, 'src'))
+ const shared = await realpath(resolve(core, 'src'))
+ for (const root of [host, shared]) for (const path of await files(root)) {
+ if (!path.endsWith('.ts')) continue
+ const source = await readFile(path, 'utf8')
+ // Validate static imports, re-exports and literal dynamic imports/requires.
+ const imports = source.matchAll(/(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire\s*\(\s*)['"]([^'"]+)['"]/g)
+ for (const [, specifier] of imports) {
+ assert(!isAbsolute(specifier), `Absolute source import: ${path}`)
+ if (!specifier.startsWith('.')) {
+ if (root === shared) assert(specifier.startsWith('node:'), `Host dependency in core: ${specifier}`)
+ continue
+ }
+ const target = await realpath(resolve(dirname(path), specifier))
+ assert(inside(shared, target) || (root === host && inside(host, target)), `Import escapes allowed source: ${path}`)
+ }
+ }
+}
+
+export async function validateManifest(hostRoot) {
+ const manifest = JSON.parse(await readFile(resolve(hostRoot, 'lib/runtime-manifest.json'), 'utf8'))
+ const pkg = JSON.parse(await readFile(resolve(hostRoot, 'package.json'), 'utf8'))
+ assert.equal(manifest.packageVersion, pkg.version)
+ assert.equal(manifest.coreDigest, await digest())
+ assert.equal(manifest.sourceCommit, await sourceCommit())
+ assert.equal(manifest.contractVersion, 1)
+ assert.equal(manifest.host, pkg.name === 'opengui-codex' ? 'codex' : 'workbuddy')
+ // Bundles must not retain imports of source files or paths outside lib.
+ const lib = await realpath(resolve(hostRoot, 'lib'))
+ for (const path of await files(lib)) if (path.endsWith('.js') || path.endsWith('.mjs')) {
+ const text = await readFile(path, 'utf8')
+ for (const [, specifier] of text.matchAll(/(?:\bfrom\s*|\bimport\s*\(?\s*)['"]([^'"]+)['"]/g)) {
+ assert(!isAbsolute(specifier), `Absolute bundle import: ${path}`)
+ if (specifier.startsWith('.')) {
+ assert(!specifier.endsWith('.ts'), `Uncompiled source import: ${path}`)
+ assert(inside(lib, await realpath(resolve(dirname(path), specifier))), `Bundle import escapes lib: ${path}`)
+ }
+ }
+ }
+}
+
+async function manifest(host, root) {
+ assert(['codex', 'workbuddy'].includes(host))
+ const pkg = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8'))
+ await writeFile(resolve(root, 'lib/runtime-manifest.json'), JSON.stringify({
+ host, packageVersion: pkg.version, sourceCommit: await sourceCommit(),
+ contractVersion: 1, coreDigest: await digest(),
+ }, null, 2) + '\n')
+}
+
+/** Export only one adapter and the core, preserving their relative layout. */
+async function stage(host, destination) {
+ const hostPath = host === 'codex' ? 'plugins/opengui' : host === 'workbuddy' ? 'workbuddy-plugin' : undefined
+ assert(hostPath, 'Expected codex or workbuddy')
+ const target = resolve(destination)
+ assert(!inside(repository, target) && !inside(target, repository), 'Use an external isolated build directory')
+ await mkdir(target) // Refuse to merge into an existing tree.
+ const filter = source => !/(^|[/\\])(node_modules|lib|dist|\.artifacts|coverage|artifacts)([/\\]|$)/.test(source)
+ await cp(resolve(repository, hostPath), resolve(target, hostPath), { recursive: true, filter })
+ await cp(core, resolve(target, 'packages/device-runtime'), { recursive: true, filter })
+ await writeFile(resolve(target, 'packages/device-runtime/build-source.json'), JSON.stringify({ sourceCommit: await sourceCommit() }) + '\n')
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ const [command, host, root] = process.argv.slice(2)
+ assert(root, 'Usage: build.mjs manifest|stage codex|workbuddy directory')
+ if (command === 'manifest') await manifest(host, resolve(root))
+ else if (command === 'stage') await stage(host, root)
+ else throw new Error('Unknown build command')
+}
diff --git a/packages/device-runtime/src/contracts.ts b/packages/device-runtime/src/contracts.ts
new file mode 100644
index 0000000..e866e8e
--- /dev/null
+++ b/packages/device-runtime/src/contracts.ts
@@ -0,0 +1,9 @@
+/** Transport contracts shared by independently installed host runtimes. */
+export interface VideoDevice { readonly id: string; readonly serial: string }
+export interface ScrcpyStreamSink {
+ sendText(text: string): void
+ sendBinary(data: Buffer): void
+ bufferedBytes(): number
+ close(code?: number, reason?: string): void
+ onClose(listener: () => void): void
+}
diff --git a/packages/device-runtime/src/viewer-page.ts b/packages/device-runtime/src/viewer-page.ts
new file mode 100644
index 0000000..7867472
--- /dev/null
+++ b/packages/device-runtime/src/viewer-page.ts
@@ -0,0 +1,41 @@
+/** Read-only H.264 canvas. No model image capture or phone input route exists here. */
+export function viewerPage(): string {
+ return String.raw`
OpenGUI · 实时设备墙
+
+
+`
+}
diff --git a/packages/device-runtime/src/viewer.ts b/packages/device-runtime/src/viewer.ts
new file mode 100644
index 0000000..2971f29
--- /dev/null
+++ b/packages/device-runtime/src/viewer.ts
@@ -0,0 +1,230 @@
+import { randomBytes, randomUUID } from 'node:crypto'
+import { createServer, type IncomingMessage, type Server } from 'node:http'
+import type { ScrcpyStreamSink, VideoDevice } from './contracts.ts'
+import { acceptStreamWebSocket } from './websocket.ts'
+import { viewerPage } from './viewer-page.ts'
+
+export interface ViewerDevice extends VideoDevice { readonly name: string }
+export interface ViewerStreams {
+ prepare(signal: AbortSignal): Promise
+ subscribe(device: VideoDevice, sink: ScrcpyStreamSink): Promise<() => void>
+ dispose(): Promise
+}
+type Phase = 'preparing' | 'waiting_for_frame' | 'ready' | 'disconnected' | 'error' | 'closed'
+interface Connection {
+ id: string; deviceId: string; sink: ScrcpyStreamSink; challenge: string
+ issued: number; painted: number; connectedAt: number; media: boolean; release?: () => void
+}
+interface Viewer {
+ id: string; token: string; owner: string; devices: readonly ViewerDevice[]
+ phase: Phase; deadline: number; established: boolean; ended: boolean
+ firstFrameMs?: number; error?: string; connections: Map; pages: Set; lastPage: number
+ preparation?: Promise; readyDevices: Set
+}
+
+/** Watching grants never contain a control credential or renew a control lease. */
+export class ViewerServer {
+ private server: Server | undefined
+ private starting: Promise | undefined
+ private origin = ''
+ private readonly viewers = new Map()
+ private readonly sweep: ReturnType
+ constructor(private readonly streams: ViewerStreams, private readonly now = Date.now) {
+ this.sweep = setInterval(() => {
+ for (const viewer of this.viewers.values()) {
+ this.update(viewer)
+ for (const connection of viewer.connections.values()) {
+ if (this.now() - Math.max(connection.painted, connection.connectedAt) > 12_000) connection.sink.close(1001, 'page_inactive')
+ }
+ if (viewer.ended && viewer.pages.size === 0 && viewer.connections.size === 0 && this.now() - viewer.lastPage > 300_000) this.viewers.delete(viewer.id)
+ }
+ }, 1000)
+ this.sweep.unref()
+ }
+
+ get active(): boolean {
+ return [...this.viewers.values()].some(v => v.phase !== 'closed' && (v.pages.size > 0 || v.connections.size > 0 || this.now() - v.lastPage < 15_000))
+ }
+
+ async open(owner: string, devices: readonly ViewerDevice[], signal: AbortSignal) {
+ if (!owner) throw new Error('host_task_required')
+ if (devices.length < 1 || devices.length > 4 || new Set(devices.map(d => d.id)).size !== devices.length) throw new Error('invalid_viewer_devices')
+ let viewer = [...this.viewers.values()].find(v => v.owner === owner && (!v.ended || Boolean(v.error)))
+ if (viewer && !this.same(viewer, devices)) throw new Error('device_frozen')
+ if (!viewer) {
+ if (this.viewers.size >= 100) throw new Error('viewer_capacity')
+ viewer = { id: randomUUID(), token: randomBytes(32).toString('base64url'), owner, devices: [...devices], phase: 'preparing', deadline: 0, established: false, ended: false, connections: new Map(), pages: new Set(), readyDevices: new Set(), lastPage: this.now() }
+ this.viewers.set(viewer.id, viewer)
+ const current = viewer
+ viewer.preparation = (async () => { try {
+ await this.streams.prepare(signal)
+ await this.start()
+ current.deadline = this.now() + 30_000
+ current.phase = 'waiting_for_frame'
+ } catch (error) {
+ current.phase = 'error'
+ current.error = `dependency_prepare_failed: ${String(error)}`
+ } })()
+ }
+ await viewer.preparation
+ if (viewer.phase === 'closed' && viewer.established) viewer.phase = 'disconnected'
+ // A repeated tool call cannot reset a failed first-display deadline.
+ return this.snapshot(viewer)
+ }
+
+ find(owner: string, devices: readonly ViewerDevice[], id?: string): string {
+ const viewer = id ? this.require(id, owner) : [...this.viewers.values()].find(v => v.owner === owner && !v.ended && this.same(v, devices))
+ if (!viewer || viewer.ended || !this.same(viewer, devices)) throw new Error('display_required: open the viewer for this task and these devices first')
+ this.update(viewer)
+ if (!viewer.established && ['closed', 'error'].includes(viewer.phase)) throw new Error(viewer.error ?? 'display_required')
+ return viewer.id
+ }
+
+ assertReady(id: string): void {
+ const viewer = this.require(id)
+ this.update(viewer)
+ if (!viewer.established) throw new Error(viewer.error ?? 'waiting_for_frame: visible decoded video is required before observation or action')
+ }
+
+ async status(id: string, owner: string, waitMs = 0, signal?: AbortSignal) {
+ const viewer = this.require(id, owner)
+ const end = this.now() + Math.min(30_000, Math.max(0, waitMs))
+ while (true) {
+ signal?.throwIfAborted()
+ this.update(viewer)
+ if (viewer.established || ['closed', 'error'].includes(viewer.phase) || this.now() >= end) break
+ await new Promise(resolve => setTimeout(resolve, Math.min(100, end - this.now())))
+ }
+ return this.snapshot(viewer)
+ }
+
+ closeViewer(id: string, owner: string) {
+ const viewer = this.require(id, owner)
+ viewer.phase = 'closed'
+ for (const c of viewer.connections.values()) c.sink.close(1000, 'viewer_closed')
+ for (const page of viewer.pages) page.close(1000, 'viewer_closed')
+ return this.snapshot(viewer)
+ }
+ endOwner(owner: string): void { for (const v of this.viewers.values()) if (v.owner === owner) v.ended = true }
+ endTask(id: string): void { this.require(id).ended = true }
+ url(id: string): string { const v = this.require(id); return `${this.origin}/${v.token}/` }
+ async dispose(): Promise {
+ clearInterval(this.sweep)
+ for (const v of this.viewers.values()) this.closeViewer(v.id, v.owner)
+ await this.streams.dispose()
+ this.server?.closeAllConnections()
+ await new Promise(resolve => this.server ? this.server.close(() => resolve()) : resolve())
+ }
+
+ private same(v: Viewer, devices: readonly ViewerDevice[]): boolean {
+ return v.devices.length === devices.length && devices.every(d => v.devices.some(x => x.id === d.id && x.serial === d.serial))
+ }
+ private require(id: string, owner?: string): Viewer {
+ const v = this.viewers.get(id)
+ if (!v || (owner !== undefined && v.owner !== owner)) throw new Error('foreign_viewer')
+ return v
+ }
+ private update(v: Viewer): void {
+ if (!v.established && v.deadline && this.now() >= v.deadline && v.phase !== 'closed') {
+ v.phase = 'error'; v.error = 'display_timeout: no visible first video frame within 30 seconds; stop this task'
+ }
+ }
+ private snapshot(v: Viewer) {
+ this.update(v)
+ return { viewerId: v.id, url: this.url(v.id), state: v.phase, firstDisplayEstablished: v.established,
+ ...(v.firstFrameMs === undefined ? {} : { firstFrameMs: v.firstFrameMs }),
+ taskState: v.ended ? 'ended' : v.established ? 'executing' : 'preparing',
+ ...(v.error ? { errorCode: v.error.split(':')[0], message: v.error } : {}),
+ nextAction: v.phase === 'error' ? 'report_blocker' : v.established ? 'observe' : 'open_in_host_and_wait',
+ devices: v.devices.map(d => ({ id: d.id, name: d.name, ready: v.readyDevices.has(d.id),
+ state: v.phase === 'closed' ? 'closed' : [...v.connections.values()].some(c => c.deviceId === d.id && c.media && this.now() - c.painted < 12_000) ? (v.readyDevices.has(d.id) ? 'ready' : 'waiting_for_frame') : 'disconnected' })) }
+ }
+ private local(req: IncomingMessage, websocket = false): boolean {
+ return req.headers.host === new URL(this.origin).host
+ && (!req.headers.origin ? !websocket && req.method === 'GET' : req.headers.origin === this.origin)
+ && !['cross-site'].includes(String(req.headers['sec-fetch-site']))
+ }
+ private start(): Promise {
+ this.starting ??= new Promise((resolve, reject) => {
+ const server = createServer((req, res) => {
+ const handle = async (): Promise => {
+ if (!this.local(req)) { res.writeHead(403).end(); return }
+ const url = new URL(req.url ?? '/', this.origin)
+ const [token, route = ''] = url.pathname.slice(1).split('/')
+ const v = [...this.viewers.values()].find(v => v.token === token)
+ if (!v) { res.writeHead(404).end(); return }
+ res.setHeader('Cache-Control', 'no-store')
+ res.setHeader('Referrer-Policy', 'no-referrer')
+ res.setHeader('X-Content-Type-Options', 'nosniff')
+ res.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'")
+ if (req.method === 'GET' && route === '') { v.lastPage = this.now(); res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(viewerPage()); return }
+ if (req.method === 'GET' && route === 'status') { v.lastPage = this.now(); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(this.snapshot(v))); return }
+ if (req.method === 'POST' && route === 'frame' && req.headers.origin === this.origin) {
+ let body = ''
+ for await (const chunk of req) { body += String(chunk); if (body.length > 2048) { res.writeHead(413).end(); return } }
+ const input = JSON.parse(body) as Record
+ const c = v.connections.get(String(input.connectionId))
+ this.update(v)
+ if (!c || !c.media || input.challenge !== c.challenge || input.deviceId !== c.deviceId || input.visible !== true || this.now() - c.issued > 10_000 || v.phase === 'closed') { res.writeHead(409).end(); return }
+ c.painted = this.now()
+ if (!v.error) {
+ v.readyDevices.add(c.deviceId)
+ if (v.devices.every(d => [...v.connections.values()].some(x => x.deviceId === d.id && x.media && this.now() - x.painted < 2000))) {
+ v.firstFrameMs ??= this.now() - (v.deadline - 30_000)
+ v.established = true; v.phase = 'ready'
+ }
+ }
+ c.challenge = randomBytes(24).toString('base64url'); c.issued = this.now()
+ res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ challenge: c.challenge })); return
+ }
+ res.writeHead(404).end()
+ }
+ void handle().catch(() => { if (!res.headersSent) res.writeHead(400); res.end() })
+ })
+ this.server = server
+ server.on('upgrade', (req, socket, head) => {
+ if (!this.local(req, true)) { socket.end('HTTP/1.1 403 Forbidden\r\n\r\n'); return }
+ const url = new URL(req.url ?? '/', this.origin)
+ const [token, route] = url.pathname.slice(1).split('/')
+ const v = [...this.viewers.values()].find(v => v.token === token)
+ if (v && route === 'presence' && v.phase !== 'closed' && v.pages.size < 16) {
+ const page = acceptStreamWebSocket(req, socket, head)
+ v.pages.add(page)
+ page.onClose(() => v.pages.delete(page))
+ return
+ }
+ const device = v?.devices.find(d => d.id === url.searchParams.get('deviceId'))
+ if (!v || !device || route !== 'stream' || v.phase === 'closed' || v.connections.size >= 16) { socket.end('HTTP/1.1 403 Forbidden\r\n\r\n'); return }
+ const sink = acceptStreamWebSocket(req, socket, head)
+ const c: Connection = { id: randomUUID(), deviceId: device.id, sink, challenge: randomBytes(24).toString('base64url'), issued: this.now(), painted: 0, connectedAt: this.now(), media: false }
+ // The grace timestamp is not a rendered-frame receipt.
+ v.connections.set(c.id, c)
+ sink.sendText(JSON.stringify({ type: 'connection', connectionId: c.id, challenge: c.challenge }))
+ let closed = false
+ sink.onClose(() => {
+ closed = true; c.release?.(); v.connections.delete(c.id)
+ if (v.connections.size === 0 && v.phase !== 'closed' && !v.error) v.phase = 'disconnected'
+ })
+ const wrapped: ScrcpyStreamSink = { ...sink, sendBinary: data => { c.media = true; sink.sendBinary(data) }, sendText: text => {
+ const event = JSON.parse(text) as { type: string; message?: string }
+ if (event.type === 'error') { v.phase = 'disconnected'; sink.sendText(text); return }
+ if (event.type === 'session' || event.type === 'reset') {
+ c.media = false; c.challenge = randomBytes(24).toString('base64url'); c.issued = this.now()
+ sink.sendText(JSON.stringify({ type: 'connection', connectionId: c.id, challenge: c.challenge }))
+ }
+ sink.sendText(text)
+ } }
+ void this.streams.subscribe(device, wrapped).then(release => { if (closed) release(); else c.release = release }).catch(error => {
+ sink.sendText(JSON.stringify({ type: 'error', message: String(error) })); sink.close(1011, 'video_failed')
+ })
+ })
+ server.once('error', reject)
+ server.listen(0, '127.0.0.1', () => {
+ const address = server.address()
+ if (!address || typeof address === 'string') { reject(new Error('viewer_listen_failed')); return }
+ this.origin = `http://127.0.0.1:${address.port}`; resolve()
+ })
+ })
+ return this.starting
+ }
+}
diff --git a/packages/device-runtime/src/websocket.ts b/packages/device-runtime/src/websocket.ts
new file mode 100644
index 0000000..4ad8cf9
--- /dev/null
+++ b/packages/device-runtime/src/websocket.ts
@@ -0,0 +1,105 @@
+// Ported from the repository video transport; see VIDEO-NOTICE.md.
+import { createHash } from 'node:crypto'
+import type { IncomingMessage } from 'node:http'
+import type { Duplex } from 'node:stream'
+import type { ScrcpyStreamSink } from './contracts.ts'
+
+const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
+
+function frame(opcode: number, payload: Buffer): Buffer {
+ const size = payload.length
+ const header = size < 126 ? Buffer.allocUnsafe(2) : size <= 0xffff ? Buffer.allocUnsafe(4) : Buffer.allocUnsafe(10)
+ header[0] = 0x80 | opcode
+ if (size < 126) header[1] = size
+ else if (size <= 0xffff) { header[1] = 126; header.writeUInt16BE(size, 2) }
+ else { header[1] = 127; header.writeBigUInt64BE(BigInt(size), 2) }
+ return Buffer.concat([header, payload])
+}
+
+/** Minimal one-way WebSocket peer for the plugin's same-origin binary stream. */
+export function acceptStreamWebSocket(request: IncomingMessage, socket: Duplex, head: Buffer): ScrcpyStreamSink {
+ const key = request.headers['sec-websocket-key']
+ if (request.method !== 'GET' || request.headers.upgrade?.toLocaleLowerCase() !== 'websocket' || (typeof key !== 'string' || !/^[A-Za-z0-9+/]{22}==$/.test(key)) || request.headers['sec-websocket-version'] !== '13') {
+ socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')
+ throw new Error('invalid_websocket_upgrade')
+ }
+ const accept = createHash('sha1').update(`${key}${WS_GUID}`).digest('base64')
+ socket.write([
+ 'HTTP/1.1 101 Switching Protocols',
+ 'Upgrade: websocket',
+ 'Connection: Upgrade',
+ `Sec-WebSocket-Accept: ${accept}`,
+ '\r\n',
+ ].join('\r\n'))
+ let closed = false
+ let closeNotified = false
+ const closeListeners = new Set<() => void>()
+ let input = Buffer.alloc(0)
+ const send = (opcode: number, payload: Buffer): void => {
+ if (!closed && !socket.destroyed) socket.write(frame(opcode, payload))
+ }
+ const consume = (chunk: Buffer): void => {
+ if (input.length + chunk.length > 8192) { socket.destroy(); return }
+ input = Buffer.concat([input, chunk])
+ while (input.length >= 2) {
+ const masked = (input[1]! & 0x80) !== 0
+ if (!masked || (input[0]! & 0x70) !== 0 || (input[0]! & 0x80) === 0) { socket.destroy(); return }
+ let length = input[1]! & 0x7f
+ let offset = 2
+ if (length === 126) {
+ if (input.length < 4) return
+ length = input.readUInt16BE(2); offset = 4
+ } else if (length === 127) {
+ if (input.length < 10) return
+ const large = input.readBigUInt64BE(2)
+ if (large > 125n) { socket.destroy(); return }
+ length = Number(large); offset = 10
+ }
+ if (length > 125 || ![8, 9, 10].includes(input[0]! & 0x0f)) { socket.destroy(); return }
+ const maskBytes = masked ? 4 : 0
+ if (input.length < offset + maskBytes + length) return
+ const opcode = input[0]! & 0x0f
+ let payload = Buffer.from(input.subarray(offset + maskBytes, offset + maskBytes + length))
+ if (masked) {
+ const mask = input.subarray(offset, offset + 4)
+ payload = Buffer.from(payload.map((value, index) => value ^ mask[index % 4]!))
+ }
+ input = input.subarray(offset + maskBytes + length)
+ if (opcode === 0x8) { closed = true; socket.end(frame(0x8, payload)); return }
+ if (opcode === 0x9) send(0xA, payload)
+ }
+ }
+ socket.on('data', chunk => consume(Buffer.from(chunk)))
+ const notifyClosed = (): void => {
+ if (closeNotified) return
+ closeNotified = true
+ closed = true
+ for (const listener of closeListeners) listener()
+ closeListeners.clear()
+ }
+ socket.once('end', () => { notifyClosed(); socket.destroy() })
+ socket.once('close', notifyClosed)
+ socket.once('error', notifyClosed)
+ if (head.length > 0) consume(head)
+ return {
+ sendText: text => send(0x1, Buffer.from(text, 'utf8')),
+ sendBinary: data => send(0x2, data),
+ bufferedBytes: () => Number((socket as Duplex & { writableLength?: number }).writableLength ?? 0),
+ close(code = 1000, reason = '') {
+ if (closed) return
+ closed = true
+ const reasonBuffer = Buffer.from(reason, 'utf8').subarray(0, 123)
+ const payload = Buffer.allocUnsafe(2 + reasonBuffer.length)
+ payload.writeUInt16BE(code, 0)
+ reasonBuffer.copy(payload, 2)
+ socket.end(frame(0x8, payload))
+ notifyClosed()
+ const timer = setTimeout(() => socket.destroy(), 250)
+ timer.unref()
+ },
+ onClose(listener) {
+ if (closed) listener()
+ else closeListeners.add(listener)
+ },
+ }
+}
diff --git a/plugins/opengui/SOURCE.md b/plugins/opengui/SOURCE.md
index 5aeb0a1..454b44a 100644
--- a/plugins/opengui/SOURCE.md
+++ b/plugins/opengui/SOURCE.md
@@ -1,6 +1,7 @@
# Source and production boundary
-This standalone Codex package is maintained only in `Core-Mate/OpenGUI/plugins/opengui`.
+This standalone Codex package is maintained in `Core-Mate/OpenGUI/plugins/opengui`, with shared build-time
+source in `packages/device-runtime`.
Its initial phone-control implementation was copied once from this repository's
`deepseek-harness-plugin` at commit `674e35893219f47b03508ba58b84a13e57f31c57`.
The original MIT license is retained in `LICENSE`.
@@ -13,7 +14,8 @@ The source files were not moved or edited. There is no ongoing synchronization.
The maintainer explicitly required production DSH isolation. This standalone
package is an exception to the older dual-host source-location note. Do not edit
that note, any DSH source/configuration/package/workflow, or the root marketplace
-as part of Codex work. Never import a parent checkout or install DSH dependencies.
+as part of Codex work. Only `packages/device-runtime/src` may be imported at build time outside the
+adapter. The released bundle must never import a parent checkout. Never install DSH dependencies.
Do not run DSH package scripts or replace/reload the production runtime.
The Codex package owns its own version, lockfile, artifacts, state and release
diff --git a/plugins/opengui/package.json b/plugins/opengui/package.json
index 4814162..dfcebe5 100644
--- a/plugins/opengui/package.json
+++ b/plugins/opengui/package.json
@@ -5,18 +5,22 @@
"description": "Standalone local Android control for Codex on macOS",
"type": "module",
"license": "MIT",
- "engines": { "node": ">=22.19.0" },
+ "engines": {
+ "node": ">=22.19.0"
+ },
"packageManager": "pnpm@11.19.0",
"scripts": {
- "test:viewer": "tsc -p tsconfig.browser.json && node scripts/test-viewer-browser.mjs .artifacts/browser/src/viewer.js",
- "build": "tsc --noEmit && tsdown",
+ "test:viewer": "tsdown --config tsdown.viewer.config.ts && node scripts/test-viewer-browser.mjs .artifacts/browser/viewer.js",
+ "build": "tsc --noEmit && tsdown && node ../../packages/device-runtime/build.mjs manifest codex .",
"test": "vitest run",
"validate": "node scripts/validate.mjs",
"check": "pnpm test && pnpm build && pnpm validate",
"stage": "node scripts/stage.mjs",
"package": "node scripts/package.mjs"
},
- "dependencies": { "tar": "7.5.22" },
+ "dependencies": {
+ "tar": "7.5.22"
+ },
"devDependencies": {
"@types/node": "24.10.1",
"tsdown": "0.22.2",
diff --git a/plugins/opengui/scripts/stage.mjs b/plugins/opengui/scripts/stage.mjs
index f4fd0e5..9a4a440 100644
--- a/plugins/opengui/scripts/stage.mjs
+++ b/plugins/opengui/scripts/stage.mjs
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
export const STAGED_PATHS = [
- '.codex-plugin', 'skills', 'scripts/opengui', 'assets', 'lib/cli.js',
+ '.codex-plugin', 'skills', 'scripts/opengui', 'assets', 'lib/cli.js', 'lib/runtime-manifest.json',
'LICENSE', 'VIDEO-NOTICE.md', 'SOURCE.md', 'README.md', 'README.zh-CN.md', 'docs',
]
diff --git a/plugins/opengui/scripts/validate.mjs b/plugins/opengui/scripts/validate.mjs
index 2703aed..c33c5bb 100644
--- a/plugins/opengui/scripts/validate.mjs
+++ b/plugins/opengui/scripts/validate.mjs
@@ -1,3 +1,4 @@
+import { validateSourceBoundary, validateManifest } from '../../../packages/device-runtime/build.mjs'
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { lstat, mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises'
@@ -42,7 +43,7 @@ async function walk(directory) {
}
for (const path of await walk(join(root, 'src'))) {
const text = await readFile(path, 'utf8')
- assert.ok(!/from ['"][^'"]*(deepseek-harness|@deepseek|\.\.\/\.\.\/\.\.)/.test(text), 'Cross-package runtime import: ' + path)
+ assert.ok(!/from ['"][^'"]*(deepseek-harness|@deepseek)/.test(text), 'Cross-package runtime import: ' + path)
assert.ok(!/DSH_HOME|coremate-mobile-scrcpy|dev-auto-reload/.test(text), 'Production coupling: ' + path)
// state.ts may name .dsh only to reject it as an unsafe override.
if (path !== join(root, 'src/state.ts')) assert.ok(!/\.dsh\b/.test(text), 'Legacy runtime path: ' + path)
@@ -58,3 +59,6 @@ try {
execFileSync('/bin/sh', ['-n', join(destination, 'scripts/opengui')])
console.log('Standalone manifest, dependencies, runtime, launcher, ADB checksum and staged upload verified.')
} finally { await rm(temp, { recursive: true, force: true }) }
+
+await validateSourceBoundary(root)
+await validateManifest(root)
diff --git a/plugins/opengui/src/viewer-page.ts b/plugins/opengui/src/viewer-page.ts
index 7867472..e581080 100644
--- a/plugins/opengui/src/viewer-page.ts
+++ b/plugins/opengui/src/viewer-page.ts
@@ -1,41 +1 @@
-/** Read-only H.264 canvas. No model image capture or phone input route exists here. */
-export function viewerPage(): string {
- return String.raw`OpenGUI · 实时设备墙
-
-
-`
-}
+export * from '../../../packages/device-runtime/src/viewer-page.ts'
diff --git a/plugins/opengui/src/viewer.ts b/plugins/opengui/src/viewer.ts
index 5e764db..304007f 100644
--- a/plugins/opengui/src/viewer.ts
+++ b/plugins/opengui/src/viewer.ts
@@ -1,230 +1 @@
-import { randomBytes, randomUUID } from 'node:crypto'
-import { createServer, type IncomingMessage, type Server } from 'node:http'
-import type { ScrcpyStreamSink, VideoDevice } from './scrcpy-stream.ts'
-import { acceptStreamWebSocket } from './websocket.ts'
-import { viewerPage } from './viewer-page.ts'
-
-export interface ViewerDevice extends VideoDevice { readonly name: string }
-export interface ViewerStreams {
- prepare(signal: AbortSignal): Promise
- subscribe(device: VideoDevice, sink: ScrcpyStreamSink): Promise<() => void>
- dispose(): Promise
-}
-type Phase = 'preparing' | 'waiting_for_frame' | 'ready' | 'disconnected' | 'error' | 'closed'
-interface Connection {
- id: string; deviceId: string; sink: ScrcpyStreamSink; challenge: string
- issued: number; painted: number; connectedAt: number; media: boolean; release?: () => void
-}
-interface Viewer {
- id: string; token: string; owner: string; devices: readonly ViewerDevice[]
- phase: Phase; deadline: number; established: boolean; ended: boolean
- firstFrameMs?: number; error?: string; connections: Map; pages: Set; lastPage: number
- preparation?: Promise; readyDevices: Set
-}
-
-/** Watching grants never contain a control credential or renew a control lease. */
-export class ViewerServer {
- private server: Server | undefined
- private starting: Promise | undefined
- private origin = ''
- private readonly viewers = new Map()
- private readonly sweep: ReturnType
- constructor(private readonly streams: ViewerStreams, private readonly now = Date.now) {
- this.sweep = setInterval(() => {
- for (const viewer of this.viewers.values()) {
- this.update(viewer)
- for (const connection of viewer.connections.values()) {
- if (this.now() - Math.max(connection.painted, connection.connectedAt) > 12_000) connection.sink.close(1001, 'page_inactive')
- }
- if (viewer.ended && viewer.pages.size === 0 && viewer.connections.size === 0 && this.now() - viewer.lastPage > 300_000) this.viewers.delete(viewer.id)
- }
- }, 1000)
- this.sweep.unref()
- }
-
- get active(): boolean {
- return [...this.viewers.values()].some(v => v.phase !== 'closed' && (v.pages.size > 0 || v.connections.size > 0 || this.now() - v.lastPage < 15_000))
- }
-
- async open(owner: string, devices: readonly ViewerDevice[], signal: AbortSignal) {
- if (!owner) throw new Error('host_task_required')
- if (devices.length < 1 || devices.length > 4 || new Set(devices.map(d => d.id)).size !== devices.length) throw new Error('invalid_viewer_devices')
- let viewer = [...this.viewers.values()].find(v => v.owner === owner && (!v.ended || Boolean(v.error)))
- if (viewer && !this.same(viewer, devices)) throw new Error('device_frozen')
- if (!viewer) {
- if (this.viewers.size >= 100) throw new Error('viewer_capacity')
- viewer = { id: randomUUID(), token: randomBytes(32).toString('base64url'), owner, devices: [...devices], phase: 'preparing', deadline: 0, established: false, ended: false, connections: new Map(), pages: new Set(), readyDevices: new Set(), lastPage: this.now() }
- this.viewers.set(viewer.id, viewer)
- const current = viewer
- viewer.preparation = (async () => { try {
- await this.streams.prepare(signal)
- await this.start()
- current.deadline = this.now() + 30_000
- current.phase = 'waiting_for_frame'
- } catch (error) {
- current.phase = 'error'
- current.error = `dependency_prepare_failed: ${String(error)}`
- } })()
- }
- await viewer.preparation
- if (viewer.phase === 'closed' && viewer.established) viewer.phase = 'disconnected'
- // A repeated tool call cannot reset a failed first-display deadline.
- return this.snapshot(viewer)
- }
-
- find(owner: string, devices: readonly ViewerDevice[], id?: string): string {
- const viewer = id ? this.require(id, owner) : [...this.viewers.values()].find(v => v.owner === owner && !v.ended && this.same(v, devices))
- if (!viewer || viewer.ended || !this.same(viewer, devices)) throw new Error('display_required: open the viewer for this task and these devices first')
- this.update(viewer)
- if (!viewer.established && ['closed', 'error'].includes(viewer.phase)) throw new Error(viewer.error ?? 'display_required')
- return viewer.id
- }
-
- assertReady(id: string): void {
- const viewer = this.require(id)
- this.update(viewer)
- if (!viewer.established) throw new Error(viewer.error ?? 'waiting_for_frame: visible decoded video is required before observation or action')
- }
-
- async status(id: string, owner: string, waitMs = 0, signal?: AbortSignal) {
- const viewer = this.require(id, owner)
- const end = this.now() + Math.min(30_000, Math.max(0, waitMs))
- while (true) {
- signal?.throwIfAborted()
- this.update(viewer)
- if (viewer.established || ['closed', 'error'].includes(viewer.phase) || this.now() >= end) break
- await new Promise(resolve => setTimeout(resolve, Math.min(100, end - this.now())))
- }
- return this.snapshot(viewer)
- }
-
- closeViewer(id: string, owner: string) {
- const viewer = this.require(id, owner)
- viewer.phase = 'closed'
- for (const c of viewer.connections.values()) c.sink.close(1000, 'viewer_closed')
- for (const page of viewer.pages) page.close(1000, 'viewer_closed')
- return this.snapshot(viewer)
- }
- endOwner(owner: string): void { for (const v of this.viewers.values()) if (v.owner === owner) v.ended = true }
- endTask(id: string): void { this.require(id).ended = true }
- url(id: string): string { const v = this.require(id); return `${this.origin}/${v.token}/` }
- async dispose(): Promise {
- clearInterval(this.sweep)
- for (const v of this.viewers.values()) this.closeViewer(v.id, v.owner)
- await this.streams.dispose()
- this.server?.closeAllConnections()
- await new Promise(resolve => this.server ? this.server.close(() => resolve()) : resolve())
- }
-
- private same(v: Viewer, devices: readonly ViewerDevice[]): boolean {
- return v.devices.length === devices.length && devices.every(d => v.devices.some(x => x.id === d.id && x.serial === d.serial))
- }
- private require(id: string, owner?: string): Viewer {
- const v = this.viewers.get(id)
- if (!v || (owner !== undefined && v.owner !== owner)) throw new Error('foreign_viewer')
- return v
- }
- private update(v: Viewer): void {
- if (!v.established && v.deadline && this.now() >= v.deadline && v.phase !== 'closed') {
- v.phase = 'error'; v.error = 'display_timeout: no visible first video frame within 30 seconds; stop this task'
- }
- }
- private snapshot(v: Viewer) {
- this.update(v)
- return { viewerId: v.id, url: this.url(v.id), state: v.phase, firstDisplayEstablished: v.established,
- ...(v.firstFrameMs === undefined ? {} : { firstFrameMs: v.firstFrameMs }),
- taskState: v.ended ? 'ended' : v.established ? 'executing' : 'preparing',
- ...(v.error ? { errorCode: v.error.split(':')[0], message: v.error } : {}),
- nextAction: v.phase === 'error' ? 'report_blocker' : v.established ? 'observe' : 'open_in_host_and_wait',
- devices: v.devices.map(d => ({ id: d.id, name: d.name, ready: v.readyDevices.has(d.id),
- state: v.phase === 'closed' ? 'closed' : [...v.connections.values()].some(c => c.deviceId === d.id && c.media && this.now() - c.painted < 12_000) ? (v.readyDevices.has(d.id) ? 'ready' : 'waiting_for_frame') : 'disconnected' })) }
- }
- private local(req: IncomingMessage, websocket = false): boolean {
- return req.headers.host === new URL(this.origin).host
- && (!req.headers.origin ? !websocket && req.method === 'GET' : req.headers.origin === this.origin)
- && !['cross-site'].includes(String(req.headers['sec-fetch-site']))
- }
- private start(): Promise {
- this.starting ??= new Promise((resolve, reject) => {
- const server = createServer((req, res) => {
- const handle = async (): Promise => {
- if (!this.local(req)) { res.writeHead(403).end(); return }
- const url = new URL(req.url ?? '/', this.origin)
- const [token, route = ''] = url.pathname.slice(1).split('/')
- const v = [...this.viewers.values()].find(v => v.token === token)
- if (!v) { res.writeHead(404).end(); return }
- res.setHeader('Cache-Control', 'no-store')
- res.setHeader('Referrer-Policy', 'no-referrer')
- res.setHeader('X-Content-Type-Options', 'nosniff')
- res.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'")
- if (req.method === 'GET' && route === '') { v.lastPage = this.now(); res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(viewerPage()); return }
- if (req.method === 'GET' && route === 'status') { v.lastPage = this.now(); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(this.snapshot(v))); return }
- if (req.method === 'POST' && route === 'frame' && req.headers.origin === this.origin) {
- let body = ''
- for await (const chunk of req) { body += String(chunk); if (body.length > 2048) { res.writeHead(413).end(); return } }
- const input = JSON.parse(body) as Record
- const c = v.connections.get(String(input.connectionId))
- this.update(v)
- if (!c || !c.media || input.challenge !== c.challenge || input.deviceId !== c.deviceId || input.visible !== true || this.now() - c.issued > 10_000 || v.phase === 'closed') { res.writeHead(409).end(); return }
- c.painted = this.now()
- if (!v.error) {
- v.readyDevices.add(c.deviceId)
- if (v.devices.every(d => [...v.connections.values()].some(x => x.deviceId === d.id && x.media && this.now() - x.painted < 2000))) {
- v.firstFrameMs ??= this.now() - (v.deadline - 30_000)
- v.established = true; v.phase = 'ready'
- }
- }
- c.challenge = randomBytes(24).toString('base64url'); c.issued = this.now()
- res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ challenge: c.challenge })); return
- }
- res.writeHead(404).end()
- }
- void handle().catch(() => { if (!res.headersSent) res.writeHead(400); res.end() })
- })
- this.server = server
- server.on('upgrade', (req, socket, head) => {
- if (!this.local(req, true)) { socket.end('HTTP/1.1 403 Forbidden\r\n\r\n'); return }
- const url = new URL(req.url ?? '/', this.origin)
- const [token, route] = url.pathname.slice(1).split('/')
- const v = [...this.viewers.values()].find(v => v.token === token)
- if (v && route === 'presence' && v.phase !== 'closed' && v.pages.size < 16) {
- const page = acceptStreamWebSocket(req, socket, head)
- v.pages.add(page)
- page.onClose(() => v.pages.delete(page))
- return
- }
- const device = v?.devices.find(d => d.id === url.searchParams.get('deviceId'))
- if (!v || !device || route !== 'stream' || v.phase === 'closed' || v.connections.size >= 16) { socket.end('HTTP/1.1 403 Forbidden\r\n\r\n'); return }
- const sink = acceptStreamWebSocket(req, socket, head)
- const c: Connection = { id: randomUUID(), deviceId: device.id, sink, challenge: randomBytes(24).toString('base64url'), issued: this.now(), painted: 0, connectedAt: this.now(), media: false }
- // The grace timestamp is not a rendered-frame receipt.
- v.connections.set(c.id, c)
- sink.sendText(JSON.stringify({ type: 'connection', connectionId: c.id, challenge: c.challenge }))
- let closed = false
- sink.onClose(() => {
- closed = true; c.release?.(); v.connections.delete(c.id)
- if (v.connections.size === 0 && v.phase !== 'closed' && !v.error) v.phase = 'disconnected'
- })
- const wrapped: ScrcpyStreamSink = { ...sink, sendBinary: data => { c.media = true; sink.sendBinary(data) }, sendText: text => {
- const event = JSON.parse(text) as { type: string; message?: string }
- if (event.type === 'error') { v.phase = 'disconnected'; sink.sendText(text); return }
- if (event.type === 'session' || event.type === 'reset') {
- c.media = false; c.challenge = randomBytes(24).toString('base64url'); c.issued = this.now()
- sink.sendText(JSON.stringify({ type: 'connection', connectionId: c.id, challenge: c.challenge }))
- }
- sink.sendText(text)
- } }
- void this.streams.subscribe(device, wrapped).then(release => { if (closed) release(); else c.release = release }).catch(error => {
- sink.sendText(JSON.stringify({ type: 'error', message: String(error) })); sink.close(1011, 'video_failed')
- })
- })
- server.once('error', reject)
- server.listen(0, '127.0.0.1', () => {
- const address = server.address()
- if (!address || typeof address === 'string') { reject(new Error('viewer_listen_failed')); return }
- this.origin = `http://127.0.0.1:${address.port}`; resolve()
- })
- })
- return this.starting
- }
-}
+export * from '../../../packages/device-runtime/src/viewer.ts'
diff --git a/plugins/opengui/src/websocket.ts b/plugins/opengui/src/websocket.ts
index 1e0afd6..a7dfbe1 100644
--- a/plugins/opengui/src/websocket.ts
+++ b/plugins/opengui/src/websocket.ts
@@ -1,105 +1 @@
-// Ported from the repository video transport; see VIDEO-NOTICE.md.
-import { createHash } from 'node:crypto'
-import type { IncomingMessage } from 'node:http'
-import type { Duplex } from 'node:stream'
-import type { ScrcpyStreamSink } from './scrcpy-stream.ts'
-
-const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
-
-function frame(opcode: number, payload: Buffer): Buffer {
- const size = payload.length
- const header = size < 126 ? Buffer.allocUnsafe(2) : size <= 0xffff ? Buffer.allocUnsafe(4) : Buffer.allocUnsafe(10)
- header[0] = 0x80 | opcode
- if (size < 126) header[1] = size
- else if (size <= 0xffff) { header[1] = 126; header.writeUInt16BE(size, 2) }
- else { header[1] = 127; header.writeBigUInt64BE(BigInt(size), 2) }
- return Buffer.concat([header, payload])
-}
-
-/** Minimal one-way WebSocket peer for the plugin's same-origin binary stream. */
-export function acceptStreamWebSocket(request: IncomingMessage, socket: Duplex, head: Buffer): ScrcpyStreamSink {
- const key = request.headers['sec-websocket-key']
- if (request.method !== 'GET' || request.headers.upgrade?.toLocaleLowerCase() !== 'websocket' || (typeof key !== 'string' || !/^[A-Za-z0-9+/]{22}==$/.test(key)) || request.headers['sec-websocket-version'] !== '13') {
- socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')
- throw new Error('invalid_websocket_upgrade')
- }
- const accept = createHash('sha1').update(`${key}${WS_GUID}`).digest('base64')
- socket.write([
- 'HTTP/1.1 101 Switching Protocols',
- 'Upgrade: websocket',
- 'Connection: Upgrade',
- `Sec-WebSocket-Accept: ${accept}`,
- '\r\n',
- ].join('\r\n'))
- let closed = false
- let closeNotified = false
- const closeListeners = new Set<() => void>()
- let input = Buffer.alloc(0)
- const send = (opcode: number, payload: Buffer): void => {
- if (!closed && !socket.destroyed) socket.write(frame(opcode, payload))
- }
- const consume = (chunk: Buffer): void => {
- if (input.length + chunk.length > 8192) { socket.destroy(); return }
- input = Buffer.concat([input, chunk])
- while (input.length >= 2) {
- const masked = (input[1]! & 0x80) !== 0
- if (!masked || (input[0]! & 0x70) !== 0 || (input[0]! & 0x80) === 0) { socket.destroy(); return }
- let length = input[1]! & 0x7f
- let offset = 2
- if (length === 126) {
- if (input.length < 4) return
- length = input.readUInt16BE(2); offset = 4
- } else if (length === 127) {
- if (input.length < 10) return
- const large = input.readBigUInt64BE(2)
- if (large > 125n) { socket.destroy(); return }
- length = Number(large); offset = 10
- }
- if (length > 125 || ![8, 9, 10].includes(input[0]! & 0x0f)) { socket.destroy(); return }
- const maskBytes = masked ? 4 : 0
- if (input.length < offset + maskBytes + length) return
- const opcode = input[0]! & 0x0f
- let payload = Buffer.from(input.subarray(offset + maskBytes, offset + maskBytes + length))
- if (masked) {
- const mask = input.subarray(offset, offset + 4)
- payload = Buffer.from(payload.map((value, index) => value ^ mask[index % 4]!))
- }
- input = input.subarray(offset + maskBytes + length)
- if (opcode === 0x8) { closed = true; socket.end(frame(0x8, payload)); return }
- if (opcode === 0x9) send(0xA, payload)
- }
- }
- socket.on('data', chunk => consume(Buffer.from(chunk)))
- const notifyClosed = (): void => {
- if (closeNotified) return
- closeNotified = true
- closed = true
- for (const listener of closeListeners) listener()
- closeListeners.clear()
- }
- socket.once('end', () => { notifyClosed(); socket.destroy() })
- socket.once('close', notifyClosed)
- socket.once('error', notifyClosed)
- if (head.length > 0) consume(head)
- return {
- sendText: text => send(0x1, Buffer.from(text, 'utf8')),
- sendBinary: data => send(0x2, data),
- bufferedBytes: () => Number((socket as Duplex & { writableLength?: number }).writableLength ?? 0),
- close(code = 1000, reason = '') {
- if (closed) return
- closed = true
- const reasonBuffer = Buffer.from(reason, 'utf8').subarray(0, 123)
- const payload = Buffer.allocUnsafe(2 + reasonBuffer.length)
- payload.writeUInt16BE(code, 0)
- reasonBuffer.copy(payload, 2)
- socket.end(frame(0x8, payload))
- notifyClosed()
- const timer = setTimeout(() => socket.destroy(), 250)
- timer.unref()
- },
- onClose(listener) {
- if (closed) listener()
- else closeListeners.add(listener)
- },
- }
-}
+export * from '../../../packages/device-runtime/src/websocket.ts'
diff --git a/plugins/opengui/tsdown.viewer.config.ts b/plugins/opengui/tsdown.viewer.config.ts
new file mode 100644
index 0000000..f001cc7
--- /dev/null
+++ b/plugins/opengui/tsdown.viewer.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'tsdown'
+export default defineConfig({
+ entry: { viewer: 'src/viewer.ts' }, outDir: '.artifacts/browser',
+ format: ['esm'], platform: 'node', target: 'node22', fixedExtension: false,
+ deps: { alwaysBundle: id => !id.startsWith('node:'), onlyBundle: false },
+ outputOptions: { codeSplitting: false }, dts: false, clean: true,
+})
diff --git a/plugins/opengui/vitest.config.ts b/plugins/opengui/vitest.config.ts
index 1a11c5a..e22a06b 100644
--- a/plugins/opengui/vitest.config.ts
+++ b/plugins/opengui/vitest.config.ts
@@ -1,4 +1,2 @@
import { defineConfig } from 'vitest/config'
-
-// Browser QA compilation may leave generated tests below .artifacts.
-export default defineConfig({ test: { include: ['tests/**/*.spec.ts'] } })
+export default defineConfig({ test: { include: ['tests/**/*.spec.ts', '../../packages/device-runtime/tests/**/*.spec.ts'] } })
diff --git a/workbuddy-plugin/package-lock.json b/workbuddy-plugin/package-lock.json
index cea5519..013a892 100644
--- a/workbuddy-plugin/package-lock.json
+++ b/workbuddy-plugin/package-lock.json
@@ -22,6 +22,7 @@
"@types/node": "22.19.0",
"@types/yauzl": "2.10.3",
"fflate": "0.8.3",
+ "tsdown": "0.22.2",
"typescript": "5.9.3",
"vitest": "3.2.7",
"yazl": "3.3.1"
@@ -30,6 +31,96 @@
"node": "^22.19.0 || >=24"
}
},
+ "node_modules/@babel/generator": {
+ "version": "8.0.0-rc.6",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0-rc.6.tgz",
+ "integrity": "sha512-6mIzgVK8DgEzvIapoQwhXTMnnkuE4STQmVv9H03i/tZ2ml8oev3TRvZJgTenK2Bsq0YWNtzOrFdTyNzCMFtjJQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^8.0.0-rc.6",
+ "@babel/types": "^8.0.0-rc.6",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "@types/jsesc": "^2.5.0",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": "^22.18.0 || >=24.11.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz",
+ "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^22.18.0 || >=24.11.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "8.0.0-rc.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.6.tgz",
+ "integrity": "sha512-nVJ+1JcCgntv8d78rRo++o2wuODT0Irknx2BF8Np4Ft2CRgjLqIs4qzSZ8b66yGbBdMWGmZBO9WEZv1hhNiSpg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^22.18.0 || >=24.11.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "8.0.0-rc.6",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.6.tgz",
+ "integrity": "sha512-rOS8IpdO7mQELkTPlCsTgPejO0bFuZdEDCGQJouYbYf9e1FLTym7Fei2pEjq8q7MWbX0ravcd7QQYKs1TxOuog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^8.0.0-rc.6"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": "^22.18.0 || >=24.11.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "8.0.5",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.5.tgz",
+ "integrity": "sha512-eVdMqi3ej5aHhyQ2Si6yD2cAWeV8FJK9UrhK5aL0Sd8hu5GhT+YswhVNbVheOGVYMg8kuGuMaUpkB3stjj4z8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^8.0.0",
+ "@babel/helper-validator-identifier": "^8.0.4"
+ },
+ "engines": {
+ "node": "^22.18.0 || >=24.11.0"
+ }
+ },
+ "node_modules/@babel/types/node_modules/@babel/helper-validator-identifier": {
+ "version": "8.0.4",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz",
+ "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^22.18.0 || >=24.11.0"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
+ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
@@ -40,6 +131,17 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -1054,6 +1156,27 @@
"node": ">=18.0.0"
}
},
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
@@ -1061,6 +1184,17 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -1086,41 +1220,379 @@
"zod-to-json-schema": "^3.25.1"
},
"engines": {
- "node": ">=18"
- },
- "peerDependencies": {
- "@cfworker/json-schema": "^4.1.1",
- "zod": "^3.25 || ^4.0"
- },
- "peerDependenciesMeta": {
- "@cfworker/json-schema": {
- "optional": true
- },
- "zod": {
- "optional": false
- }
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@cfworker/json-schema": "^4.1.1",
+ "zod": "^3.25 || ^4.0"
+ },
+ "peerDependenciesMeta": {
+ "@cfworker/json-schema": {
+ "optional": true
+ },
+ "zod": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz",
+ "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.3"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4",
+ "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.139.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
+ "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@quansync/fs": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.1.0.tgz",
+ "integrity": "sha512-qAPG/t3HqML1TlN7sY/pTbEjzFVAKsMjNNMGheyDosro+kT4iw2KCUoHcVdmliwWjorm4elZbgNQyU2eD97eDg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "quansync": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sxzz"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
+ "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
+ "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
+ "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
+ "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
+ "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
+ "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
+ "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
+ "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
+ "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
+ "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
+ "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
+ "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
+ "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.11.1",
+ "@emnapi/runtime": "1.11.1",
+ "@napi-rs/wasm-runtime": "^1.1.6"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
}
},
- "node_modules/@napi-rs/lzma-linux-x64-gnu": {
- "version": "1.5.1",
- "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
- "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
+ "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
"cpu": [
- "x64"
+ "arm64"
],
"dev": true,
- "libc": [
- "glibc"
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
+ "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
+ "cpu": [
+ "x64"
],
+ "dev": true,
"license": "MIT",
"optional": true,
"os": [
- "linux"
+ "win32"
],
"engines": {
- "node": "^22.20 || ^24.12 || >=25"
+ "node": "^20.19.0 || >=22.12.0"
}
},
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz",
@@ -1510,6 +1982,17 @@
"win32"
]
},
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.4",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
+ "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -1535,6 +2018,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/jsesc": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz",
+ "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "22.19.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.0.tgz",
@@ -1716,6 +2206,16 @@
}
}
},
+ "node_modules/ansis": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.4.0.tgz",
+ "integrity": "sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1726,6 +2226,50 @@
"node": ">=12"
}
},
+ "node_modules/ast-kit": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-3.0.0.tgz",
+ "integrity": "sha512-8OG92q3R35qjC/4i6BLBMg8IB+fClWu/1PEwg2Z9Rn+BuNaiEgJzpzn+pxWOdHJWDCAwu2JP0wCDTozAM4QirQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^8.0.0",
+ "estree-walker": "^3.0.3",
+ "pathe": "^2.0.3"
+ },
+ "engines": {
+ "node": "^22.18.0 || >=24.11.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sxzz"
+ }
+ },
+ "node_modules/ast-kit/node_modules/@babel/parser": {
+ "version": "8.0.5",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.5.tgz",
+ "integrity": "sha512-51RXvQNFakaS0bTpYiGkxNbUVwkPO4kONv6EVLorZABxsx+KZ6Z7uSYvi/wmKS/+X+rfj9RvOw0/ZNh+cmI0Rw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^8.0.5"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": "^22.18.0 || >=24.11.0"
+ }
+ },
+ "node_modules/birpc": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/birpc/-/birpc-4.2.0.tgz",
+ "integrity": "sha512-KxgKcZPfrtzJDDALHPguGpGJUrzdgpymyiQQgzFjWreHMOpWrnFNVREr5J48x2DBh8ZVioscrV1SBkDipGiX+Q==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
@@ -1955,6 +2499,13 @@
"node": ">=6"
}
},
+ "node_modules/defu": {
+ "version": "6.1.7",
+ "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
+ "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -1973,6 +2524,27 @@
"node": ">=8"
}
},
+ "node_modules/dts-resolver": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/dts-resolver/-/dts-resolver-3.0.0.tgz",
+ "integrity": "sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^22.18.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sxzz"
+ },
+ "peerDependencies": {
+ "oxc-resolver": ">=11.0.0"
+ },
+ "peerDependenciesMeta": {
+ "oxc-resolver": {
+ "optional": true
+ }
+ }
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1993,6 +2565,16 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
+ "node_modules/empathic": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.1.tgz",
+ "integrity": "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -2346,6 +2928,22 @@
"node": ">= 0.4"
}
},
+ "node_modules/get-tsconfig": {
+ "version": "5.0.0-beta.5",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.5.tgz",
+ "integrity": "sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=20.20.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -2391,6 +2989,13 @@
"node": ">=16.9.0"
}
},
+ "node_modules/hookable": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.2.tgz",
+ "integrity": "sha512-+abwxtiEA52GCVIsQqut3S/uKTbUwYIp4Pe/vv+6py5XiXBCqMZHg6pA6Y5qhgLSEys0/cYuPbO0z1QFj5ZCmg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -2427,6 +3032,19 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/import-without-cache": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/import-without-cache/-/import-without-cache-0.4.1.tgz",
+ "integrity": "sha512-vXoV9PjKHEednCUu01e98TkImxy67e3BJXbTIOmIq4Hyzw3IAwYRcuPkfeTzJzwyyts4kjuA73x+pWJLc4f86A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^22.18.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sxzz"
+ }
+ },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -2479,6 +3097,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -2663,6 +3294,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/obug": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
+ "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -2822,6 +3467,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/quansync": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/quansync/-/quansync-1.0.0.tgz",
+ "integrity": "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/antfu"
+ },
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/sxzz"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/range-parser": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
@@ -2859,6 +3521,94 @@
"node": ">=0.10.0"
}
},
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
+ "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.139.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.1.5",
+ "@rolldown/binding-darwin-arm64": "1.1.5",
+ "@rolldown/binding-darwin-x64": "1.1.5",
+ "@rolldown/binding-freebsd-x64": "1.1.5",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
+ "@rolldown/binding-linux-arm64-gnu": "1.1.5",
+ "@rolldown/binding-linux-arm64-musl": "1.1.5",
+ "@rolldown/binding-linux-ppc64-gnu": "1.1.5",
+ "@rolldown/binding-linux-s390x-gnu": "1.1.5",
+ "@rolldown/binding-linux-x64-gnu": "1.1.5",
+ "@rolldown/binding-linux-x64-musl": "1.1.5",
+ "@rolldown/binding-openharmony-arm64": "1.1.5",
+ "@rolldown/binding-wasm32-wasi": "1.1.5",
+ "@rolldown/binding-win32-arm64-msvc": "1.1.5",
+ "@rolldown/binding-win32-x64-msvc": "1.1.5"
+ }
+ },
+ "node_modules/rolldown-plugin-dts": {
+ "version": "0.25.2",
+ "resolved": "https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.25.2.tgz",
+ "integrity": "sha512-nMhN/R+vmR8GM45ZW1FWMSjRTSDDn/6w4GTf8RNrEFCBdl8B1kySWrU1ixPtbwzXoRlcO+R/S88VgXuJQwfdDg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/generator": "8.0.0-rc.6",
+ "@babel/helper-validator-identifier": "8.0.0-rc.6",
+ "@babel/parser": "8.0.0-rc.6",
+ "ast-kit": "^3.0.0-beta.1",
+ "birpc": "^4.0.0",
+ "dts-resolver": "^3.0.0",
+ "get-tsconfig": "5.0.0-beta.5",
+ "obug": "^2.1.1"
+ },
+ "engines": {
+ "node": "^22.18.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sxzz"
+ },
+ "peerDependencies": {
+ "@ts-macro/tsc": "^0.3.6",
+ "@typescript/native-preview": ">=7.0.0-dev.20260325.1",
+ "rolldown": "^1.0.0",
+ "typescript": "^5.0.0 || ^6.0.0",
+ "vue-tsc": "~3.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@ts-macro/tsc": {
+ "optional": true
+ },
+ "@typescript/native-preview": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ },
+ "vue-tsc": {
+ "optional": true
+ }
+ }
+ },
"node_modules/rollup": {
"version": "4.63.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz",
@@ -3271,6 +4021,109 @@
"node": ">=0.6"
}
},
+ "node_modules/tree-kill": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "tree-kill": "cli.js"
+ }
+ },
+ "node_modules/tsdown": {
+ "version": "0.22.2",
+ "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.22.2.tgz",
+ "integrity": "sha512-VX9gsyKXsTnBZjnIM4jsHl9aRv+GfgkE/k1hQslilaBfZMlaw3JuGR+6yhiU0QxWBtOCDnTjwOSoXzgB7Rr50g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansis": "^4.3.1",
+ "cac": "^7.0.0",
+ "defu": "^6.1.7",
+ "empathic": "^2.0.1",
+ "hookable": "^6.1.1",
+ "import-without-cache": "^0.4.0",
+ "obug": "^2.1.1",
+ "picomatch": "^4.0.4",
+ "rolldown": "~1.1.0",
+ "rolldown-plugin-dts": "^0.25.2",
+ "semver": "^7.8.1",
+ "tinyexec": "^1.2.4",
+ "tinyglobby": "^0.2.17",
+ "tree-kill": "^1.2.2",
+ "unconfig-core": "^7.5.0"
+ },
+ "bin": {
+ "tsdown": "dist/run.mjs"
+ },
+ "engines": {
+ "node": "^22.18.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sxzz"
+ },
+ "peerDependencies": {
+ "@arethetypeswrong/core": "^0.18.1",
+ "@tsdown/css": "0.22.2",
+ "@tsdown/exe": "0.22.2",
+ "@vitejs/devtools": "*",
+ "publint": "^0.3.8",
+ "tsx": "*",
+ "typescript": "^5.0.0 || ^6.0.0",
+ "unplugin-unused": "^0.5.0",
+ "unrun": "*"
+ },
+ "peerDependenciesMeta": {
+ "@arethetypeswrong/core": {
+ "optional": true
+ },
+ "@tsdown/css": {
+ "optional": true
+ },
+ "@tsdown/exe": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "publint": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ },
+ "unplugin-unused": {
+ "optional": true
+ },
+ "unrun": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tsdown/node_modules/cac": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-7.0.0.tgz",
+ "integrity": "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/tsdown/node_modules/tinyexec": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
+ "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -3323,6 +4176,20 @@
"node": ">=14.17"
}
},
+ "node_modules/unconfig-core": {
+ "version": "7.5.0",
+ "resolved": "https://registry.npmjs.org/unconfig-core/-/unconfig-core-7.5.0.tgz",
+ "integrity": "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@quansync/fs": "^1.0.0",
+ "quansync": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
diff --git a/workbuddy-plugin/package.json b/workbuddy-plugin/package.json
index c7ce222..54c1266 100644
--- a/workbuddy-plugin/package.json
+++ b/workbuddy-plugin/package.json
@@ -4,13 +4,29 @@
"description": "Independent OpenGUI Android MCP runtime for WorkBuddy",
"type": "module",
"license": "SEE LICENSE IN LICENSE",
- "repository": { "type": "git", "url": "https://github.com/Core-Mate/OpenGUI.git", "directory": "workbuddy-plugin" },
- "bin": { "opengui-mcp": "lib/mcp.js" },
- "files": ["scripts/install-local.mjs", "lib", "assets", "LICENSE", "NOTICE.md", "VIDEO-NOTICE.md", "README.md"],
- "engines": { "node": "^22.19.0 || >=24" },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/Core-Mate/OpenGUI.git",
+ "directory": "workbuddy-plugin"
+ },
+ "bin": {
+ "opengui-mcp": "lib/mcp.js"
+ },
+ "files": [
+ "scripts/install-local.mjs",
+ "lib",
+ "assets",
+ "LICENSE",
+ "NOTICE.md",
+ "VIDEO-NOTICE.md",
+ "README.md"
+ ],
+ "engines": {
+ "node": "^22.19.0 || >=24"
+ },
"scripts": {
"test:viewer": "npm run build && node scripts/test-viewer-browser.mjs",
- "build": "tsc -p tsconfig.json && node scripts/finalize.mjs",
+ "build": "tsc -p tsconfig.json && tsdown && node scripts/finalize.mjs && node ../packages/device-runtime/build.mjs manifest workbuddy .",
"test": "vitest run",
"check": "npm test && npm run build && node scripts/validate.mjs",
"pack:release": "npm run check && node scripts/package.mjs",
@@ -29,6 +45,7 @@
"@types/node": "22.19.0",
"@types/yauzl": "2.10.3",
"fflate": "0.8.3",
+ "tsdown": "0.22.2",
"typescript": "5.9.3",
"vitest": "3.2.7",
"yazl": "3.3.1"
diff --git a/workbuddy-plugin/scripts/validate.mjs b/workbuddy-plugin/scripts/validate.mjs
index cccf8e9..f266e78 100644
--- a/workbuddy-plugin/scripts/validate.mjs
+++ b/workbuddy-plugin/scripts/validate.mjs
@@ -1,3 +1,4 @@
+import { validateSourceBoundary, validateManifest } from '../../packages/device-runtime/build.mjs'
import { execFileSync } from 'node:child_process'
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
@@ -61,7 +62,6 @@ async function sources(path) {
else if (entry.name.endsWith('.ts')) {
const text = await readFile(absolute, 'utf8')
assert(!/@deepseek|deepseek-harness-plugin|\.codex|DSH_HOME|OPENGUI_CODEX_HOME/.test(text), `Production dependency in ${entry.name}`)
- assert(!/from ['"]\.\.\//.test(text), `Import escapes independent source tree: ${entry.name}`)
}
}
}
@@ -75,3 +75,6 @@ if (process.argv.includes('--release')) {
}
}
console.log('WorkBuddy manifest, fourteen-tool contract, production isolation, native helpers, and bundled ADB hashes verified.')
+
+await validateSourceBoundary(root)
+await validateManifest(root)
diff --git a/workbuddy-plugin/src/viewer-page.ts b/workbuddy-plugin/src/viewer-page.ts
index 7867472..33f66a4 100644
--- a/workbuddy-plugin/src/viewer-page.ts
+++ b/workbuddy-plugin/src/viewer-page.ts
@@ -1,41 +1 @@
-/** Read-only H.264 canvas. No model image capture or phone input route exists here. */
-export function viewerPage(): string {
- return String.raw`OpenGUI · 实时设备墙
-
-
-`
-}
+export * from '../../packages/device-runtime/src/viewer-page.ts'
diff --git a/workbuddy-plugin/src/viewer.ts b/workbuddy-plugin/src/viewer.ts
index 5e764db..25e0023 100644
--- a/workbuddy-plugin/src/viewer.ts
+++ b/workbuddy-plugin/src/viewer.ts
@@ -1,230 +1 @@
-import { randomBytes, randomUUID } from 'node:crypto'
-import { createServer, type IncomingMessage, type Server } from 'node:http'
-import type { ScrcpyStreamSink, VideoDevice } from './scrcpy-stream.ts'
-import { acceptStreamWebSocket } from './websocket.ts'
-import { viewerPage } from './viewer-page.ts'
-
-export interface ViewerDevice extends VideoDevice { readonly name: string }
-export interface ViewerStreams {
- prepare(signal: AbortSignal): Promise
- subscribe(device: VideoDevice, sink: ScrcpyStreamSink): Promise<() => void>
- dispose(): Promise
-}
-type Phase = 'preparing' | 'waiting_for_frame' | 'ready' | 'disconnected' | 'error' | 'closed'
-interface Connection {
- id: string; deviceId: string; sink: ScrcpyStreamSink; challenge: string
- issued: number; painted: number; connectedAt: number; media: boolean; release?: () => void
-}
-interface Viewer {
- id: string; token: string; owner: string; devices: readonly ViewerDevice[]
- phase: Phase; deadline: number; established: boolean; ended: boolean
- firstFrameMs?: number; error?: string; connections: Map; pages: Set; lastPage: number
- preparation?: Promise; readyDevices: Set
-}
-
-/** Watching grants never contain a control credential or renew a control lease. */
-export class ViewerServer {
- private server: Server | undefined
- private starting: Promise | undefined
- private origin = ''
- private readonly viewers = new Map()
- private readonly sweep: ReturnType
- constructor(private readonly streams: ViewerStreams, private readonly now = Date.now) {
- this.sweep = setInterval(() => {
- for (const viewer of this.viewers.values()) {
- this.update(viewer)
- for (const connection of viewer.connections.values()) {
- if (this.now() - Math.max(connection.painted, connection.connectedAt) > 12_000) connection.sink.close(1001, 'page_inactive')
- }
- if (viewer.ended && viewer.pages.size === 0 && viewer.connections.size === 0 && this.now() - viewer.lastPage > 300_000) this.viewers.delete(viewer.id)
- }
- }, 1000)
- this.sweep.unref()
- }
-
- get active(): boolean {
- return [...this.viewers.values()].some(v => v.phase !== 'closed' && (v.pages.size > 0 || v.connections.size > 0 || this.now() - v.lastPage < 15_000))
- }
-
- async open(owner: string, devices: readonly ViewerDevice[], signal: AbortSignal) {
- if (!owner) throw new Error('host_task_required')
- if (devices.length < 1 || devices.length > 4 || new Set(devices.map(d => d.id)).size !== devices.length) throw new Error('invalid_viewer_devices')
- let viewer = [...this.viewers.values()].find(v => v.owner === owner && (!v.ended || Boolean(v.error)))
- if (viewer && !this.same(viewer, devices)) throw new Error('device_frozen')
- if (!viewer) {
- if (this.viewers.size >= 100) throw new Error('viewer_capacity')
- viewer = { id: randomUUID(), token: randomBytes(32).toString('base64url'), owner, devices: [...devices], phase: 'preparing', deadline: 0, established: false, ended: false, connections: new Map(), pages: new Set(), readyDevices: new Set(), lastPage: this.now() }
- this.viewers.set(viewer.id, viewer)
- const current = viewer
- viewer.preparation = (async () => { try {
- await this.streams.prepare(signal)
- await this.start()
- current.deadline = this.now() + 30_000
- current.phase = 'waiting_for_frame'
- } catch (error) {
- current.phase = 'error'
- current.error = `dependency_prepare_failed: ${String(error)}`
- } })()
- }
- await viewer.preparation
- if (viewer.phase === 'closed' && viewer.established) viewer.phase = 'disconnected'
- // A repeated tool call cannot reset a failed first-display deadline.
- return this.snapshot(viewer)
- }
-
- find(owner: string, devices: readonly ViewerDevice[], id?: string): string {
- const viewer = id ? this.require(id, owner) : [...this.viewers.values()].find(v => v.owner === owner && !v.ended && this.same(v, devices))
- if (!viewer || viewer.ended || !this.same(viewer, devices)) throw new Error('display_required: open the viewer for this task and these devices first')
- this.update(viewer)
- if (!viewer.established && ['closed', 'error'].includes(viewer.phase)) throw new Error(viewer.error ?? 'display_required')
- return viewer.id
- }
-
- assertReady(id: string): void {
- const viewer = this.require(id)
- this.update(viewer)
- if (!viewer.established) throw new Error(viewer.error ?? 'waiting_for_frame: visible decoded video is required before observation or action')
- }
-
- async status(id: string, owner: string, waitMs = 0, signal?: AbortSignal) {
- const viewer = this.require(id, owner)
- const end = this.now() + Math.min(30_000, Math.max(0, waitMs))
- while (true) {
- signal?.throwIfAborted()
- this.update(viewer)
- if (viewer.established || ['closed', 'error'].includes(viewer.phase) || this.now() >= end) break
- await new Promise(resolve => setTimeout(resolve, Math.min(100, end - this.now())))
- }
- return this.snapshot(viewer)
- }
-
- closeViewer(id: string, owner: string) {
- const viewer = this.require(id, owner)
- viewer.phase = 'closed'
- for (const c of viewer.connections.values()) c.sink.close(1000, 'viewer_closed')
- for (const page of viewer.pages) page.close(1000, 'viewer_closed')
- return this.snapshot(viewer)
- }
- endOwner(owner: string): void { for (const v of this.viewers.values()) if (v.owner === owner) v.ended = true }
- endTask(id: string): void { this.require(id).ended = true }
- url(id: string): string { const v = this.require(id); return `${this.origin}/${v.token}/` }
- async dispose(): Promise {
- clearInterval(this.sweep)
- for (const v of this.viewers.values()) this.closeViewer(v.id, v.owner)
- await this.streams.dispose()
- this.server?.closeAllConnections()
- await new Promise(resolve => this.server ? this.server.close(() => resolve()) : resolve())
- }
-
- private same(v: Viewer, devices: readonly ViewerDevice[]): boolean {
- return v.devices.length === devices.length && devices.every(d => v.devices.some(x => x.id === d.id && x.serial === d.serial))
- }
- private require(id: string, owner?: string): Viewer {
- const v = this.viewers.get(id)
- if (!v || (owner !== undefined && v.owner !== owner)) throw new Error('foreign_viewer')
- return v
- }
- private update(v: Viewer): void {
- if (!v.established && v.deadline && this.now() >= v.deadline && v.phase !== 'closed') {
- v.phase = 'error'; v.error = 'display_timeout: no visible first video frame within 30 seconds; stop this task'
- }
- }
- private snapshot(v: Viewer) {
- this.update(v)
- return { viewerId: v.id, url: this.url(v.id), state: v.phase, firstDisplayEstablished: v.established,
- ...(v.firstFrameMs === undefined ? {} : { firstFrameMs: v.firstFrameMs }),
- taskState: v.ended ? 'ended' : v.established ? 'executing' : 'preparing',
- ...(v.error ? { errorCode: v.error.split(':')[0], message: v.error } : {}),
- nextAction: v.phase === 'error' ? 'report_blocker' : v.established ? 'observe' : 'open_in_host_and_wait',
- devices: v.devices.map(d => ({ id: d.id, name: d.name, ready: v.readyDevices.has(d.id),
- state: v.phase === 'closed' ? 'closed' : [...v.connections.values()].some(c => c.deviceId === d.id && c.media && this.now() - c.painted < 12_000) ? (v.readyDevices.has(d.id) ? 'ready' : 'waiting_for_frame') : 'disconnected' })) }
- }
- private local(req: IncomingMessage, websocket = false): boolean {
- return req.headers.host === new URL(this.origin).host
- && (!req.headers.origin ? !websocket && req.method === 'GET' : req.headers.origin === this.origin)
- && !['cross-site'].includes(String(req.headers['sec-fetch-site']))
- }
- private start(): Promise {
- this.starting ??= new Promise((resolve, reject) => {
- const server = createServer((req, res) => {
- const handle = async (): Promise => {
- if (!this.local(req)) { res.writeHead(403).end(); return }
- const url = new URL(req.url ?? '/', this.origin)
- const [token, route = ''] = url.pathname.slice(1).split('/')
- const v = [...this.viewers.values()].find(v => v.token === token)
- if (!v) { res.writeHead(404).end(); return }
- res.setHeader('Cache-Control', 'no-store')
- res.setHeader('Referrer-Policy', 'no-referrer')
- res.setHeader('X-Content-Type-Options', 'nosniff')
- res.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'")
- if (req.method === 'GET' && route === '') { v.lastPage = this.now(); res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(viewerPage()); return }
- if (req.method === 'GET' && route === 'status') { v.lastPage = this.now(); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(this.snapshot(v))); return }
- if (req.method === 'POST' && route === 'frame' && req.headers.origin === this.origin) {
- let body = ''
- for await (const chunk of req) { body += String(chunk); if (body.length > 2048) { res.writeHead(413).end(); return } }
- const input = JSON.parse(body) as Record
- const c = v.connections.get(String(input.connectionId))
- this.update(v)
- if (!c || !c.media || input.challenge !== c.challenge || input.deviceId !== c.deviceId || input.visible !== true || this.now() - c.issued > 10_000 || v.phase === 'closed') { res.writeHead(409).end(); return }
- c.painted = this.now()
- if (!v.error) {
- v.readyDevices.add(c.deviceId)
- if (v.devices.every(d => [...v.connections.values()].some(x => x.deviceId === d.id && x.media && this.now() - x.painted < 2000))) {
- v.firstFrameMs ??= this.now() - (v.deadline - 30_000)
- v.established = true; v.phase = 'ready'
- }
- }
- c.challenge = randomBytes(24).toString('base64url'); c.issued = this.now()
- res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ challenge: c.challenge })); return
- }
- res.writeHead(404).end()
- }
- void handle().catch(() => { if (!res.headersSent) res.writeHead(400); res.end() })
- })
- this.server = server
- server.on('upgrade', (req, socket, head) => {
- if (!this.local(req, true)) { socket.end('HTTP/1.1 403 Forbidden\r\n\r\n'); return }
- const url = new URL(req.url ?? '/', this.origin)
- const [token, route] = url.pathname.slice(1).split('/')
- const v = [...this.viewers.values()].find(v => v.token === token)
- if (v && route === 'presence' && v.phase !== 'closed' && v.pages.size < 16) {
- const page = acceptStreamWebSocket(req, socket, head)
- v.pages.add(page)
- page.onClose(() => v.pages.delete(page))
- return
- }
- const device = v?.devices.find(d => d.id === url.searchParams.get('deviceId'))
- if (!v || !device || route !== 'stream' || v.phase === 'closed' || v.connections.size >= 16) { socket.end('HTTP/1.1 403 Forbidden\r\n\r\n'); return }
- const sink = acceptStreamWebSocket(req, socket, head)
- const c: Connection = { id: randomUUID(), deviceId: device.id, sink, challenge: randomBytes(24).toString('base64url'), issued: this.now(), painted: 0, connectedAt: this.now(), media: false }
- // The grace timestamp is not a rendered-frame receipt.
- v.connections.set(c.id, c)
- sink.sendText(JSON.stringify({ type: 'connection', connectionId: c.id, challenge: c.challenge }))
- let closed = false
- sink.onClose(() => {
- closed = true; c.release?.(); v.connections.delete(c.id)
- if (v.connections.size === 0 && v.phase !== 'closed' && !v.error) v.phase = 'disconnected'
- })
- const wrapped: ScrcpyStreamSink = { ...sink, sendBinary: data => { c.media = true; sink.sendBinary(data) }, sendText: text => {
- const event = JSON.parse(text) as { type: string; message?: string }
- if (event.type === 'error') { v.phase = 'disconnected'; sink.sendText(text); return }
- if (event.type === 'session' || event.type === 'reset') {
- c.media = false; c.challenge = randomBytes(24).toString('base64url'); c.issued = this.now()
- sink.sendText(JSON.stringify({ type: 'connection', connectionId: c.id, challenge: c.challenge }))
- }
- sink.sendText(text)
- } }
- void this.streams.subscribe(device, wrapped).then(release => { if (closed) release(); else c.release = release }).catch(error => {
- sink.sendText(JSON.stringify({ type: 'error', message: String(error) })); sink.close(1011, 'video_failed')
- })
- })
- server.once('error', reject)
- server.listen(0, '127.0.0.1', () => {
- const address = server.address()
- if (!address || typeof address === 'string') { reject(new Error('viewer_listen_failed')); return }
- this.origin = `http://127.0.0.1:${address.port}`; resolve()
- })
- })
- return this.starting
- }
-}
+export * from '../../packages/device-runtime/src/viewer.ts'
diff --git a/workbuddy-plugin/src/websocket.ts b/workbuddy-plugin/src/websocket.ts
index 1e0afd6..1152f84 100644
--- a/workbuddy-plugin/src/websocket.ts
+++ b/workbuddy-plugin/src/websocket.ts
@@ -1,105 +1 @@
-// Ported from the repository video transport; see VIDEO-NOTICE.md.
-import { createHash } from 'node:crypto'
-import type { IncomingMessage } from 'node:http'
-import type { Duplex } from 'node:stream'
-import type { ScrcpyStreamSink } from './scrcpy-stream.ts'
-
-const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
-
-function frame(opcode: number, payload: Buffer): Buffer {
- const size = payload.length
- const header = size < 126 ? Buffer.allocUnsafe(2) : size <= 0xffff ? Buffer.allocUnsafe(4) : Buffer.allocUnsafe(10)
- header[0] = 0x80 | opcode
- if (size < 126) header[1] = size
- else if (size <= 0xffff) { header[1] = 126; header.writeUInt16BE(size, 2) }
- else { header[1] = 127; header.writeBigUInt64BE(BigInt(size), 2) }
- return Buffer.concat([header, payload])
-}
-
-/** Minimal one-way WebSocket peer for the plugin's same-origin binary stream. */
-export function acceptStreamWebSocket(request: IncomingMessage, socket: Duplex, head: Buffer): ScrcpyStreamSink {
- const key = request.headers['sec-websocket-key']
- if (request.method !== 'GET' || request.headers.upgrade?.toLocaleLowerCase() !== 'websocket' || (typeof key !== 'string' || !/^[A-Za-z0-9+/]{22}==$/.test(key)) || request.headers['sec-websocket-version'] !== '13') {
- socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')
- throw new Error('invalid_websocket_upgrade')
- }
- const accept = createHash('sha1').update(`${key}${WS_GUID}`).digest('base64')
- socket.write([
- 'HTTP/1.1 101 Switching Protocols',
- 'Upgrade: websocket',
- 'Connection: Upgrade',
- `Sec-WebSocket-Accept: ${accept}`,
- '\r\n',
- ].join('\r\n'))
- let closed = false
- let closeNotified = false
- const closeListeners = new Set<() => void>()
- let input = Buffer.alloc(0)
- const send = (opcode: number, payload: Buffer): void => {
- if (!closed && !socket.destroyed) socket.write(frame(opcode, payload))
- }
- const consume = (chunk: Buffer): void => {
- if (input.length + chunk.length > 8192) { socket.destroy(); return }
- input = Buffer.concat([input, chunk])
- while (input.length >= 2) {
- const masked = (input[1]! & 0x80) !== 0
- if (!masked || (input[0]! & 0x70) !== 0 || (input[0]! & 0x80) === 0) { socket.destroy(); return }
- let length = input[1]! & 0x7f
- let offset = 2
- if (length === 126) {
- if (input.length < 4) return
- length = input.readUInt16BE(2); offset = 4
- } else if (length === 127) {
- if (input.length < 10) return
- const large = input.readBigUInt64BE(2)
- if (large > 125n) { socket.destroy(); return }
- length = Number(large); offset = 10
- }
- if (length > 125 || ![8, 9, 10].includes(input[0]! & 0x0f)) { socket.destroy(); return }
- const maskBytes = masked ? 4 : 0
- if (input.length < offset + maskBytes + length) return
- const opcode = input[0]! & 0x0f
- let payload = Buffer.from(input.subarray(offset + maskBytes, offset + maskBytes + length))
- if (masked) {
- const mask = input.subarray(offset, offset + 4)
- payload = Buffer.from(payload.map((value, index) => value ^ mask[index % 4]!))
- }
- input = input.subarray(offset + maskBytes + length)
- if (opcode === 0x8) { closed = true; socket.end(frame(0x8, payload)); return }
- if (opcode === 0x9) send(0xA, payload)
- }
- }
- socket.on('data', chunk => consume(Buffer.from(chunk)))
- const notifyClosed = (): void => {
- if (closeNotified) return
- closeNotified = true
- closed = true
- for (const listener of closeListeners) listener()
- closeListeners.clear()
- }
- socket.once('end', () => { notifyClosed(); socket.destroy() })
- socket.once('close', notifyClosed)
- socket.once('error', notifyClosed)
- if (head.length > 0) consume(head)
- return {
- sendText: text => send(0x1, Buffer.from(text, 'utf8')),
- sendBinary: data => send(0x2, data),
- bufferedBytes: () => Number((socket as Duplex & { writableLength?: number }).writableLength ?? 0),
- close(code = 1000, reason = '') {
- if (closed) return
- closed = true
- const reasonBuffer = Buffer.from(reason, 'utf8').subarray(0, 123)
- const payload = Buffer.allocUnsafe(2 + reasonBuffer.length)
- payload.writeUInt16BE(code, 0)
- reasonBuffer.copy(payload, 2)
- socket.end(frame(0x8, payload))
- notifyClosed()
- const timer = setTimeout(() => socket.destroy(), 250)
- timer.unref()
- },
- onClose(listener) {
- if (closed) listener()
- else closeListeners.add(listener)
- },
- }
-}
+export * from '../../packages/device-runtime/src/websocket.ts'
diff --git a/workbuddy-plugin/tsconfig.json b/workbuddy-plugin/tsconfig.json
index cb700cf..f46e707 100644
--- a/workbuddy-plugin/tsconfig.json
+++ b/workbuddy-plugin/tsconfig.json
@@ -3,19 +3,25 @@
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
- "lib": ["ES2023", "DOM", "DOM.Iterable"],
- "types": ["node"],
- "rootDir": "src",
- "outDir": "lib",
- "declaration": true,
- "rewriteRelativeImportExtensions": true,
+ "lib": [
+ "ES2023",
+ "DOM",
+ "DOM.Iterable"
+ ],
+ "types": [
+ "node"
+ ],
"esModuleInterop": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
- "skipLibCheck": true
+ "skipLibCheck": true,
+ "noEmit": true,
+ "allowImportingTsExtensions": true
},
- "include": ["src/**/*.ts"]
+ "include": [
+ "src/**/*.ts"
+ ]
}
diff --git a/workbuddy-plugin/tsdown.config.ts b/workbuddy-plugin/tsdown.config.ts
new file mode 100644
index 0000000..d909c32
--- /dev/null
+++ b/workbuddy-plugin/tsdown.config.ts
@@ -0,0 +1,11 @@
+import { readdirSync } from 'node:fs'
+import { defineConfig } from 'tsdown'
+
+// Retain the existing JS entry points consumed by the installer and package QA.
+export default defineConfig({
+ entry: Object.fromEntries(readdirSync('src').filter(name => name.endsWith('.ts'))
+ .map(name => [name.slice(0, -3), `src/${name}`])),
+ outDir: 'lib', format: ['esm'], platform: 'node', target: 'node22',
+ fixedExtension: false, dts: false, clean: true,
+ deps: { neverBundle: [/^@modelcontextprotocol\//, /^sharp(?:\/|$)/, /^ajv(?:\/|$)/, /^tar(?:\/|$)/, /^yauzl(?:\/|$)/] },
+})
diff --git a/workbuddy-plugin/vitest.config.ts b/workbuddy-plugin/vitest.config.ts
new file mode 100644
index 0000000..ebd6658
--- /dev/null
+++ b/workbuddy-plugin/vitest.config.ts
@@ -0,0 +1,2 @@
+import { defineConfig } from 'vitest/config'
+export default defineConfig({ test: { include: ['tests/**/*.spec.ts', '../packages/device-runtime/tests/**/*.spec.ts'] } })