Review: opencode-cockpit line (Tailscale host deployment, desktop shell, iOS 27 upgrade report) by XinHu-001 · Pull Request #3 · BB-84C/opencode-mobile-solution · GitHub
Skip to content

Review: opencode-cockpit line (Tailscale host deployment, desktop shell, iOS 27 upgrade report) - #3

Open
XinHu-001 wants to merge 60 commits into
BB-84C:mainfrom
hairen-org:main
Open

XinHu-001 wants to merge 60 commits into
BB-84C:mainfrom
hairen-org:main

Conversation

@XinHu-001

@XinHu-001 XinHu-001 commented Sep 24, 2026 •

Copy link
Copy Markdown

This PR brings the opencode-cockpit line back to your repository for review. It is opened so you can read the full history and diff before we decide how to merge the two projects. Please do not treat it as a request to merge main as-is; the last section lists what I think should and should not come across.

  • Head: hairen-org/opencode-mobile-solution main @ 6e79fa4 (tag v2.0.0 is d9fcf71; the two commits after it renumber the desktop shell and update the docs) (a real fork of this repo)
  • Base: your main @ fa368d2, which is also the merge base. 60 commits ahead, 0 behind, history kept as it happened. The one exception is 98f6b72, which squashes our own two-commit PR for the session auto-naming fix described below.
  • Size: 188 files, +14,545 / −11,107 lines

iOS 27: a data-preserving upgrade no longer crashes

docs/ios27-crash-investigation.md says the root cause of the iOS 27 launch crash is not confirmed because no termination report is available, and that the next confirmation step is a build with the cache fix, tested by the original reporter while keeping existing data.

I ran that step on my own phone:

  • Device: iPhone 16, iOS 27
  • Build: this branch at version 1.1.0, which contains your cache limit and old-cache cleanup through merge commit 7dd3aa7
  • Install type: an in-place upgrade over the previous install of this line, same bundle identifier (com.skylerhu.opencodemobile) and same signing team. Not a fresh install. All existing app data was kept.
  • Result: the crash is gone. Cold launch works, and returning from the background works.

This confirms the symptom went away under the exact condition your doc asked for. It does not identify the mechanism: I have no crash report from before the upgrade, so I cannot say the cache was the cause. One caveat on comparability: my phone ran this line's bundle, not your TestFlight bundle app.opencodemobile.client, so this is the same test condition on a different install, not the original reporter's device.

Sessions opened from the app are named again (added 2026-09-24, 98f6b72)

createSession in app/src/opencode/client.ts defaulted the title to the literal New Session and always sent it. The OpenCode core names a session automatically only while its title still matches its own placeholder, ^(New session - |Child session - )<ISO 8601 with milliseconds>$, so every session created from the app kept that literal until someone renamed it by hand. No error, no log line. Your code has the same default, so this applies to your app as well.

The fix sends title only when the caller chose one. Four tests assert the request body; two of them fail against the old client. It reaches a phone only after a rebuild and reinstall, and sessions already titled New Session are not renamed retroactively.

The paperclip attaches a real file now (added 2026-09-29, 23324dc)

In your main the paperclip in the composer only inserts @, and the reference list it opens shows agents until you type a query, so it reads as "mention an agent". Nothing could send a photo or a PDF.

It now opens Photos or Files on the phone and the file dialog on the desktop, and sends each pick as a file part with a data URL next to the text, which the prompt endpoint already accepts. Images up to 5 MB, PDFs up to 20 MB, five per message; the type is read from the file's bytes. A model whose declared capabilities exclude that kind is refused before sending. The old reference behaviour moved to its own @ button. Sent files show in the transcript by name, and the phone's transcript cache drops the inline bytes so a photo cannot use up the bounded cache.

Checked through the relay against a live backend: a model read a two-colour PNG and a code word from a PDF sent this way; in a browser the pick, chip, send and remove steps worked. This adds two native modules (expo-image-picker, expo-document-picker), so it needs a native rebuild, plus NSPhotoLibraryUsageDescription, which the app.json plugin entry supplies.

Copy anything, answer subagent permissions, get alerted (added 2026-10-05, ff7cf90, 0c83cd1, ee57e9c, 29e07d6)

Three problems from daily use, all fixed on both the phone and the desktop app.

