Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Agent 笔记:插件自有原生终端与有界重开上下文

状态:已实现

## 先说结论

Docker 等工具插件可以请求 Lithe 在现有原生终端里启动程序,插件负责可执行程序和参数,宿主负责显示、伪终端(PTY,让命令像在普通终端里运行的系统接口)和进程清理。插件只能控制自己后端会话在指定工作区创建的终端。关闭后可用同一身份重新打开,保留插件指定长度的最近显示文本,但不重放输入,也不承诺恢复旧进程或 Shell 内存状态。

## 问题

Docker 插件需要一键进入容器,但公共 API 计划中的 API-24 只有候选名称,宿主没有对应服务。只加网页按钮不能完成工作流;把 xterm.js 和 Docker exec 流塞进插件又会重复宿主已有的终端渲染、输入、缩放、窗口和退出流程。

用户进一步要求关闭后重开时带上一部分上下文,长度由插件选择。这里的上下文必须说清楚:终端显示历史可以安全地作为历史展示,自动重放历史命令却可能重复删除、部署或数据写入;一个已经结束的进程也不能靠显示历史恢复变量和前台程序状态。

## 决策

### 复用原生终端,扩展现有宿主服务

- 以函数协议 3 的 `terminal` 宿主服务新增能力,通过 `getCapabilities` 发现,不升级协议版本。不另建通信系统,也不把交互客户端伪装成 Build/Run Provider。
- 新权限 `terminal.create` 展开成具体方法授权,要求 `backend:2`。Web 页面调用插件自己的函数,后端拿本次调用的工作区上下文请求宿主;不从全局焦点猜窗口。
- `PluginTerminalHostService` 负责句柄、权限之后的归属检查、参数预算和生命周期。`AppModel+PluginTerminal` 把请求适配到既有 `TerminalFeatureModel`;`MacTerminalTransport` 继续拥有实际 PTY 和渲染。
- 启动参数保持 executable/argv 分离,cwd 是工作区目录资源;原生层校验 canonical cwd 不越界。执行前显示可取消确认,等待期间切工作区、停用插件或取消都不能继续启动。
- 执行确认把完整程序、参数数组和工作目录放入只读的原生文本视口,支持滚动、选择与复制;用 JSON 转义区分参数边界和控制字符。不能把执行详情塞进限制八行的普通通知正文,否则长参数会遮住后续参数和目录。文本布局与可见字形绘制由 TextKit 负责,不随滚动重建全量控件。
- `show:false` 在创建与重开时都保留原有活动终端,不挂载标签、不显示面板或请求输入焦点;后续显式 show 才注册标签并选择新终端。否则仅有编辑器区域终端时,工具区域会把新标签当成默认显示项。终端模块创建入口显式接收选择选项,不依赖创建后再切回旧标签。
- 首版仅本机 macOS。远程 authority 显式拒绝,Windows 未注册 handler,也不宣传已经支持。SDK 的 Node、Java、Rust 封装保留能力发现与旧宿主不可用语义。

### 终端身份与资源寿命

每个句柄归属 pluginID、backend sessionID 和 context generation;操作其他插件、旧会话或其他工作区的句柄返回不存在。八个句柄的配额包括已经关闭但仍可重开的句柄,插件用 `dispose` 明确释放;不能无限累积历史和启动配置。

`close` 结束该进程会话并移除原生标签,保留启动配置及有限历史;`reopen` 先确认,再启动新的 PTY,沿用 terminalID 并增加进程 generation。退出订阅每个 generation 只发布一次,旧原生回调不能结束新进程;正在 close/reopen 时再次变更返回 busy。用户关闭标签或程序自然退出同样可以重开。

用户关闭标签与自然退出也要经过有界原生清理、按预算截取历史并释放整个终端会话和渲染缓冲区,不能只从界面移除标签。清理期间句柄保持 busy;清理结束后才发布该 generation 的退出通知,收到通知后可立即重开。`retainedContextBytes:0` 在这两条路径同样不保留显示历史;无需等待插件再调用 close 或 reopen 才释放。

用户关标签已发起 stop 后,退出收尾仍会调用 stopAndWait 等待其清理,所以子进程回收器必须按 PID 复用同一观察源。重复登记只追加完成回调;不要先创建一个 DispatchSource 再判断是否重复,未激活的重复源被释放时会让 libdispatch 直接终止应用。原生 PTY 的用户关标签回归覆盖这条真实重复调用链。

后端结束、插件禁用/更新和工作区关闭,释放所属句柄并通过既有有界原生清理结束其进程。独立用户终端不参与这张表。网页关闭只取消在途调用,不结束已经交付给用户的终端。

### 上下文只恢复显示,不恢复执行

- 插件以 UTF-8 字节数配置 `retainedContextBytes`,默认 16 KiB,上限 256 KiB,0 表示不保留;close 可覆盖创建时的选择。总上限受八个句柄配额约束。
- 只在关闭/重开边界从成熟 SwiftTerm 渲染缓冲区取后缀,不在每个输出字节上维护第二份日志。先二分定位缓冲区末端,只翻译满足字节预算的末尾行,避免为少量上下文测量整篇历史。
- 历史变成去控制字符的纯显示文本,按 Unicode scalar 边界截断;启动前喂给显示器,不能写新 PTY 的 stdin。颜色、TUI 控制状态和进程内变量不恢复。
- 历史和启动环境只放当前宿主内存,不写 bundle、插件目录、缓存或持久状态,不影响代码签名和 Sparkle delta 基线。已由程序显示/回显的秘密可能仍在快照中,插件可选择 0;宿主不读取其他终端。

正确做法:Docker 插件声明明确连接和容器 ID,调用通用终端服务启动 CLI;关闭后重开显示最近输出,再获得一个新容器 Shell。另一个插件启动数据库交互客户端也用相同接口。

不要这样做:宿主判断工具名为 docker 时拼参数、往用户正在编辑的终端输入命令,或重开时自动执行保留输出里的命令文本。

## 考虑过的备选方案

- 插件内嵌 xterm.js 与私有 exec 传输:会重复原生终端和生命周期,且用户明确要求使用 Lithe 终端,拒绝。
- 创建普通 Shell 后 sendText:增加 Shell quoting、提示符准备竞态及忙碌终端误注入风险,首版只直接 argv 启动。
- 把 close 当隐藏、让进程继续运行:与终端关闭含义不符,也让插件禁用后的资源处理含糊,拒绝;如未来需要 detach,必须单独定义能力。
- 保留原始 ANSI 流并回放:可能触发 OSC 标题/链接及设备查询,还要维护第二份输出缓存,采用成熟终端缓冲区的纯文本后缀。
- 持久化所有历史并跨重启恢复:带来秘密持久化与进程身份恢复问题,首版限定当前后端会话和工作区内存寿命。

## 后果

工具插件能复用 Lithe 的完整原生终端,跨工具无特判,并通过 close/reopen 少做手动 Shell 操作。代价是首版没有读取终端输出、注入输入或恢复旧进程状态的公共 API;重开会重新执行原始启动 argv,必须再次确认,插件应为交互会话提供适合重复启动的入口。SDK 封装在独立仓库维护,契约副本仍须在主仓库合并后由同步工具导出,不能手改镜像。

## 验证

- `scripts/macos/test/test-macos.sh --filter 'PluginTerminalHostServiceTests|PluginTerminalNativeTests|TerminalModuleTests'`:隔离、未知字段、remote 拒绝、关闭工作区期间的在途创建、退出去重、generation、历史后缀及真实 PTY 输入不重放;后台创建/重开保留已有选择,用户关标签和自然退出按 0/8 字节预算释放原生缓冲区,长参数完整进入原生可选择视口且取消不启动进程。
- `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib plugin_function` 和 `--lib terminal_permission`:逐方法授权与旧协议拒绝。
- `scripts/architecture/verify-service-boundaries.sh`、`scripts/architecture/verify-shared-contracts.sh`、`scripts/macos/verify/verify-runtime-bundle-immutability.sh`。
- `scripts/agent-notes/verify-agent-notes.sh`、`scripts/platform-feature-matrix/verify-platform-feature-matrix.sh`。

原生 PTY 集成测试不替代用户在安装插件中的完整点击、滚动、窗口焦点和退出验收;功能矩阵的待验收状态按真实证据维护。

## 适用范围

- `macos/Sources/Lithe/Application/Features/PluginTerminalHostService.swift`
- `macos/Sources/Lithe/Models/AppModel/AppModel+PluginTerminal.swift`
- `macos/Sources/LitheTerminalModule/`
- `macos/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift`
- `rust/lithe-core/src/lsp/interface/plugin_function.rs`
- `shared/contracts/plugins/v1/`
- `docs/development/macos-plugin-api-plan.md`
4 changes: 2 additions & 2 deletions docs/development/macos-plugin-api-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Windows 不在本轮,平台矩阵的 partial/pending 保持真实。
| API-21 | 既有能力 | 协议 2 credentials/get/set/delete;权限与插件隔离不变 |
| API-22 | 部分完成 | 既有函数进度与取消;独立 output channel、通用 log/progress API 未做 |
| API-23 | 部分完成 | 已有进程声明与运行时解析;通用 toolchains/process 服务未开放 |
| API-24 | 未做 | 插件专属终端 create/sendText/onExit 未开放 |
| API-24 | 部分完成 | 已公开插件自有原生终端 create/show/close/reopen/dispose/onExit(macOS、本机);不开放 sendText/输出读取,Windows 待接入 |
| API-25 | 部分完成 | 既有语言/LSP 贡献;新增 `languages:2` 的清单与包内 JSON 语法/编辑配置,仅支持 Monarch;运行期传入 JSON 动态注册/替换高亮 Provider、独立语言语义 Provider 未做,详见下方范围说明 |
| API-26 | 未做 | 公共诊断 collection/events API 未开放;既有 LSP 诊断不等于该 API |
| API-27 | 部分完成 | 独立 `editor/registerActions` 绑定普通函数与文档范围,不依赖 LSP/Build/Run;通用主题、片段、其他装饰未做 |
Expand Down Expand Up @@ -228,7 +228,7 @@ Mac 当前声明的能力版本为:`backend:1`、`buildModelInputs:1`、`build
| API-21 H | `secrets.get/set/delete`;已有函数协议 v2 credentials 服务 [S2] | 保持本插件隔离和现有凭据授权;不得读取 IDE/Agent/其他插件密钥,不将 secret 放日志、普通配置或状态。先补 SDK/文档一致性,不另造密码库 | M1 |
| API-22 H/E | `output.createChannel/append/dispose`、`log`、`progress.withCancellation`;已有函数进度与输出生命周期 [S2/S11] | 插件/操作归属、日志脱敏与字节预算;取消/超时后不再更新 UI。只复用已有进度状态机,不把 stdout 协议流与业务日志混用 | M1:命令结果/进度;M3:独立输出 |
| API-23 H | `toolchains.resolve/list`、受管 `process.run/cancel`;已有 executable/runtime 解析与进程链路 [S1/S11] | 显式执行授权,executable/argv 分离,cwd/环境白名单、输出和超时上限、进程树清理;不提供任意 shell 字符串,也不自动下载运行时 | M4 |
| API-24 H/E | `terminal.create/show/sendText/onExit`;已有终端模块 [S12] | 创建与写终端都按执行授权处理;只控制自身终端,不能读用户其他会话或注入命令。禁用释放插件拥有资源,不停止用户独立终端 | 后续 |
| API-24 H/E | `terminal.create/show/close/reopen/dispose/onExit` 已复用原生终端模块 [S12];函数协议 3 宿主服务 | `terminal.create` 权限、创建逐操作确认;只控制本 backend session/context 的终端,禁用/关闭工作区清理。无 sendText 或输出读取;本机 macOS,Windows 与远程未实现 | 首版接线,原生验收见功能矩阵 |
| API-25 C/P | 语言/语法/LSP、补全/悬浮/定义/符号/格式化/重命名/Code Action;已有语言服务器贡献 [S1/S13] | 优先接成熟 LSP 的完整能力;自定义 Provider 仅补上游确实缺失项,按文档版本取消旧请求。避免让 SDK 成为第二套语言/工程引擎 | M4:已支持能力文档;`languages:2` 声明式语法已实现(见 Agent Note 2026-10-31-plugin-editor-extension-apis);后续:新 Provider |
| API-26 H/P/E | `diagnostics.createCollection/set/delete/onDidChange`;已有 Problems/诊断 owner [S13] | 只能写本插件 collection,带文档版本和稳定范围;编辑/卸载后旧诊断可撤回。不能覆盖其他 LSP 结果或伪造宿主诊断身份 | M4 |
| API-27 C/H/P | 主题/图标/片段、只读装饰、CodeLens/Inlay;已有内部 theme/editor 能力 [S5/S16] | 数据贡献/资源路径校验、预算与卸载;图标/主题用宿主 token,不允许 CSS/原生视图注入。语言信息优先走 LSP,UI 资源不得执行脚本 | 独立编辑器动作 API 已接线(Mac,待运行验收;复用 backend 2/函数协议 3,不升领域版本);主题/片段/其他装饰后续 |
Expand Down
18 changes: 18 additions & 0 deletions macos/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@
"Allow project environment and Maven configuration changes" = "Allow project environment and Maven configuration changes";
"Allow run, build, reload and stop (executes project code)" = "Allow run, build, reload and stop (executes project code)";
"Allow the Agent to continue?" = "Allow the Agent to continue?";
"Allow “%@” to open a terminal?" = "Allow “%@” to open a terminal?";
"Allow “%@” to run build tools?" = "Allow “%@” to run build tools?";
"Always ask before making changes" = "Ask before making changes or running actions that require approval.";
"Always ask to edit external files and use the internet" = "Always ask to edit external files and use the internet";
Expand Down Expand Up @@ -2702,6 +2703,7 @@
"Open a Java project before connecting the debugger" = "Open a Java project before connecting the debugger";
"Open a local folder to start working." = "Open a local folder to start working.";
"Open a local project folder" = "Open a local project folder";
"Open a plugin terminal" = "Open a plugin terminal";
"Open a project" = "Open a project";
"Open a project and enable a plugin that defines tools to inspect its environment." = "Open a project and enable a plugin that defines tools to inspect its environment.";
"Open a project before choosing project paths." = "Open a project before choosing project paths.";
Expand All @@ -2720,6 +2722,7 @@
"Open a source file or choose a project Run configuration" = "Open a source file or choose a project Run configuration";
"Open a text file instead of a folder." = "Open a text file instead of a folder.";
"Open again" = "Open again";
"Open and manage its own native terminals, retaining bounded display history for reopening" = "Open and manage its own native terminals, retaining bounded display history for reopening";
"Open file" = "Open file";
"Open file in editor to see problems" = "Open file in editor to see problems";
"Open folder" = "Open folder";
Expand Down Expand Up @@ -4174,6 +4177,11 @@
"The plugin package's %@ is not an executable file. Reinstall the plugin." = "The plugin package's %@ is not an executable file. Reinstall the plugin.";
"The plugin repository %@ has already been added." = "The plugin repository %@ has already been added.";
"The plugin server returned HTTP %@." = "The plugin server returned HTTP %@.";
"The plugin terminal does not exist." = "The plugin terminal does not exist.";
"The plugin terminal is changing state. Try again when it finishes." = "The plugin terminal is changing state. Try again when it finishes.";
"The plugin terminal is closed. Reopen it first." = "The plugin terminal is closed. Reopen it first.";
"The plugin terminal is still running." = "The plugin terminal is still running.";
"The plugin terminal was not opened." = "The plugin terminal was not opened.";
"The plugin will be removed after restarting Lithe." = "The plugin will be removed after restarting Lithe.";
"The plugin “%@” wants to use your camera and microphone." = "The plugin “%@” wants to use your camera and microphone.";
"The plugin “%@” wants to use your camera." = "The plugin “%@” wants to use your camera.";
Expand Down Expand Up @@ -4259,6 +4267,7 @@
"The stopped source file is no longer available: %@" = "The stopped source file is no longer available: %@";
"The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file." = "The switch controls the Lithe shared block, not the Agent or its own instructions. Save shared changes before switching. Open a row to edit the complete native file.";
"The terminal directory is outside the remote workspace." = "The terminal directory is outside the remote workspace.";
"The terminal is unavailable." = "The terminal is unavailable.";
"The test file is outside the current workspace: %@" = "The test file is outside the current workspace: %@";
"The toolchain query could not be built." = "The toolchain query could not be built.";
"The update could not be installed. Download the release manually and try again." = "The update could not be installed. Download the release manually and try again.";
Expand Down Expand Up @@ -9674,6 +9683,15 @@
"outline.expandAll" = "Expand All";
"plugin.host.invalidParameters" = "The plugin host request parameters are invalid.";
"plugin.host.requestFailed" = "The plugin host request could not be completed.";
"plugin.terminal.busy" = "The plugin terminal is changing state. Try again when it finishes.";
"plugin.terminal.cancelled" = "The plugin terminal was not opened.";
"plugin.terminal.closed" = "The plugin terminal is closed. Reopen it first.";
"plugin.terminal.consent" = "Allow “%1$@” to open a terminal?";
"plugin.terminal.notFound" = "The plugin terminal does not exist.";
"plugin.terminal.open" = "Open a plugin terminal";
"plugin.terminal.permission" = "Open and manage its own native terminals, retaining bounded display history for reopening";
"plugin.terminal.running" = "The plugin terminal is still running.";
"plugin.terminal.unavailable" = "The terminal is unavailable.";
"plugins.java.installNotice" = "Java Support is installed separately. Install it from the Marketplace, or use Install from Disk for an offline package. Your Java settings are kept.";
"plugins.java.installTemplates" = "Install Java Support for Java, Maven and Spring templates…";
"plugins.title" = "Plugins";
Expand Down
Loading
Loading