Skip to content

feat(control): 原生 CLI 读写终端与本地/Web 控制提示 - #132

Merged
defia merged 7 commits into
chenbstack:mainfrom
defia:feat/local-control-cli
Nov 3, 2026
Merged

defia merged 7 commits into
chenbstack:mainfrom
defia:feat/local-control-cli

Conversation

@defia

@defia defia commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

变更内容 / Summary

新增随 Glint 打包的原生 glint CLI,可列出窗格、读取实时终端或滚动历史、发送文字/按键,以及显式聚焦。设置页支持安装、卸载和复制 Agent 使用说明,无需 Node.js、Python 或 MCP。

  • 所有命令受外部控制开关约束;读写需要令牌。关闭开关撤销已有连接,重新开启后旧连接与令牌失效。安装 CLI 不会自动授予控制权限。
  • 普通 CLI 读写和浏览器选择已有终端保留本地工作区选择;只有显式 pane focus 才切换。CLI 可直接操作已就绪的后台终端,并可唤醒没有待恢复历史或启动任务的终端。重启后的限制:滚动历史恢复默认开启,有待恢复存档的后台 pane 需要先在 Glint 中打开并完成恢复,CLI 读取(包括不带 --scrollback)和输入才可使用;本版本不保证重启后直接唤醒所有后台终端。
  • 带有待执行启动命令或 agent 自动恢复的终端返回 pane-requires-local-open,CLI 和 Web 提示先在 Glint 中打开;远程访问不消费待执行命令,也不会在无本地视图时自动运行它们。CLI 对待恢复滚动历史同样在创建 shell、读取或注入输入前返回此提示;Web 保留按浏览器网格完成历史恢复的路径。Web 快照失败仍走选择失败反馈,成功后才标记控制状态。
  • 侧栏与窗格顶部显示本地/Web 控制来源。本地标识在最后一次成功读写后保留 30 秒;连续轮询仅刷新时间戳,不反复发布状态或分配定时任务。Web 标识在连接期间保留。
  • 主线程仅获取 Ghostty grid 快照;JSON 解码、文本拼接与读取响应序列化在连接线程完成。CLI 只扫描新接收的分块以定位帧边界,避免大响应的平方级扫描。
  • 设置页安装状态已本地化;仅指向已不存在 Glint.app/Contents/Helpers/glint 的悬空链接可修复/卸载,并以警告色显示。指向另一份现存 Glint 的正常链接单独显示为「已链接到另一个 Glint」,禁止安装覆盖或卸载;其他程序的文件和链接仍受保护。
  • 自定义 socket 验证目录、socket 与 token 的属主和权限;token 使用不跟随软链接的文件描述符读取。编码后的请求上限为 1 MiB,CLI 和服务端均明确报告 request-too-large。
  • --debug --help、--socket PATH --help 和子命令帮助可用;--text '--help' 保持为输入文字。
  • 恢复 ChildWindowExceptionGuard.m 与 ExceptionBackstopTests.swift 在 main 中的对象 ID 与顺序,减少无关工程文件变更。

验证方式 / Testing

  • Debug 应用/打包 CLI 构建通过,65 项控制桥、后台终端和 Web Remote XCTest 通过。
  • 20 项 CLI 子进程测试通过,包括悬空链接修复、其他现存 Glint 链接保护、权限/token 软链接拒绝、编码后超长请求拒绝和大响应读取。
  • 新存档用例确认 CLI 在创建 shell 前拒绝读/文字/按键,保持历史和本地选择不变;Web 按指定网格恢复后,CLI 可读到历史,且旧历史位于新输出之前。
  • 链接用例确认现存 Glint 链接状态为 linkedElsewhere,install/uninstall 均不改动链接;目标删除后转为 repairable,才允许修复。
  • objectWillChange、注入时钟过期、Unicode/软换行、真实 Unix socket 与打包 CLI 集成路径均有相关回归覆盖。
  • git diff --check 及本地化 JSON 校验通过;提交 db1934b 的完整远端 CI 已通过:Actions 40463065863。
  • 本次未安装或发布 Release,也未重新执行真实 UI 目检。

文档澄清提交 dbd71fe 仅修改中英文 README,git diff --check 通过,该提交的 Debug CI 也已通过。

发布验证边界 / Release validation

当前 PR CI 验证的是 Debug 构建和本地测试,不代表 Contents/Helpers/glint 已通过 Developer ID 发布签名、公证或安装后运行验证。scripts/sign-and-notarize.sh 会对 app 使用 --options runtime --timestamp --deep 并执行公证,但 helper 的最终发布产物尚未实测。