Partial text selection. On iOS a React Native Text with selectable only offers Copy for its whole string, and every paragraph and code block was its own view, so nobody could copy a few words or a span across paragraphs. Each message now renders as one UITextView through @bsky.app/react-native-uitextview 2.7.1 (Bluesky's library, tested by its authors against RN 0.86). markdown-spans.ts flattens the markdown into styled runs: block structure becomes newlines, list markers and quote bars; bold, italic, code, links and headings stay as attributes. A selection can start and end anywhere inside a message. For selections across messages, the ... menu's Select text opens the whole loaded conversation as one text, scrolled to that message. The card no longer opens its menu on long press, which belongs to selection now; a permanent ... button does it. On the desktop, Windows Ctrl+C / Ctrl+A and macOS Cmd+A were claimed by the TUI keymap (exit, clear input, line start); the shell now leaves them to the system and adds a right-click menu with Copy and Select All. This adds a native module, so it needs a native rebuild.

Subagent permission requests were invisible. The store kept only the open session's requests, and a subagent asks in its own child session, so the parent sat blocked with nothing on screen. Requests are now kept per directory. The parent session shows its subagents' requests labelled From subagent · <title> and can answer them in place; only the session's own requests lock its input. Questions were never affected, since subagents cannot ask.

Alerts. A watch on /global/event (it receives events from every directory) feeds a N waiting for you badge on the session list, an in-app banner on the phone that opens the session, and on the desktop a system notification that does the same. The desktop app now stays in the tray or menu bar when its window closes, starts at login by default (a tray toggle turns it off), and rebuilds its window if the renderer dies, so an alert never points at a window that will not open. macOS packages are re-signed ad hoc under their bundle id, because a build signed as Electron is never registered with Notification Center and every alert it sends is dropped. On Windows a toast only appears for an app with a Start Menu shortcut carrying its AppUserModelID; the build has no installer, so it writes that shortcut itself on first launch.

Checked live: a child session with an ask rule for bash raised a real permission request. The desktop app showed the banner, the badge and the card in the parent session, Allow once cleared it on the server and the command ran. On the desktop a real mouse drag selected 1,094 characters across message cards. Partial selection on the phone was confirmed by hand on an iPhone 16. Not verified: clicking a system notification, since this Mac was in Do Not Disturb.

A todo board, and failed turns shown in red (2.0.0, d9fcf71)

Todo board. The TUI shows the main agent's todo list in its sidebar, which is how you see a long task's progress and plan. The app already loaded todos (GET /session/{id}/todo, todo.updated) and never displayed them. They now show as a bar at the top of the session, with done/total and the task in progress, that expands to the full list ([✓] [•] [ ] [✗]). It is open by default on the desktop and folded on the phone, and the TUI's <leader>b (sidebar toggle) or the session menu flips it.

Failed turns. A failed turn is not output. The server writes { name, data: { message } } to the assistant message's info.error and emits session.error. The app showed neither, so a session that stopped on, for example, Internal Server Error: No available Claude accounts support the requested model looked as if it had stalled for no reason. Now:

  • A message error renders as a red box at the end of that reply, so it also shows when an old session is opened.
  • An error with no message (a missing model, say) shows in a red box above the prompt, until dismissed or a new turn starts.
  • A retry status says why it is retrying.
  • Stop shows only as a grey Interrupted.
  • The background watch also follows session.error, so a failing subagent or unopened session marks its list card Stopped, raises a desktop notification, and shows a phone banner.
  • The server's own JavaScript stack trace is stripped from the text.

Checked live on the desktop app against this machine's backend:

  • a real agent's todo list appeared and advanced 0/3 → 1/3 as it worked;
  • a real session.error (unknown model) appeared within 3 s in the open session, and on the list card and banner when the session was not open;
  • an old session whose last reply had failed with the No available Claude accounts error showed it in red.

Not yet verified on the phone.

Also in this release: macOS packaging now signs the bundle in a temporary copy. Under iCloud the bundle folder was re-tagged between clearing and signing, which failed the build intermittently. App version 2.0.0; the desktop shell is numbered 2.0.0 too (31070c4), since it renders the same UI.

After the release, 31070c4 numbers the desktop shell 2.0.0 like the app, and 6e79fa4 brings the README, docs/architecture.md and relay/README.md up to the tailnet setup and these clients. It also says how to build each part from source, since no public binaries are published.

Formulas render as math, and copy back as TeX (1.2.0, 974e76b)

Most of my questions to OpenCode are mathematical, and answers given in the chat showed raw $...$ LaTeX. Replies now render $...$, \(...\), $$...$$ and \[...\] with KaTeX, the engine HTML reports use. A single $ follows the Pandoc rule, so "$5 and $10" stays text; math inside code is left alone; a formula KaTeX cannot parse shows its source in red instead of blanking the message. Copying a selection that contains math puts the TeX source on the clipboard with line breaks intact (KaTeX's own copy-tex extension drops paragraph breaks in a React Native Web page, so this is a small replacement, math-copy.ts).

  • Desktop and web: typeset inline (MathView.web.tsx). The renderer build places KaTeX's fonts beside its stylesheet, since Metro copies the CSS but not the fonts it references, and fails if any referenced font is missing.
  • iOS: no native text view can typeset TeX, so only a message that contains math is rendered in a WebView (react-native-webview 13.16.1, a native module), sized from the height the page reports. Messages without math keep the native selectable text. KaTeX's fonts are inlined in a generated module (npm run build:katex-css after a katex upgrade) because a page loaded from an HTML string cannot reliably read font files from the app bundle. Select text uses a WebView too when the conversation has math.

Checked: on the desktop app, five formulas typeset with KaTeX fonts loaded, and a real Cmd+C put the TeX source on the system clipboard. The iOS page was checked in WebKit (rendering, height reporting, copy) and then by hand on an iPhone.

This commit also sets the app version to 1.2.0 and the desktop shell to 0.2.0.

The largest difference: VPS and tunnel transport removed, replaced by tailnet direct access

This is the part that needs the most attention, because merging it as-is would remove a deployment path you support today.

Removed (commit d50656c):

  • clients/macos/ (19 files) and clients/windows/ (19 files): machine-side launchers, supervisors, and the FRP tunnel plumbing. About 9,100 lines.
  • Relay machine enrollment: the OAuth device-code flow, /api/machine/*, the console's Machines and Pending sections, and the frp-ssh transport payload. relay/lib/pairing-store.mjs and relay/lib/passkey-pairing.mjs each shrink by roughly 300 lines.
  • relay/deploy/deploy-relay.sh and docs/windows-relay-oauth-spec.md.

Replaced by:

  • host/ (10 new files): deploy-macos.sh installs the backend and relay as launchd jobs on one Mac and publishes the relay with tailscale serve, then prints the one-time owner passkey registration step. Alongside it: uninstall-macos.sh, renew-ios-app.sh (rebuild and re-sign the iPhone app), and opencode-shared (attaches a local opencode to the running backend instead of starting a second one).
  • The relay runs on the same host as its backends and binds loopback only. Phones reach it over the tailnet, and Tailscale terminates TLS for the *.ts.net name. No public listener, no port forwarding, no tunnel to maintain. docs/architecture.md section 2 is rewritten to match.

Why: I only run backends on the machine the relay lives on, and Tailscale already solves remote reach and certificates. The enrollment and tunnel code became unreachable in that topology, so I deleted it rather than carry it untested.

Upgrade path for existing relays: a state file written by the tunnel-era relay carries a machines array. validateState drops that field and migrates to version 3, keeping the owner credential and paired devices. An unknown state version is still rejected. The enrollment routes now return 404. Three new tests cover these cases, each verified to fail against the pre-deletion tree.

What you would lose by merging this part: Windows machines reached over FRP, the VPS relay topology, and remote machine enrollment. The word "machine" stays in the app, where it now means a relay target.

Three deliberate differences

  1. ios.bundleIdentifier is com.skylerhu.opencodemobile (yours is com.example.opencodemobile). This is what made my install an upgrade instead of a new app, and I am keeping it on this line. Commit 80e1d9e changed the app test to assert the identifier's shape rather than one fixed value, so either value passes. On merge you would keep your own.
  2. Version 2.0.0 (this line forked at 1.1.0; the desktop shell carries the same number). Your app.json still says 1.0.0 (your 1.0.1 and 1.0.3 are TestFlight build numbers that were never written back). The separate number marks builds that include this line's additions.
  3. The removed directories described above.

Everything else in the diff

Area Files Lines What it is
app/ 93 (63 new, 0 deleted) +7,376 / −174 Desktop keyboard layer that the phone never loads (src/ux/desktop-*), a machine picker as the first screen, a new-session screen, command palette, notice toast, delete-session with its own confirmation, a Top button in the transcript, attachments, and the 1.2 / 2.0 work described above: any-range selection, subagent permissions, attention badge and notifications, typeset math, the todo board and red error boxes. None of your features were removed; the one changed interaction is that the message menu opens from a permanent ... button instead of a long press, which now belongs to text selection. Full list: docs/differences-from-upstream.md.
desktop/ 19, all new +3,721 Electron shell around the shared UI, with a real keybinding layer, tray residency, start at login and system notifications. Builds macOS and Windows packages.
host/ 10, all new +1,368 macOS one-command deployment (above).
relay/ 15 +509 / −1,186 Enrollment removal (above), the items below, and a README that now describes the tailnet setup.
deploy-notes/ 5, all new +964 My Chinese deployment notes, an audit report, and an earlier PR draft. Personal to my setup.
docs/, README.md, .gitignore, clients/README.md 8, including the deleted spec above +607 / −617 README and docs/architecture.md rewritten for the single-host tailnet setup (the English half had still described the VPS relay, the frp tunnel, machine enrollment and the clients/ launchers), a Chinese introduction with credit to you, docs/differences-from-upstream.md, a note on why clients behind a third-party VPN cannot reach the tailnet, and fixed links between the two product specs.

Relay changes beyond the enrollment removal, which docs/differences-from-upstream.md does not cover because it only describes app/:

  • One CORS policy (relay/lib/cors.mjs, commit a01752e). The pairing module and the proxy both answered preflights and had drifted apart. The narrower one shadowed the other and blocked the desktop app's X-OpenCode-Target / X-OpenCode-Directory requests. The proxy now also adds Access-Control-Allow-Origin: * and Access-Control-Expose-Headers to forwarded responses when the backend does not set them, because without exposed headers the paged session list read X-Next-Cursor as null and silently showed one page. Your preflight already answered with *. What is new is that forwarded response bodies become readable cross-origin. Auth is a bearer header rather than a cookie, so a page still needs a token, but this matters more in your publicly reverse-proxied topology than in mine.
  • The two fixes from PR fix(relay): release aborted SSE streams and keep pairing scope bounded #2 (pairing scope stays bounded, aborted SSE streams release their upstream connection) are included here, applied in commit 526b61f with a different patch shape. If you merge fix(relay): release aborted SSE streams and keep pairing scope bounded #2 first, expect overlap in relay/lib/proxy.mjs and relay/lib/pairing-store.mjs.

Branches in the fork: main is the one under review. fix/relay-stream-teardown is the PR #2 branch. upstream-main is an old pointer to your d89d493 and can be ignored.

Tests

Run on 25d7da4, with app and desktop re-run on d9fcf71:

  • app: npx vitest run, 517 passed, 4 skipped on d9fcf71 (460 on 23324dc). The only type errors are two existing ones in src/opencode/pairing.test.ts.
  • relay: npm test, 56 passed (not re-run; nothing since 23324dc touches relay/)
  • desktop: npm test, 44 passed on d9fcf71; a Windows x64 package cross-builds from the Mac. Notifications on a real Windows machine are not verified yet.
  • host: node --test test/plist.test.mjs, 7 passed. I did not run test/uninstall-scope.test.mjs on this machine because it invokes uninstall-macos.sh and this Mac hosts a live deployment.

A secret scan over the full history of all three pushed branches found no real credentials. The 64-character hex strings are SHA-256 checksums of release artifacts. Credential-looking strings in tests are fixtures.

中文速览:两边差在哪,合并时要注意什么

两边的手机端基本一致。你的功能我一个没删,iOS 27 相关的改动(Expo 57、scene 生命周期、会话缓存上限、权限请求卡片)我全部合进来了。我多加的东西有两块:一是只在桌面端加载的键盘操作层,外加 Electron 桌面壳;二是选机器、新建会话、命令面板、删除会话这几个界面。

差异最大的是部署方式。你那边走 VPS 中继加 FRP/SSH 隧道,远端机器通过 OAuth 注册进来。我这边把中继和后端放在同一台 Mac 上,中继只监听本机回环地址,手机走 Tailscale 内网直连,所以 clients/macos、clients/windows、relay/deploy 和中继里的机器注册流程都被我删了,换成 host/deploy-macos.sh。旧状态文件升级时会丢掉 machines 字段,已配对的设备和主人凭证会保留。

iOS 27 闪退:iPhone 16,iOS 27,1.1.0 覆盖安装,同 bundle id、同签名团队,是原地升级,旧数据都在。升级后不再闪退,冷启动和从后台切回都正常。这是你文档里缺的那一步确认。它只能证明症状消失了,根因还没法定,因为我手上没有升级前的崩溃报告。

会话自动命名:app 新建会话时一直把标题写成 New Session 发出去,而内核只给还带着占位标题的会话自动起名,所以从 app 开的会话永远叫 New Session。你那边的 createSession 也是同样的默认值。最新提交 98f6b72 改成不指定标题时就不发,让服务端自己命名。要重新构建装到手机上才生效,已经叫 New Session 的旧会话不会被补名。

附件:你原版里输入框右下角的回形针其实只是插入一个 @,点开先列出的是席位角色,所以看起来像“@角色”,并不能发文件。23324dc 把它改成真附件:手机上选相册或文件,桌面上弹文件对话框,图片和 PDF 随消息直接发给模型;原来的 @ 引用挪到单独的 @ 按钮。新增了两个原生模块,要重新构建原生工程。

复制、子 agent 权限和提醒(2026-10-05,ff7cf90、0c83cd1、ee57e9c、29e07d6):

  • 复制:iOS 上 RN 的 Text 只能整段复制,所以每条消息改成用 Bluesky 的 react-native-uitextview 渲染成一整块,消息里任意几个字、跨段落、跨代码块都能选。要跨多条消息选,就用「...」→「Select text」,它会打开整段对话并停在当前消息。长按留给选字,消息菜单改由常驻的「...」按钮打开。桌面端不再拦截 Windows 的 Ctrl+C / Ctrl+A 和 Mac 的 Cmd+A,右键菜单可以复制、全选。新增了原生模块,要重新构建原生工程。
  • 子 agent 权限:原来只保存当前会话的权限请求,子 agent 在自己的子会话里申请,父会话就卡住,界面上什么也看不到。现在按目录保存,父会话里能看到并直接批准,卡片标注「From subagent」。
  • 提醒:通过监听全局事件,会话列表上显示「N waiting for you」角标;手机前台出横幅,点了跳到对应会话;桌面端发系统通知。桌面分机关窗口后常驻托盘,默认开机自启,页面进程崩了会自动重建窗口。Mac 包会用自己的 bundle id 补签名,否则通知中心不认这个 App,通知会被悄悄丢掉。

公式(1.2.0,974e76b):回答里的 $...$、\(...\)、$$...$$、\[...\] 现在用 KaTeX 排成公式,和 HTML 报告同一个引擎;「$5 和 $10」这类金额不会被当成公式。复制选区时,里面的公式会变回 $...$ 源码,换行保留。桌面端直接排版;iOS 上只有带公式的消息改用 WebView 显示(新增原生模块 react-native-webview),字体内嵌在生成的样式里。版本号改为 App 1.2.0、桌面壳 0.2.0。

todo 看板与报错(2.0.0,d9fcf71):

  • todo 看板: 会话页顶部新增一条可展开的 todo 栏,和 TUI 侧栏一样显示进度和当前任务,内容实时更新。
  • 报错: 回合失败时,主机写进消息 info.error 的错误和 session.error 事件,现在都显示成红色报错框。
  • 提醒: 子 agent 或后台会话出错,会话列表上会出现红色「Stopped」,桌面弹系统通知。
  • 版本号: App 和桌面分机统一为 2.0.0。

文档(6e79fa4):README 的英文部分、docs/architecture.md 和 relay/README.md 原来还在介绍 VPS 中继、frp 隧道、机器注册和 clients/ 启动器,这些早已删掉,现在改成单主机 + Tailscale 的实际架构,并写明各部分怎么从源码编译。桌面分机的版本号也和 App 统一成 2.0.0(31070c4)。

合并风险和建议:

  • 别直接合 main。一合进去,你的 Windows/FRP 路线、VPS 拓扑和远端机器注册就全没了。我建议分三块处理:app/、desktop/ 和中继修复可以先合;传输层到底是保留隧道、只用 Tailscale 还是两条都支持,我们当面定;host/ 可以作为「单机 + Tailscale」的可选部署方式放进来。
  • app.json 里的 bundle id 和版本号请保留你自己的。我这边的 com.skylerhu.opencodemobile 不能改回去,改了我手机上就变成装一个新应用,已配对的主机和令牌会被清空。
  • deploy-notes/ 是我个人机器的部署记录,里面写着我的 tailnet 域名,不适合进你的仓库,合并时建议排除。
  • 中继现在给转发的响应加了 Access-Control-Allow-Origin: *。在我这种只有 tailnet 能访问的环境里问题不大;在你那种中继公开在反向代理后面的环境里,建议先评估。
  • PR fix(relay): release aborted SSE streams and keep pairing scope bounded #2 的两个中继修复这里也有。先合哪个都行,另一个会在 proxy.mjs 和 pairing-store.mjs 上重叠,需要手动对一下。

个人配置:
- app.json 改用自己的 bundle identifier
- package.json 由 expo prebuild 自动更新 ios/android 脚本

relay 修复(将单独整理成上游 PR):
- 配对设备的 target 授权范围不再被可用列表放大
- 客户端 SSE 中断时释放上游连接
- 修正测试脚手架失效的连接计数器并补回归测试
Remote access is Tailscale's job now. The relay runs on the same host as the
backends it fronts, binds loopback only, and never sees a tunnel, so everything
that existed to enrol a remote machine and dial it back is dead weight.

Removed:
  - clients/: 38 files of machine-side launchers, supervisors and FRP tunnel
    plumbing, superseded by the host deployment script
  - relay machine enrolment: the OAuth device-code flow, /api/machine/*, the
    console's Machines and Pending sections, and the frp-ssh transport payload
  - relay/deploy/deploy-relay.sh and docs/windows-relay-oauth-spec.md

Kept, because "machine" means two different things here: in the app it is the
user-facing name for a relay target, and that vocabulary stays. machine-status-
monitor.mjs probes each declared target and is unrelated to enrolment; it stays
wired to the target registry, though nothing reads its cache until the multi-
backend work lands.

State files written by the tunnel-era relay carry a machines array. Rather than
reject them, validateState drops the field and migrates to version 3, so an
existing deployment keeps its owner credential and paired devices on upgrade.

Tests: 53 -> 50. Five machine-enrolment cases in pairing-store and one machine
OAuth case in passkey-pairing were removed; three new cases replace them, each
verified to fail against the pre-deletion tree: the enrolment routes now 404,
a tunnel-era state file upgrades without losing devices, and an unknown state
version is still rejected rather than silently treated as a fresh install.
…value

The suite has been red on this branch since the iOS bundle identifier was set
to a personal one: the test still pinned the upstream placeholder. Every
deployment signs with its own id, so pinning any single value reddens the suite
for whoever changes it next. Assert a well-formed reverse-DNS identifier
instead, which still fails on an empty or single-segment value.
Running the real relay shows these paths answer 401, not 404: once the pairing
controller stops claiming a route, the request falls through to the relay's own
bearer check. The 404 in this test is the fixture's fallback for an unclaimed
route, which is a statement about the controller rather than about deployed
behaviour. Name and comment now say so.
… a proxy

Brings up the backends, the relay, and HTTPS on the tailnet in one command.
There is no reverse proxy and no certificate to renew: tailscaled terminates
TLS for the host's own *.ts.net name, which also means nothing listens on a
public interface. Measured on this machine, `tailscale serve` streams SSE
without buffering -- one event per second arrives at 1.001s intervals, 11ms
behind a direct connection -- so the proxy it replaces was only adding a hop.

Two modes. production installs launchd jobs and supervises the backends;
`--managed-backend name:port[:profile]` registers each one as its own relay
target, which is how a client gets a switchable "machine" per profile. shadow
installs nothing and runs beside a deployment that is already serving devices,
registering its backend with --target instead of taking it over, so a new build
can be exercised without touching the live one.

Three rules the scripts enforce rather than document:
  - no path is hardcoded. The Tailscale CLI lives inside the app bundle, and
    /usr/bin/git may be an xcode-select shim that fails; both are probed.
  - ProcessType never reaches a plist. Background puts a job in a throttled
    band where a Node service needs over 90s to bind its port.
  - verification goes through the tailnet name, never 127.0.0.1, because the
    loopback path skips both TLS termination and the proxy hop.

Renders launchd jobs from a module with tests rather than a heredoc, so the
ProcessType ban and the supervision keys are asserted; one test round-trips
through plutil to prove macOS can parse what we write.

Verified by installing, killing the relay to confirm launchd restarts it, and
uninstalling to exit 0 with no residue.
The relay answered its own OPTIONS preflight but forwarded the upstream
response verbatim, so a client with a real origin cleared the preflight and was
then blocked from reading the body. Measured against a live backend: the
preflight carried access-control-allow-origin, the GET that followed carried
nothing. From the client side that looks like a hang rather than a policy
error, which is an expensive thing to debug from inside a renderer.

Supply the header only when the backend did not, so a backend started with
`opencode serve --cors` keeps its own policy instead of being overridden.

The desktop shell loads from its own scheme and is the first client this
affects; phones were unaffected because a native fetch enforces no origin.
…nding layer

The window loads the same React bundle the phone runs. `expo export --platform
web` produces a plain static site, so components, store, themes and business
logic are shared rather than reimplemented; the shell only does the three
things a page cannot do for itself.

Serves the bundle from a registered `cockpit://` scheme rather than file://,
whose null origin the relay's CORS policy cannot name and which disables the
storage APIs the UI uses. Keeps the renderer sandboxed with node integration
off, and exposes three named functions across the bridge instead of a channel
the page can send anything down.

The keybinding layer is the substantive part. A terminal owns every key and a
window does not: tab moves focus, ctrl+w closes the window, ctrl+p prints, and
the leader itself -- ctrl+x -- is Cut inside a text field. 44 chords are claimed
before their default action runs; ctrl+v is deliberately not among them because
opencode's own table marks paste as non-preventing.

Same-chord-different-meaning needed a context model. Taking the table at face
value produced 13 collisions, e.g. ctrl+d as exit, delete-session and
delete-stash at once. Splitting each dialog into its own context and naming the
six list-scoped actions whose names do not reveal their scope brings it down to
three, each irreducible: the upstream table binds <leader>q twice, and up/down
are cursor movement or history depending on where the cursor sits, which is a
runtime fact. A test pins that set, so a fourth cannot appear quietly. Conflicts
are reported rather than resolved by parse order, because the losing action
becomes unreachable and nobody would know.

Verified by running it: the window paints, renders the real UI, and reports no
page errors. The smoke mode asserts on errors the preload actually collects,
not on an array nothing writes to.

Not yet wired: the page receives the dispatched actions but does not consume
them, and the first screen is still the bundle's own Hosts view rather than a
purpose-built host-and-profile picker.
The desktop shell resolves a key against opencode's table and sends back an
action name like session_compact. The app speaks a different vocabulary --
command-palette ids, entrypoints, and a few things only the session screen can
do -- so without a translation the shell would claim the key and nothing would
happen, which is worse than leaving it unbound.

An action absent from the table resolves to `unsupported` rather than being
dropped, so a key that does nothing can say so instead of looking broken.

The mapping is checked rather than trusted: a test walks every target and fails
if it names a command or entrypoint that does not exist. A typo there compiles
cleanly and then silently no-ops at the one moment a user is watching. Verified
by planting one: a single misspelling turns three tests red. Two further tests
assert the reverse direction, that no palette action or entrypoint has been left
without a key.

The bridge itself is inert when the host object is absent, so the phone build
needs no platform branch at the call sites, and a throwing handler is reported
instead of tearing down the subscription -- one bad action should not leave
every later key dead.
… nobody can

Three outcomes: the store does it now, a screen that registered a handler does
it, or nobody can yet. The third is the reason this exists. A key that silently
does nothing is indistinguishable from a broken keyboard, so an action no one
claims is reported with why -- no session open, no screen mounted, or not in
the mapping at all.

Session-scoped actions are refused before they reach a store method that would
throw on a missing session, which is what pressing escape on the host list used
to do.

One test walks the lookup tables against the capability table: a name present
in one and missing from the other would otherwise only surface as a crash at
the moment a key is pressed.
Completes the chain the shell started. A screen registry holds the actions only
a mounted screen can perform, a performer calls the store method or the screen
handler, and a hook mounts the whole thing once at the root. The hook is thin
because translation, routing and performing are pure modules with their own
tests; it only subscribes while the app is mounted and reads the store at press
time rather than closing over a stale snapshot.

Nothing is allowed to vanish. An action with no open session, no mounted screen,
or no mapping reports why, because a key that silently does nothing is
indistinguishable from a broken keyboard. Async store actions that reject are
surfaced rather than left as unhandled rejections.

The registry keeps the newer screen's handler when navigating between sessions:
the second screen mounts before the first unmounts, so a stale dispose must not
remove the live handler.

Verified by pressing a real key in a real window, not by asserting on a mock.
The smoke run sends Escape through Electron's input pipeline and diffs the page
before and after; one line appears, "interrupt needs an open session", which
exists only in the new performer. Its presence proves every link fired: key
interception, keymap resolution, IPC, translation, routing, the store write and
the re-render. The successful-mutation branch is unit-tested but was not the
path this observable exercised.
…keys to resolve against

Two pieces, found in that order.

The transcript list is inverted: the newest message sits at native offset 0 and
older content at larger offsets, so page-up moves toward older by *increasing*
the offset. Getting that backwards is silent, so the arithmetic is a tested
module rather than inline in the component, with clamping at both ends and a
guard for metrics that have not been measured yet. The component advances its
own record of the offset immediately after issuing a scroll, because key repeat
outruns the native scroll event and reading the stale value would make every
press after the first a no-op.

Then the measurement that mattered: pressing PageUp in the real window changed
nothing, and the reason was not the scrolling. Keys are resolved against the
surface the user is on, and nothing had ever told the shell what that was, so
every key resolved as 'global' -- where no transcript, prompt or diff binding
exists. Every context-scoped binding in the table was unreachable. Screens now
declare their surface for as long as they are mounted, and the value is replayed
to a shell bridge that attaches later, since the two mount in either order.

Verified that the previously working path still works: Escape in a real window
still reports "interrupt needs an open session" end to end. PageUp moving a live
transcript is covered by unit tests and by a measured resolution table -- it
resolves to nothing in 'global' and to messages_page_up in 'messages' -- but has
not been watched against real content, which needs a paired host in the shell.
A host can run several backends side by side, one per profile, and the relay
exposes each as its own target. The store already discovers them with their
reachability, so this only shapes what the screen shows and what it refuses.

Two decisions live here rather than in a component. A host with no machine stays
visible with the reason it has none, because hiding it looks like the host was
never added, and the two kinds of nothing -- answered but authorized nothing
versus never answered -- read very differently to someone debugging. And a host
running more than one machine is flagged, because every machine on a host shares
one session database: the same session appears under each of them, so choosing a
machine picks which process runs the next prompt, not which sessions exist. That
is the one thing this screen has to say out loud rather than let a user discover
by seeing their session listed twice.

The screen component and its routing are not here yet; this is the part that can
be tested without one.
A laptop reaches several backends on one host, and which one runs the next
prompt is a choice worth making before a session is on screen. The desktop shell
now loads this route at launch; the phone keeps its existing tabs, where the
sessions screen already offers a per-machine filter and a first screen would be
one more thing to dismiss.

The screen opens straight through when a host has exactly one reachable machine,
because a picker with a single option is a dialog, not a choice. When a host
runs more than one it says that they share a session database, so seeing the
same session under each machine reads as expected rather than as a bug.

Two defects that only a real window showed, both now fixed: the route rendered
with a stray "devices" header because it was never registered in the stack, and
the empty state printed its reason twice, once as a subtitle and once in the
body. Verified after the fix by launching the shell and reading what the window
actually painted.
Upstream refused both on purpose: a phone was a monitor, not a development
machine. A laptop reaching the same host is a development machine, so the two
hardcoded refusals are replaced by real calls. The API layer already had both;
only the store said no.

A created session carries the machine it was created on. Without that it would
be routed to whichever backend answered first, which on a host running several
profiles is not the one the user picked.

startNewSessionPrompt no longer states something untrue. It clears a stale error
banner so the screen offering the choices opens clean; creation itself is
createSession.

The two tests asserting the refusal are replaced by three asserting the
capability, including that a refused creation reports itself and returns no ref
rather than handing back a reference to nothing.

Directory and machine are selectable at creation. Agent and model are not yet:
they are chosen per session after it opens, and wiring them into the creation
call needs the picker UI that does not exist yet.
The data was already there: the store fetches a session's diff when it opens and
keeps it current from the event stream. Only the screen was still rendering a
hardcoded sample, with a subtitle that said so.

The page is addressed by route rather than reading whichever session happens to
be active, so a diff opened from one session cannot show another's changes. With
no session in the route it keeps the fixture, which is what the copy-QA page and
its existing test rely on, and it now distinguishes "this session has no changes"
from "this is the sample" instead of showing sample changes for an empty session.

The session screen claims the diff entrypoint, so the desktop keybinding that
resolves to it has somewhere to go.

Two things this turned up. The screen's test never mocked expo-router or the
store, so pulling either into the module graph broke the suite at import time
before a single test ran. And SessionRef requires a machine id, which a route
parameter cannot promise, so the absent case is now explicit rather than passed
through as undefined.
Machine, working directory, agent and model go in, but they do not leave
together. Creating a session carries the machine and the directory; the agent
and the model are applied to the session once it exists, because that is how the
backend models them. So this returns both halves, and a caller that uses only
the first silently drops what the user picked.

Agents and models come from the chosen machine's execution contract. A machine
whose contract has not arrived can still be chosen but can offer neither list,
and saying so beats rendering two empty dropdowns that look like a machine with
no agents.

The configured default model is a bare model id while a picker key carries its
provider, so the default is matched on the id. Comparing the two strings would
never match and the default would quietly become whatever sorted first.

A choice the machine does not offer is refused by name rather than dropped, and
a blank directory falls back to the machine's own rather than sending an empty
string for the backend to interpret.

The screen is not here yet; this is the part that can be tested without one.
Collects the four choices and then does the two-step the backend requires:
create carries the machine and directory, and the agent and model are applied to
the session once it exists. Getting only the first half right would look like
the pickers did nothing.

The agent and model lists come from the machine's execution contract, which is
cached per machine and directory rather than per session, so a new session can
read one an earlier session already fetched. When none is cached the screen says
the machine has not reported its agents and that the session will start on its
defaults, instead of rendering empty lists that read as "this machine has none".

session_new no longer routes to the store. Creating a session needs four choices
that only a screen can collect, so the key now opens that screen; the store keeps
the creation call itself. The desktop's first screen claims the key.

Two things this turned up. Importing the store into a screen test pulls React
Native into the graph and fails on a missing __DEV__ global before any test
runs, so the scope-key helper is mirrored in the mock instead. And only machines
that are actually answering are offered, since creating a session on an
unreachable one fails after the user has made four choices rather than before.
Three gaps and one measurement.

The new-session key only worked on the screen that claimed it, which made it
dead everywhere else. Opening a screen is an app-level concern, so the root
claims it now and the key works from any surface.

On a fresh install nothing has ever cached a machine's execution contract, so
the agent and model lists would have stayed empty forever and the four-choice
form was really a two-choice one. Fetching a machine's agents and providers is
not a session operation, so the store now asks the machine directly and caches
the result under the same key a session would have used.

The desktop app can be installed: one command builds the renderer, packages a
.app, and then checks the artefact rather than the exit code -- an Info.plist
macOS cannot parse produces a bundle that fails to launch with no useful
message. Verified by running the packaged binary, not the dev build: it paints
the machine picker with no page errors. Unsigned, so the first launch needs
right-click then Open.

And the question that has been open since the multi-profile design was written:
two backends on one session database. Measured in an isolated data directory
that never opens the real one. Started together, the second dies with "database
is locked" and never binds a port. Started after the first is listening, both
run, and fifteen rounds of concurrent writes produced no lock contention in
either log with the database in WAL mode. So it works, but they must not
initialise at the same time -- the deploy script already starts them one at a
time, while launchd at boot does not, and relies on KeepAlive restarting the
loser ten seconds later. That last part is inference, not measurement, and says
so in the docs.

Two traps found along the way, both recorded: macOS /tmp resolves to /private/tmp
and the server stores the resolved path, so a query with the unresolved one
matches zero sessions rather than erroring; and session listing is scoped by the
server's own working directory, which a directory query does not change.
The smoke could only send one chord, so every leader binding -- twenty-eight of
them, including the one that creates a session -- was unverified in a real
window. A comma now separates chords in a sequence.

Pressing ctrl+x then n moves the window from the machine picker to the new
session screen, which proves five things at once that unit tests cannot: the
leader chord is taken before the text field treats it as Cut, the pending state
survives between two key events, the sequence resolves to session_new, the
action reaches the claim at the root rather than dying on whichever screen is
mounted, and the screen it opens actually renders.
The relay normalises a missing allowedDirectories to an empty list, and an empty
list forbids every directory. The deploy script omitted the field, so every
fresh host produced an owner client that answered directory-scoped requests with
403 directory_forbidden. Writing null, which means any directory, fixes it.

Found by running the app's live end-to-end test against a real deployment: it
connected, discovered the machines, and then failed on the first scoped call.

The deploy script's own verification had passed throughout, because it only ever
made unscoped requests. It now asks for a directory explicitly, so this class of
failure surfaces at deploy time instead of on a user's first real call. The
check is known to fail on the old configuration: that is exactly the 403 the
end-to-end run produced before the fix.
A hiddenInset title bar floats the window buttons over the page, and the page
starts at the top, so the app title rendered underneath them.

The strip is reserved in the shell rather than in the shared UI, because the
phone renders that same UI and the space would be dead margin there.

The smoke now reads the computed padding back and fails if it is missing. The
injection is a side effect with no return value to check, so without this a
failed injection would look exactly like a successful one.
The deploy script never generated a bootstrap key, so the pairing console could
not register an owner and the QR flow was unreachable. Pasting a 64-character
token by hand was the only way to add a device, which is not a flow anyone would
use on a phone.

The key is generated once and preserved across redeploys, and the script prints
a one-time setup link. The secret travels in the URL fragment, which browsers do
not send to the server, so it never reaches an access log; the relay stops
accepting it the moment an owner passkey exists.

Verified against the running relay rather than by reading the code: the correct
key returns a WebAuthn ceremony whose relying-party id is the tailnet hostname,
and a wrong key of the same length is refused with 403.
…nnot drift

Hiding the title bar and reserving room for the window buttons are one decision,
but they lived in two places: a style on the window and a CSS injection fifty
lines away. Shipping the first without the second is exactly what put the
traffic lights on top of the app header.

Both now come from one function, and a test asserts the pairing on every
platform rather than only the one this machine runs: the inset is required
exactly when the title bar is hidden, the CSS exists exactly when the inset is
required, and an unrecognised platform falls back to a real system title bar
rather than to buttons floating over the content.

Windows still has no real-machine check. It takes the system title bar, which
draws above the page instead of over it, so the overlap cannot occur by
construction -- but that is reasoning, and the test only guards the decision,
not the rendering.
The pairing endpoints were same-origin by construction: no allow-origin header,
Cross-Origin-Resource-Policy set to same-origin, and a preflight handler
covering exactly one route. That holds for the console, which the relay serves
itself, and fails for every separate client -- including the packaged desktop
app, which loads from its own scheme.

An Authorization header is not CORS-safelisted, so even a GET is preflighted;
the single-route handler left the rest unreachable. The preflight now covers
/api/*, and JSON responses carry allow-origin with a cross-origin resource
policy. A wildcard origin cannot be combined with credentials by any browser,
so this does not expose the console's cookie session to another origin.

Found by driving the real UI in a browser against a live relay: six console
errors on /api/pairing/me. After the fix the same call returns a readable 401,
which is correct -- a static token client is not a paired device -- rather than
being blocked before the answer arrives.
The first screen only read cached relay targets and nothing ever populated
them on a cold start, so a healthy relay was reported as "no machine
authorized". The hosts tab worked because tapping a host runs the check;
the new screen had no equivalent. Set the active connection and refresh
once per connection whose targets are unknown, and say so on screen while
it runs.

Also replace the titlebar smoke assertion, which measured the wrong thing.
It read the computed padding on body, which proves the injected CSS parsed
but not that any content moved. Measure the viewport position of the
topmost visible text instead, and record the root elements' positioning so
a future regression is diagnosable from the artifact.
Two places replied to OPTIONS. The pairing module sees the request first, and
its /api/ branch spelled out a narrower list of its own: it allowed only
Authorization and Content-Type, and dropped PATCH. It therefore shadowed the
proxy's correct answer for every API route.

The desktop app sends X-OpenCode-Target and X-OpenCode-Directory, so the
browser blocked its requests before they were sent and the app could only
report "Failed to fetch". Nothing else noticed: curl does not enforce CORS, and
the phone loads a page the relay itself serves, which is same-origin and never
preflights.

Move the policy into lib/cors.mjs and have both callers use it. Responses now
also expose X-Next-Cursor, without which a cross-origin reader sees null and
the paged session list stops after one page.

The test that was missing reads the client's own source and fails if a custom
header it uses is neither allowed nor exposed.
The packaging script was macOS only: the platform flag, the bundle layout, the
plist lint and the du call all assumed an .app, so there was no way to produce
a Windows build at all. What differs between the targets now lives in
package-target.mjs, and the script takes --platform. Cross-building win32 from
the Mac host works and needs no Wine.

Both icon containers are derived on every build from the phone app's existing
artwork, which is the icon its author agreed we could use. One source image
means macOS and Windows cannot end up on different art. The .ico is assembled
here because the format is a header followed by whole PNG files, and pulling in
an image library to write sixteen bytes of it would be the larger cost.

The smoke also stops measuring the wrong things. It now asserts where the
topmost visible text actually landed rather than that a padding rule parsed,
and it makes that assertion on every platform: macOS must clear the floating
window buttons, and everywhere else the same inset would be dead margin. It
captures the page again once the network has settled, because first paint
showed a spinner and reading it as the finished screen reported a healthy app
as broken.
An empty list was always explained as "this relay authorized no machine for
this device", which reads as a permissions problem. The usual cause is a
request that failed with a message the screen never showed, and the wrong
explanation sent this investigation toward relay permissions while the real
error sat unread in the store.

Pass the host's sync error into the model and show it.
Three of its checks could not report a problem, and one of them had already
been read as evidence.

The page's error and action lists were handed to the renderer through
contextBridge, which deep-clones what it copies. The page therefore held a dead
snapshot: every later push landed in the preload's world and was invisible in
the one the check read. The console-error assertion had been passing against an
array that could never fill. Both lists are exposed as functions now, which the
bridge proxies live.

Synthetic key presses named their modifiers the way a binding spells them, and
sendInputEvent ignores a name it does not know. "ctrl" was such a name, so the
modifier vanished and the press landed as a bare letter; a smoke that pressed
nothing of the sort still reported the screen had changed. Names are translated
now, and an unrecognised one fails the run instead of being dropped.

Adds a click step, because screens reachable only by pointer cannot otherwise
be keyboard-tested, and records which actions the shell delivered, because "the
key never resolved" and "it resolved and nothing handled it" look identical
from outside and need opposite fixes.
XinHu-001 and others added 13 commits September 17, 2026 20:14
…killing the live stack

The machine picker is the first screen and sits outside the tabs, so when the
only paired host cannot be reached there was nothing on it that led anywhere:
no way to add a relay, edit its address, or remove it. It carries a Manage
hosts control now, present whether or not anything is reachable.

Separately, uninstall-macos.sh selected launchd jobs by label prefix regardless
of mode. Shadow installs no launchd job at all, so a shadow teardown matched the
production relay instead: it unloaded the live job and deleted its plist, taking
the host off the air. Shadow now touches nothing launchd knows about, and the
test asserts the teardown plan through the script's own dry run.
The native project is untracked, so the two constraints that make it build are
invisible: a path without non-ASCII characters, because CocoaPods reads the
Podfile through Ruby and fails on the first non-Latin byte, and a post_install
that lifts pods whose declared deployment target predates what Xcode accepts.
Both were found the hard way.
A free Apple ID signs an app for seven days and an installed copy cannot be
extended, so every renewal is a full rebuild and reinstall. Doing that by hand
meant remembering the team, the scheme, the configuration, and which device id
to pass; getting one wrong produced either a build against the wrong signing
identity or a silent no-op.

The script finds the connected phone by the shape of its UDID rather than by
the wording of devicectl's state column, which differs depending on how the
phone is attached. It reads the expiry out of the profile the build actually
embedded and refuses to install something that is already expiring: a stale
profile on disk otherwise produces an app that stops launching within hours.
…or a path

Creating a session meant typing an absolute path, which on a phone means
recalling one exactly. "/~" looks like the obvious shorthand and the relay
rejects it, because it is not an absolute path; the app then showed the bare
token directory_forbidden, which reads as a demand for a directory rather than
as a statement that the one given is not usable.

The machine already knows where its sessions live. Those directories are now
offered one tap each, busiest first, with the machine's own default at the top,
and the field says what leaving it empty does. The relay's rejection tokens are
translated where they surface.
…stination

A locked or half-attached iPhone still appears in devicectl's list, marked
unavailable. Taking its id regardless handed xcodebuild a destination it could
not match, and the error it printed talked about destination specifiers and
listed every simulator on the machine, which says nothing about the phone being
locked.

The device is now chosen from the physical entries that are not unavailable,
and an attached-but-unusable phone is reported as exactly that.
The README opened in English with a template's description of itself. It now
says in one paragraph what the thing does for the person reading it, and names
the project it started from: BB-84C's opencode-mobile-solution, whose relay and
phone client this fork builds on, and whose app icon it uses with permission.
The empty state said the relay backend cannot look up past edits. That was
wrong, and it was wrong because the probe behind it asked the wrong path: the
client reaches this endpoint at /session/{id}/diff, while the probe used
/api/session/{id}/diff, which no route serves and which the backend answers
with its web app's index page, at status 200. The same mistake made session
deletion look broken when it works.

The endpoint answers with JSON. Why a given session has no recorded changes is
not something this screen knows, so it no longer guesses.
Running bare `opencode` on the host starts its own process with its own
session database, so nothing it does is visible from the phone. This
attaches to the backend already running on 4096, which is the one the
phone and every paired client talk to, so all of them see one session
list.

The script was referenced in deploy-notes as living in ~/.local/bin and
had never been in the repository, so cloning the repo did not give you
the command the notes told you to run.

Credentials are read from backend.env into the environment rather than
passed on the command line, so they stay out of ps and shell history.
It also supplies --dir when the caller omits it: the backend partitions
sessions by working directory, and without it the list belongs to the
service process's own directory rather than the project you are in.
A client running Shadowrocket or Clash builds its own utun and takes
over the default route, so packets bound for the tailnet are swallowed
before Tailscale sees them. The symptom is a connection that times out
rather than one that is refused, and none of it is visible from the
host, which reports every service healthy.

Three causes present identically. The document separates them with one
command, because the fixes have nothing in common: a VPN stealing the
route is a client rule change, an address collision can only be fixed on
the router, and a blocked data plane is a NAT setting.

Two findings are recorded because they cost real time. Shadowrocket's
tun-excluded-routes reads like "leave this range alone" but means "hand
this range to the physical NIC", and the global route it installs
outranks the scoped route Tailscale relies on, so it makes things worse
rather than better. And curl ignores the client's rules entirely because
it reads proxy environment variables, which produces a GUI that works
while every command line request hangs.
A turn that fails server-side is reported as an event, not as a failed request:
the machine emits session.error and carries on. The app handled message, status,
question and todo events and silently dropped that one, so an unavailable model
produced no reply, no error, and no change on screen. Measured against the live
host: prompting with a model that does not exist returns 204 and then emits
session.error twice, carrying "Model not found: <provider>/<model>".

The event now lands in the session's error channel and clears the busy status,
so the spinner stops. The error text is read defensively, because a shape this
app does not recognise still has to say something rather than nothing.

That channel also rendered under a fixed "Transcript refresh warning" label
while every writer puts a self-describing sentence into it, so a model error
read as a transcript problem. The label is gone.
…h it

The friend's repo investigated an iOS 27 launch crash reported against a
TestFlight build from July. The root cause is not established there — no
termination report was available — and their own device opened the app fine
after updating. What they prepared is compatibility rather than a proven fix:
Expo 57.0.23 with aligned native modules, and expo-build-properties with
ios.enableSceneSupport, because Apple requires the scene lifecycle for anything
built against the iOS 27 SDK.

Merging the whole of upstream rather than cherry-picking the two iOS lines
costs one conflict and keeps the fork close enough to sync again later. The
conflict was complementary: both sides had added an event handler at the same
place, ours for session.error and theirs for permission.asked, so both stay.

Our bundle identifier stays com.skylerhu.opencodemobile, which is what makes
the next install an upgrade rather than a second app with empty storage.
Version moves to 1.1.0: upstream's own app.json still says 1.0.0, and a
separate number says plainly that this line carries their work plus ours.
Counted from the diff rather than recalled: 41 files, +3598/-50, 27 of them new
and none removed. Almost all of it is a desktop keyboard layer the phone never
loads; what a phone user actually sees extra is the machine picker, the new
session screen, the command palette, and four additions inside the session menu.

Records the three deliberate divergences, with the reason for each: our bundle
identifier, which is what keeps an install an upgrade; our version line; and the
dropped VPS-era client directories.
createSession defaulted title to the literal 'New Session' and always sent it. The OpenCode core auto-names a session only while its title matches its own placeholder (^(New session - |Child session - )<ISO8601 with ms>Z$), so every session opened from the app stayed unnamed, with no error and no log line. Send title only when the caller chose one; an explicit title is still sent and still wins.

Tests assert the request body: no title sends {}, an explicit title is kept, the session handed back still carries the server placeholder, and the missing-directory guard still throws. Against the pre-fix client, two of the four fail.

npm test on the merge result: 436 passed, 4 skipped. typecheck: the 2 pre-existing errors in pairing.test.ts, unchanged.

Needs an app rebuild and reinstall to reach a phone. Sessions already titled 'New Session' are not renamed retroactively.
@XinHu-001

Copy link
Copy Markdown
Author

新增一个提交 98f6b72:修复从 app 新建的会话不会自动命名的问题。原因是 createSession 默认把标题写成字面量 New Session 发出去,而内核只给还带着自己占位标题的会话自动起名。你那边的代码是同一个默认值,所以这个问题你也有。改动只涉及 app/src/opencode/client.ts 和对应测试(+54/−2)。app 测试在这个提交上是 436 通过、4 跳过。PR 描述里的数字和说明已经同步更新。

One new commit, 98f6b72: sessions created from the app are auto-named again. createSession used to send the literal title New Session, which opts a session out of the core's auto-naming. Your main has the same default. Two files, +54/−2; app tests 436 passed, 4 skipped. The description above is updated to match.

The paperclip only typed "@" into the prompt. Its reference list shows agents
until a query is typed, so pressing it looked like "mention an agent", and
nothing on this surface could send a photo or a PDF.

The paperclip now picks images and PDFs (Photos or Files on a phone, the file
dialog on the desktop) and sends each one as a file part with a data URL next
to the text. Images up to 5 MB, PDFs up to 20 MB, five per message; the type
is read from the bytes, not from the picker's label. A model that declares it
cannot read the kind attached is refused before anything is sent, and an
attachment on a shell line or slash command is refused rather than dropped.
The old reference behaviour keeps working behind an @ button.

The transcript shows an attached file as its name. The phone's transcript
cache keeps the name and drops the inline bytes, so one photo cannot spend the
bounded cache that protects launch memory.

Verified against the running backend through the relay: claude-sonnet-5 read
a two-colour PNG and a code word from a PDF sent this way, and in a browser
the picker, chip, send and remove steps all worked. app tests 460 passed, 4
skipped; typecheck has only the two existing pairing.test.ts errors.

Native: adds expo-image-picker and expo-document-picker, so the iOS app must
be rebuilt; app.json carries the photo-library permission text.
@XinHu-001

Copy link
Copy Markdown
Author

新增提交 23324dc:回形针改成真正的附件(图片和 PDF 随消息发给模型),原来的 @ 引用挪到单独的 @ 按钮。只改 app/,15 个文件;新增两个原生模块,需要重新构建原生工程。描述里已补说明和最新数字。

New commit 23324dc: the paperclip now attaches real files (images and PDFs go to the model with the message); the old @-reference moved to its own @ button. app/ only, 15 files; two new native modules, so a native rebuild is needed. Description updated.

Llulu and others added 4 commits October 1, 2026 05:23
Special patch, 2026-10-01, docs only. When a client has no IPv6 and the
tailnet runs over DERP relays (e.g. cross-border traffic is blocked),
Shadowrocket's DIRECT rules are not enough: browsers and the Electron
client go through the system proxy first, Shadowrocket's own direct
socket cannot reach the tailnet, and the request fails with a 503.

The new section at the end of clients/README.md adds *.ts.net and
100.64.0.0/10 to skip-proxy, and shows how to test the proxied path with
curl (env -u NO_PROXY + -x), since plain curl honours NO_PROXY and
always looks fine. Both steps are only needed in the no-IPv6 case; with
IPv6 direct connectivity the three existing DIRECT rules suffice.
…sign it so alerts reach Notification Center

Ctrl+C / Ctrl+A on Windows and Cmd+A on macOS are no longer claimed by the TUI keymap, and a right-click menu offers copy and select all. Closing the window hides it to the tray or menu bar; the tray, the Dock, a second launch and a notification click all reopen it, and a crashed renderer is rebuilt. The app starts at login by default, with a tray toggle. Packaged macOS builds are re-signed ad hoc under their own bundle id, since a build signed as "Electron" is never registered for notifications.
…ssions everywhere

On iOS a whole message now renders as one UITextView (react-native-uitextview), so a selection can start and end anywhere inside it, across paragraphs, lists and code blocks. Select text opens the whole loaded conversation, scrolled to the message, for selections that span messages; the message card keeps a permanent ... button instead of a long press. Permission and question requests are kept per directory, so a parent session shows its subagents' requests and can answer them. A global event stream feeds a waiting badge on the session list, an in-app banner, and desktop notifications that open the session.
@XinHu-001

Copy link
Copy Markdown
Author

Pushed three commits to main (23324dc..ee57e9c), and updated the description above. Summary:

  • Partial text selection on iOS. Each message is now one UITextView via @bsky.app/react-native-uitextview 2.7.1, so a selection can cover a few words or run across paragraphs and code blocks. Select text opens the whole conversation for selections across messages. New native module, so a native rebuild is needed.
  • Subagent permission requests are kept per directory and shown in the parent session, where they can be answered.
  • Alerts: a session-list badge, an in-app banner, and desktop notifications driven by /global/event. The desktop app stays in the tray, starts at login, and macOS builds are signed under their bundle id so notifications reach Notification Center.
  • The desktop shell no longer claims Ctrl+C / Ctrl+A on Windows or Cmd+A on macOS.

Tests: app 488 passed, 4 skipped; desktop 43 passed. A real subagent permission request was answered end to end from the parent session, and partial selection was confirmed by hand on an iPhone. No relay changes.

Windows only shows a toast for an app with a Start Menu shortcut carrying its AppUserModelID and toast activator. The packaged build has no installer, so it sets both ids at startup and writes its own shortcut.
@XinHu-001

Copy link
Copy Markdown
Author

One more commit, 29e07d6: Windows only shows a notification for an app with a Start Menu shortcut carrying its AppUserModelID, and our Windows build has no installer, so the app now writes that shortcut itself on first launch. desktop tests: 44 passed. Not yet verified on a real Windows machine.

Replies render $...$, \(...\), $$...$$ and \[...\] as formulas with KaTeX, the engine the HTML reports use, while prices such as $5 stay text. The desktop and web build typeset inline; on iOS a message containing math is rendered in a WebView, with KaTeX's fonts inlined because such a page cannot read font files from the app bundle, and Select text uses one too when the conversation has math. Copying a selection that contains math puts the TeX source on the clipboard with line breaks intact. The desktop renderer build now places KaTeX's fonts beside its stylesheet and fails if any referenced font is missing. App version 1.2.0, desktop shell 0.2.0. New dependencies: katex 0.19.0 and react-native-webview 13.16.1 (a native module).
@XinHu-001

Copy link
Copy Markdown
Author

Released 1.2.0 (974e76b, tag v1.2.0): replies now render TeX math with KaTeX (desktop/web inline; on iOS a math-bearing message renders in a WebView, which adds the native module react-native-webview 13.16.1), and copying a selection that contains math yields the TeX source. App version 1.2.0, desktop shell 0.2.0. Tests: app 501 passed, 4 skipped; desktop 44 passed. Description updated.

… red

The session's todo list (GET /session/{id}/todo, live through todo.updated) shows as a bar at the top of the session with progress and the task in progress, expanding to the whole list; open by default on the desktop, folded on the phone, toggled by the TUI's <leader>b or the session menu.

A failed turn is not output: the server stores { name, data: { message } } on the assistant message and emits session.error, and neither reached the screen, so work stopped with no reason shown. The message error now renders as a red box in the transcript (also when an old session is opened), an error with no message (a missing model, say) shows above the prompt until dismissed or a new turn starts, a retrying status says why, and Stop shows only as Interrupted. The global watch also follows session.error, so a failing subagent or background session marks its card Stopped, raises a desktop notification and a phone banner. Server stack traces are dropped from the text.

Packaging now signs the macOS bundle in a temporary copy: under iCloud the bundle is re-tagged between clearing and signing, which failed the build intermittently. App 2.0.0, desktop shell 1.0.0.
@XinHu-001

Copy link
Copy Markdown
Author

Released 2.0.0 (d9fcf71, tag v2.0.0): a todo board matching the TUI sidebar, and failed turns (info.error on the reply, session.error events, retry status) shown in red boxes instead of nothing; failing background or subagent sessions are flagged on the list and raise a notification. Also fixes an intermittent macOS signing failure under iCloud. Tests: app 517 passed, 4 skipped; desktop 44 passed. Description updated.

The shell renders the same UI as the phone, so a separate number (1.0.0 against the app's 2.0.0) only made the two look like different releases.
The English README, docs/architecture.md and relay/README.md still described the VPS relay, the SSH/frp tunnel, machine enrollment and the clients/ launchers, all removed when this fork moved to a single host on the tailnet. They now describe the host deployment, tailscale serve, the desktop client, the event streams the 2.0 features rely on, and how to build each part from source, since no public binaries are published. The product spec's transport diagram is updated and its broken links to the UX spec are fixed.
@XinHu-001

Copy link
Copy Markdown
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants