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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions .github/workflows/opengui-codex-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -33,24 +35,24 @@ 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
node scripts/test-installer.mjs
- 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
6 changes: 3 additions & 3 deletions .github/workflows/opengui-codex-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,17 @@ 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
pnpm package
- 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:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/workbuddy-plugin-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
301 changes: 301 additions & 0 deletions docs/plans/2026-09-17-codex-workbuddy-runtime-convergence.zh-CN.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions docs/plans/simple-plugin-install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
21 changes: 21 additions & 0 deletions packages/device-runtime/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions packages/device-runtime/README.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 34 additions & 0 deletions packages/device-runtime/VIDEO-NOTICE.md
Original file line number Diff line number Diff line change
@@ -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.
114 changes: 114 additions & 0 deletions packages/device-runtime/build.mjs
Original file line number Diff line number Diff line change
@@ -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')
}
9 changes: 9 additions & 0 deletions packages/device-runtime/src/contracts.ts
Original file line number Diff line number Diff line change
@@ -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
}
41 changes: 41 additions & 0 deletions packages/device-runtime/src/viewer-page.ts
Original file line number Diff line number Diff line change
@@ -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`<!doctype html><html lang="zh"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>OpenGUI · 实时设备墙</title>
<style>body{margin:0;background:#101318;color:#edf2f6;font:14px system-ui}header{padding:16px;border-bottom:1px solid #303640}h1{font-size:18px;margin:0 0 8px}#wall{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;padding:16px}.phone{background:#1b2029;border-radius:14px;padding:12px}canvas{display:block;max-width:100%;max-height:75vh;margin:auto;background:#080a0d}p{color:#aeb9ca;overflow-wrap:anywhere}button{background:#d9efcf;border:0;border-radius:8px;padding:8px 16px;cursor:pointer}canvas.stale{opacity:.3}</style>
<header><h1>OpenGUI 实时设备墙</h1><span id="task">准备中</span><p>画面仅供观看。停止 AI 任务请使用聊天中的停止入口。</p></header><main id="wall"></main>
<script>
const base=location.pathname, cards=new Map();let ended=false;
const presence=new WebSocket(location.origin.replace('http:','ws:')+base+'presence');
presence.onclose=()=>{if(!ended)document.querySelector('#task').textContent='viewer_disconnected:观看服务已关闭,请重新打开设备墙'};

function codec(data){for(let i=0;i+7<data.length;i++){const n=data[i]===0&&data[i+1]===0?(data[i+2]===1?i+3:data[i+2]===0&&data[i+3]===1?i+4:-1):-1;if(n>=0&&(data[n]&31)===7)return 'avc1.'+[data[n+1],data[n+2],data[n+3]].map(v=>v.toString(16).padStart(2,'0')).join('')}return 'avc1.42e01e'}
function reset(c){c.rendered=false;c.waitKey=true;c.config=[];if(c.decoder){try{c.decoder.close()}catch{}c.decoder=null}c.canvas.classList.add('stale')}
function stop(c){clearTimeout(c.timer);if(c.ws){c.ws.onclose=null;c.ws.close();c.ws=null}reset(c)}
function connect(c){
if(document.hidden||ended)return;stop(c);c.message.textContent='正在连接实时视频…';
if(!globalThis.VideoDecoder){c.message.textContent='decoder_unavailable:当前浏览器没有 VideoDecoder';return}
const ws=new WebSocket(location.origin.replace('http:','ws:')+base+'stream?deviceId='+encodeURIComponent(c.id));c.ws=ws;ws.binaryType='arraybuffer';
ws.onmessage=e=>{if(c.ws!==ws)return;try{
if(typeof e.data==='string'){const m=JSON.parse(e.data);if(m.type==='connection'){c.connectionId=m.connectionId;c.challenge=m.challenge}
else if(m.type==='session'||m.type==='reset')reset(c);
else if(m.type==='error'){c.message.textContent=m.message;c.canvas.classList.add('stale')}
return}
const data=new Uint8Array(e.data),flags=data[0],key=!!(flags&2),payload=data.subarray(9);
if(flags&1){reset(c);c.config=[payload.slice()];return}
if(c.decoder&&c.decoder.decodeQueueSize>3){c.decoder.reset();c.decoder.close();c.decoder=null;c.waitKey=true}
if(c.waitKey&&!key)return;
let bytes=payload;
if(key){let size=payload.length;for(const p of c.config)size+=p.length;bytes=new Uint8Array(size);let i=0;for(const p of c.config){bytes.set(p,i);i+=p.length}bytes.set(payload,i)}
if(!c.decoder){const context=c.canvas.getContext('2d',{alpha:false,desynchronized:true});
c.decoder=new VideoDecoder({output(frame){try{if(c.ws!==ws||document.hidden)return;if(c.canvas.width!==frame.displayWidth)c.canvas.width=frame.displayWidth;if(c.canvas.height!==frame.displayHeight)c.canvas.height=frame.displayHeight;context.drawImage(frame,0,0);c.canvas.classList.remove('stale');c.rendered=true;c.frames++;c.lastPTS=frame.timestamp;c.decodedAt=Date.now();c.message.textContent='实时播放';void receipt(c)}finally{frame.close()}},error(err){c.message.textContent='decode_failed: '+err.message;ws.close()}});
c.decoder.configure({codec:codec(bytes),optimizeForLatency:true,hardwareAcceleration:'prefer-hardware'})}
c.waitKey=false;c.decoder.decode(new EncodedVideoChunk({type:key?'key':'delta',timestamp:Number(new DataView(e.data).getBigUint64(1)),data:bytes}));
}catch(err){c.message.textContent='decode_failed: '+err.message;ws.close()}};
ws.onclose=()=>{if(c.ws!==ws)return;c.ws=null;reset(c);if(document.hidden||ended)return;if(c.retries++<3){c.message.textContent='视频连接中断,正在重连…';c.timer=setTimeout(()=>connect(c),500*2**c.retries)}else c.message.textContent='video_disconnected:重连已停止,请点击重试'};
}
async function receipt(c){if(document.hidden||!c.rendered||!c.ws||c.pending||Date.now()-c.lastReceipt<1000)return;c.pending=true;c.lastReceipt=Date.now();const ws=c.ws;try{const r=await fetch(base+'frame',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({connectionId:c.connectionId,challenge:c.challenge,deviceId:c.id,visible:true})});if(r.ok){const m=await r.json();if(c.ws===ws)c.challenge=m.challenge}else{c.message.textContent='frame_receipt_rejected';ws.close()}}catch{}finally{c.pending=false}}
function card(d){const el=document.createElement('section');el.className='phone';const name=document.createElement('h2');name.textContent=d.name;const canvas=document.createElement('canvas'),message=document.createElement('p'),retry=document.createElement('button');retry.textContent='重试视频';el.append(name,canvas,message,retry);document.querySelector('#wall').append(el);const c={id:d.id,canvas,message,ws:null,decoder:null,config:[],waitKey:true,retries:0,frames:0,lastReceipt:0,pending:false,rendered:false};retry.onclick=()=>{c.retries=0;connect(c)};cards.set(d.id,c);connect(c)}
async function status(){try{const r=await fetch(base+'status');if(!r.ok)throw Error('viewer_unavailable');const s=await r.json();document.querySelector('#task').textContent=s.message||({preparing:'准备中',executing:'执行中',ended:'任务已结束 · 继续投屏'})[s.taskState];if(s.state==='closed'){ended=true;presence.close();for(const c of cards.values())stop(c);document.querySelector('#task').textContent='设备墙已关闭';return}for(const d of s.devices)if(!cards.has(d.id))card(d);for(const c of cards.values())void receipt(c)}catch{document.querySelector('#task').textContent='viewer_disconnected:服务已退出,请重新打开设备墙'}}
document.addEventListener('visibilitychange',()=>{for(const c of cards.values()){if(document.hidden){stop(c);c.message.textContent='页面隐藏,播放已暂停'}else{c.retries=0;connect(c)}}});addEventListener('pagehide',()=>{ended=true;presence.close();for(const c of cards.values())stop(c)});void status();setInterval(()=>{if(!document.hidden&&!ended)void status()},1000);
</script></html>`
}
Loading
Loading