正式版前先用包含本 PR 的 beta 验证:

  • release 工作流的 Codesign + notarize 必须实际执行并成功,不能是 skipped。该步骤在缺少 Apple 证书时会跳过,单凭 release job 成功不能证明完成签名/公证。
  • 从最终 beta DMG 提取 app,分别核验 app 与 Contents/Helpers/glint 的 Developer ID 签名、Team ID、时间戳及 hardened runtime,并验证整个 bundle 的签名完整性。
  • 核验公证接受结果、stapled ticket 和 Gatekeeper 评估。
  • 从下载的 DMG 安装后,验证 GUI 启动、helper --help、CLI 链接安装及真实终端读写;同时验证后台历史恢复的上述限制。

上述 beta 发布验收尚未执行。

关联议题 / Related issues

处理 2026-10-27 审核意见 1–10,以及后续 A(后台滚动历史)、B(其他现存 Glint 链接)和修复状态警告色反馈。侧栏拖宽恢复和退出后子进程保留调查不在此 PR 范围内。

defia added 4 commits October 25, 2026 13:21
Repeated full-buffer scans made large chunked responses quadratic and caused the bundled CLI CI test to time out. Scan each received chunk once and double the regression payload without increasing the timeout.
@chenbstack

Copy link
Copy Markdown
Owner

感谢这个 PR,原生 CLI 读写终端的方向很有价值。review 时发现几处问题,建议合并前处理前 5 条,其余可以一并修或后续跟进。

建议合并前修改

  1. Glint/Workspace/WorkspaceStore.swift:3040:后台 pane 没有 live surface 时读写失败
    controlReadText、controlSendText、controlSendKeys 都要求 pane 已有 live surface。从未挂载过的后台 workspace 的 pane,或被 freeIdleTerminalsEnabled 回收的空闲 pane,会返回 pane-not-ready。而这个 PR 的目标正是在不切换 workspace 的情况下读写后台终端。webRemoteTerminalSnapshot 对同样情况会自动创建 surface,两条路径行为不一致;CLI 也去掉了原来靠 focus 把 pane 挂载出来的绕法。集成测试能通过,只是因为测试里先调用了 ensureLiveForWebRemoteControl()。30 秒的 externalControlledPanes 保护只覆盖已经读过的 pane。
    建议:控制路径遇到没有 surface 的 pane 时也要能创建,或者明确返回可操作的错误,并补一个不预先调用 ensureLiveForWebRemoteControl() 的测试。

  2. Glint/Workspace/WorkspaceStore.swift:1262:noteExternalControlActivity 在热路径上反复发布
    每次 read、send、key 都会修改 @Published externalControlledPanes,并重建一个 DispatchWorkItem,即使 key 已经存在。@Published 对无变化的 Set insert 也会触发 objectWillChange。agent 每秒轮询几次 glint pane read,就会让所有观察 WorkspaceStore 的视图反复重新求值(侧栏和每个 pane overlay 都会重算 control sources),这条热路径是项目有性能回归测试覆盖的。
    建议:只在 key 不存在时插入;已存在时只刷新过期时间戳,不发布变更,也不要每次重新分配 work item。

  3. Glint/Agent/ControlBridge.swift:861:read-text 整条路径在主线程
    整个 read-text 在 DispatchQueue.main.sync 里执行。pane read --scrollback --max-lines 10000 会在主线程完成 ghostty_surface_render_grid_json、JSONDecoder 解码和 controlText 的 span 拼接,UI 和终端渲染会卡住,重复读取会重复卡顿。
    建议:主线程只做 surface 查找和 grid 快照,解码和字符串拼接放到 serve 线程。

  4. Shared/CLIInstallation.swift:11:失效的软链接被判为 .conflict,无法修复
    ~/.local/bin/glint 指向 .../Glint.app/Contents/Helpers/glint。App 被移动、重装,或 Debug 的 DerivedData 路径变化后,链接会悬空,state() 解析出不同路径并返回 .conflict。此时 install 和 uninstall 都会抛 "destination already contains another command",UI 上 Install 按钮只在 .notInstalled 时可用,Uninstall 只在 .installed 时显示,用户只能手动删除链接。
    建议:悬空链接,或指向其他 Glint.app 路径的链接,应视为自己的并允许替换或移除。

  5. Glint/Workspace/WorkspaceStore.swift:3163:webRemoteTerminalSnapshot 在无视图时直接创建 surface
    现在它对任何已有 pane 直接创建 surface,且不再选中 workspace。浏览器选择一个从未访问过或刚恢复的 workspace 里的 pane 时,surfaceView(...) 会启动 shell,同时应用 agent 自动恢复的 initial_input,并消费待执行的一次性启动命令,而此时没有挂载任何视图。agent 会在用户看不见的无头 surface 里恢复,布局和尺寸处理也与已挂载的 pane 不同。web server 的 finishSelectionFailure 调用点也不再收到 pane-not-ready 或 focus 错误,原来的失败反馈没有了。
    建议:确认无头创建 surface 时是否应抑制自动恢复和一次性启动命令,并保留 web 端的失败反馈。

建议一并修或后续跟进

  1. Glint/Chrome/SettingsView.swift:1040:状态文案没有本地化
    cliInstallationLabel 返回 String,传给 StatusPill(label: String),走的是 verbatim 重载。中文界面会显示英文的 "Installed"、"Not installed"、"Command name in use",新加的 "Command name in use" 词条也从未被使用。这违反了 CLAUDE.md 中"helper / 自定义视图里用作 UI 标签的形参类型必须是 LocalizedStringKey"的规则。
    建议:把 label 改成 LocalizedStringKey,或者每个返回值用 String(localized:) 包起来。

  2. CLI/main.swift:199:自定义 --socket 会把 token 发给任意路径的监听者
    CLI 会读取 --socket 路径旁边的 .token 文件,再把 token 发给该 socket。如果 agent 被诱导传入共享目录或攻击者可写目录下的 socket,并且那里已有 .token 文件,token 会被发给恶意监听者。默认路径是安全的。
    建议:自定义 socket 要求位于私有目录(0700 / 0600),或在使用前校验 socket 和 token 文件的属主与权限。

  3. Glint/Agent/ControlBridge.swift:226:请求超过 1 MiB 时静默断连
    请求缓冲超过 1 MiB 时服务端直接 break,不返回任何响应。glint pane send PANE --text "<2 MB>" 会被静默关闭,CLI 只显示笼统的 "Control connection closed or timed out",尽管请求本身是合法的。
    建议:CLI 端提前拒绝超长文本,或服务端返回明确的 request-too-large 错误。

  4. GlintTests/WebRemoteProtocolTests.swift:990:测试里用真实 sleep 等 30 秒过期
    testBackgroundTerminalReadAndWebSelectionPreserveLocalWorkspace 有两次真实的 Task.sleep(15.2 秒和 15 秒),每次 CI 和本地运行多出约 30 秒;runner 负载高时过期定时器可能滞后于断言,导致不稳定。
    建议:让 30 秒的过期时间可注入,或使时钟可 mock。

  5. Glint.xcodeproj/project.pbxproj:21:无关的 pbxproj 改动
    xcodegen 重新生成时,ChildWindowExceptionGuard.m 和 ExceptionBackstopTests.swift 被分配了新的对象 ID 并改变了顺序。这些与 CLI 功能无关的改动会增加与其他分支的合并冲突风险,也让真正的 target 和 phase 改动更难 review。
    建议:基于最新 main 的 pbxproj 重新生成,只保留这个 PR 新增的 target 和文件。


最影响功能的是第 1 条:不处理的话,PR 的核心场景(不切换 workspace 读写后台终端)在没挂载过的 workspace 上基本不可用。第 2、3 条是热路径上的性能问题,建议一起处理。

🤖 Generated with Claude Code

Wake ordinary background terminals without changing workspace selection, while deferring startup and agent restoration to a local view. Keep polling state updates silent, decode grids off the main queue, repair owned CLI links, validate socket credentials, return explicit size errors, and use an injected expiry clock in tests. Restore unrelated Xcode object IDs and ordering.
@chenbstack

Copy link
Copy Markdown
Owner

感谢更新!对照上次的 10 条意见逐条看了 51c36ac:10 条都已处理,其中 9 条完全解决,第 6 条还剩一处小问题。另外发现 2 个新问题,其中 A 建议合并前修复。(本轮基于代码阅读,未在本地编译或运行测试。)

上次 10 条的处理情况

# 问题 结论
1 后台 pane 没有 live surface 时读写失败 ✅ remoteControlView 在没有 surface 时按需创建;新测试不再预先调用 ensureLiveForWebRemoteControl()
2 热路径上反复发布 ✅ 只在 key 首次出现时 insert,之后只刷新时间戳;全局只保留一个定时任务;测试确认连续 10 次读取不触发 objectWillChange
3 read-text 整条路径在主线程 ✅ 主线程只做 controlReadGrid 抓快照,解码和文本拼接移到连接线程
4 失效链接被判为 .conflict、无法修复 ✅ 新增 .repairable,安装和卸载都能处理(但有新问题,见下方 B)
5 Web 端在无视图时直接创建 surface ✅ 有待执行启动命令或 agent 自动恢复的 pane 返回 pane-requires-local-open,Web 和 CLI 都有对应提示;被拒后一次性命令保持原样;Web 失败仍走 finishSelectionFailure
6 状态文案没本地化 ✅ 改用 String(localized:),zh-Hans 条目齐全。小问题:.repairable 的 StatusPill 用的是 .neutral,建议改为 .warn
7 自定义 --socket 的 token 泄露 ✅ 校验目录 0700、socket 0600 且归当前用户所有;token 用 O_NOFOLLOW 打开并用 fstat 校验
8 超过 1 MiB 时静默断连 ✅ CLI 发送前检查,服务端返回 request-too-large,两端上限一致
9 测试里真实 sleep 30 秒 ✅ 改为注入时钟,再手动触发过期
10 无关的 pbxproj 改动 ✅ 相对 main 只有新增、没有删减

新问题

A. 后台 pane 被 CLI 唤醒后,滚动历史恢复错位(建议合并前修复)

WorkspaceStore.remoteControlView → ensureLiveForWebRemoteControl() → GhosttySurfaceView.syncSurfaceSize

  • 原因:CLI 唤醒的 surface 没有挂到窗口上,bounds 为 0,也没有 webRemoteGridSize。syncSurfaceSize 在 guard hasLocalSize || webRemoteGridSize != nil 处直接 return,所以 pendingRestoreData 的回显和 0.35 秒兜底(armRestoreFallbackIfNeeded)都不会触发,待恢复的历史会一直挂着,直到本地打开这个 pane。
  • 后果 1:agent 用 glint pane read --scrollback 读不到上次会话的历史。
  • 后果 2:之后用户在本地打开该 workspace 时,restoreScrollback 通过 process_output 把旧历史追加在 CLI 这段会话的输出之后,顺序颠倒,中间还多出一条 restored 标记。
  • 相关疑点(未核实):CLI 路径完全没设置终端尺寸(Web 路径会调 setWebRemoteGridSize),shell 和 TUI 可能按 ghostty 初始默认尺寸运行,而不是窗口尺寸。

建议:CLI 唤醒时设置一个合理的网格尺寸(例如存档记录的宽度 requiredRestoreCols,或 80×24),并在返回读结果或注入文字之前先完成历史回显;或者在有待恢复历史时也像一次性启动命令一样返回 pane-requires-local-open。最好补一个“有滚动存档的 pane 被 CLI 唤醒”的测试。

B. .repairable 会把指向另一个仍然存在的 Glint 的链接也当成要修复

Shared/CLIInstallation.swift:14

判断只看路径后缀 /Glint.app/Contents/Helpers/glint。Debug 构建的产物同样叫 Glint.app,所以 DerivedData 里的开发版会把 /Applications/Glint.app 的正常链接显示为“需要修复”,点“安装”就会把正式版的 glint 改指向 DerivedData。用户机器上同时有两份 Glint.app 时也一样。

建议:只有链接目标已经不存在(悬空)时才判为 .repairable;目标仍存在的另一个 Glint 单独显示为“已链接到另一个 Glint”,不要引导用户覆盖。


A 修复后即可合并,B 和第 6 条的状态色可以一起改,也可以后续跟进。

🤖 Generated with Claude Code

A local socket cannot restore archived history on a zero-sized unmounted surface. Refuse reads and input before creating its shell until local/Web grid restoration completes. Only dangling Glint links are repairable; existing other installations remain linked and protected. Show repairable links as warnings and cover both regressions.
@defia

defia commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

感谢复查,A、B 和第 6 条剩余的状态色问题已在 db1934b 处理,PR 描述与中英文说明也已更新。

  1. A:采用建议的保守方案。 CLI 遇到待恢复滚动历史时,在创建 shell、返回读取结果或注入输入之前返回 pane-requires-local-open,提示先在 Glint 中打开。这里没有给 CLI 新增虚拟网格;Web 仍使用浏览器指定的网格及恢复完成回调。新增用例确认拒绝后没有 live surface、存档保持不变、本地工作区不切换;Web 完成恢复后 CLI 可继续读取,且旧历史位于新输出之前。
  2. B:区分悬空链接与其他现存安装。 只有目标已不存在的 Glint 链接才是 .repairable;指向另一份现存 Glint 的链接显示为“已链接到另一个 Glint”(.linkedElsewhere),设置页不引导覆盖,install 和 uninstall 都拒绝修改它。用例验证链接目标与 inode 保持不变;删除旧目标后才转为可修复。
  3. 第 6 条: .repairable 的 StatusPill 已改为 .warn。

验证:A、B 的新增回归用例先在旧代码上复现失败,修复后通过;65 项相关 XCTest、20 项 CLI 子进程测试通过,当前提交的完整 CI 也已全部通过。本轮未安装或发布 Release,未重新执行真实 UI 目检。

@defia
defia merged commit fde0372 into chenbstack:main Nov 3, 2026
1 check passed
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