diff --git a/docs/architecture/agent-runtime-services-design.md b/docs/architecture/agent-runtime-services-design.md index 15cdc03b05..7c566b4bf1 100644 --- a/docs/architecture/agent-runtime-services-design.md +++ b/docs/architecture/agent-runtime-services-design.md @@ -731,7 +731,7 @@ SDK profile 当前从共享产品事实获得与 Headless CLI 相同的能力集 Rust Runtime SDK,不注册未实现的 `RuntimeServices` 能力,也不宣称完整 Desktop profile 可用。CLI 通过 一个调用级上下文把该 Rust 接口、Harness、能力注册、调用级权限和 Agentic 事件广播交给 TUI、Exec、Session、Usage 与 交互模式下的 Peer Host。Rust Runtime SDK 已承接会话创建/列举/删除/基础恢复、重命名/归档、会话模型更新、thread-goal 查询、类型化转录读取、本地分支、用量生成、 -轮次提交/取消与精确结算,以及 CLI/TUI 的工具确认、拒绝和用户问题回答;固定 ID 创建使用独立的 +轮次提交/取消与精确结算、用户显式 Shell 命令,以及 CLI/TUI 的工具确认、拒绝和用户问题回答;Shell 命令通过窄端口回到 Core 的正常 ToolPipeline、权限、工作区路由和持久化 owner,不构成通用 Tool 或进程执行 API。固定 ID 创建使用独立的 `create_session_with_id` 方法,普通创建 DTO 只增加可选工作区 ID 与模型 ID 事实,不承载调用方指定的会话 ID。 未实现该能力的提供方返回类型化不支持错误;实现成功时 Runtime 必须校验返回 ID 与请求完全一致,不能 替换为自动生成的 ID。`SessionSelector::Create` 仍保持自动生成。Peer Host 通过同一 Rust Runtime SDK 处理对话提交、精确取消、 @@ -1132,7 +1132,7 @@ Product 测试: turn、处理基础会话控制、更新会话模型并处理工具确认/拒绝;本地工作区快照准备、文件清单、统计和文件回滚通过独立 owner port 复用 Core 实现, 富历史和其余持久化维护缺口仍通过单一 Core 兼容接口处理,不再构造独立调度器、持久化 manager 或事件队列; wire schema、Relay ACK/重放和重连协议未在该切换中扩张。 -- CLI 主会话客户端通过 Rust Runtime SDK 处理 session、transcript、fork、本地 Session undo/redo、usage report、用量卡片完成态本地命令轮次、turn、cancel 与 settlement;undo/redo 使用独立窄 port,由 Core 统一暂存 transcript、模型上下文与工作区边界,不扩展 `RuntimeServices` 为 service locator;其他 preview 缺口仍通过一个 Core 兼容接口处理; +- CLI 主会话客户端通过 Rust Runtime SDK 处理 session、transcript、fork、本地 Session undo/redo、usage report、用量卡片完成态本地命令轮次、用户显式 Shell 命令、turn、cancel 与 settlement;Shell 命令复用正常 ToolPipeline 和远程工作区路由,undo/redo 使用独立窄 port,由 Core 统一暂存 transcript、模型上下文与工作区边界,不扩展 `RuntimeServices` 为 service locator;其他 preview 缺口仍通过一个 Core 兼容接口处理; 该接口复用现有归属模块,不建立第二套状态或事件格式。 - CLI 托管的 ACP 服务端已以 `DeliveryProfile::Acp` 构造真实 Runtime Parts;会话创建/列举、轮次、取消、会话模型更新、工具确认/拒绝和 Agent 事件订阅复用同一 Agent Runtime API 语义,ACP stdio、连接与协议转换保持不变。Agentic Event Queue 仍是唯一事件归属模块; diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index bea0eee568..1191ee01db 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -1,811 +1,216 @@ -# BitFun CLI 产品线需求与架构设计 - -本文定义 BitFun CLI 产品线的需求边界、目标架构和阶段验收标准。外部来源的统一产品体验见 -[`external-ai-work-sources-design.md`](extensions/external-ai-work-sources-design.md);稳定的仓库级接口边界以 -[`product-architecture.md`](product-architecture.md) 为准;Agent Runtime、工具和工作流归属见 -[`agent-runtime-services-design.md`](agent-runtime-services-design.md);插件宿主与 OpenCode 适配边界见 -[`plugin-runtime-design.md`](extensions/plugin-runtime-design.md);跨 GUI/TUI 的产品定制、品牌资源、界面 -布局选择和内置扩展见 [`product-customization-blueprint.md`](product-customization-blueprint.md);HarmonyOS PC 原生 -CLI/TUI 的目标、问题和风险规约见 [`platform-portability-design.md`](platform-portability-design.md);跨专题顺序见 -[`../plans/product-architecture-evolution-plan.md`](../plans/product-architecture-evolution-plan.md)。本文只补充 -CLI 产品入口、配置兼容、TUI 布局消费和 CLI Agent 体验,不重复定义这些文档中的通用契约或内部 ABI。公开 -BitFun Agent SDK 与 Headless CLI 的产品选择、能力一致性和 SDK Host 边界见 -[`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md);多个 GUI/TUI/Remote/CLI 实例并存时,交互式 TUI -连接 Shared Agent Runtime、一次性 CLI 保留 Embedded 的部署边界见 -[`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md)。 -OpenCode 的完整扩展矩阵、配置资产、插件执行和 TUI Plugin 映射分别见 -[`opencode-extension-compatibility.md`](extensions/opencode-extension-compatibility.md)、 -[`opencode-config-assets-adapter-design.md`](extensions/opencode-config-assets-adapter-design.md)、 -[`opencode-plugin-runtime-adapter-design.md`](extensions/opencode-plugin-runtime-adapter-design.md)、 -[`opencode-tui-plugin-adapter-design.md`](extensions/opencode-tui-plugin-adapter-design.md) 和 -[`opencode-external-integration-adapter-design.md`](extensions/opencode-external-integration-adapter-design.md)。 - -本文是目标设计,不记录单次 PR 进度。已有能力必须在迁移中保持兼容;尚未完成的能力不能因为 -出现在本文中就被视为已交付。 - -本文使用 CLI-P0/CLI-P1/CLI-P2 表示 CLI 产品成熟度,不替代 OpenCode 兼容矩阵的 OC-R 分区或执行计划的 OC-E -切片。映射关系为: - -| CLI 阶段 | 可消费的插件里程碑 | 边界 | -|---|---|---| -| CLI-P0 / CLI-P1 | 当前基线、OC-E0 | 只提供 BitFun 原生目录的来源确认和 OpenCode 静态工具名称预览;不注册为可执行工具。 | -| CLI-P1 / CLI-P2 | OC-E1 | 一个无外部依赖的 standalone custom tool 真实执行并进入现有 Tool Runtime,支持身份/路径字段和 `abort`;`metadata`/`ask`、官方 import 型样例、package plugin、Hook 与 TUI plugin 仍未承诺。 | -| CLI-P2 | OC-E2/OC-E3 | package plugin、Hook 和 TUI contribution 只按真实阻塞样例增加;原始 Solid/OpenTUI 组件明确降级。 | - -## 1. 目标与边界 - -### 1.1 产品目标 - -BitFun CLI 应成为可独立安装和发布的 Agent 产品,而不是 Desktop 的终端壳。目标包括: - -1. 覆盖交互式 TUI、非交互自动化、会话生命周期、工具与权限、MCP、Skill、Subagent 和诊断等 - 高频工程完整流程。 -2. CLI、Desktop、Server、ACP 和 SDK 共享 Agent Runtime 语义,不复制会话、工具、权限或上下文逻辑。 -3. 通过适配器直接消费或显式导入 OpenCode、Codex 和 Claude Code 的常用配置资产;外部格式不成为 BitFun - 内部模型,但兼容来源可以作为合法运行输入。 -4. 通过 `PluginRuntimeClient`、Plugin Host 和 OpenCode adapter 的插件兼容接口接入服务插件、工具、Hook 和 TUI Plugin; - 来源完成首次激活后本地运行兼容优先,用户、产品或组织可以按需收紧权限。 -5. 消费已校验的产品组装结果和 TUI 布局选择,生成不同品牌和能力范围的 - CLI 产物;通用产品定制不在 CLI 入口重复实现。 -6. 以任务成功率、恢复能力、工具正确性、上下文效率和资源开销评估 Agent 能力,而不是只比较命令数量。 -7. 把 HarmonyOS PC 作为一等 CLI/TUI 目标:用户在系统真实终端中安装并执行本地 `bitfun`;废弃兼容入口 - `bitfun-cli` 不作为新集成入口。HAP 内终端式 - 界面、`hdc shell`、移动 Remote App 和其他设备代执行均不构成该目标。 - -### 1.2 能力对齐口径 - -“与 Codex CLI、OpenCode CLI 或 Claude Code CLI 平齐”只指本地 CLI 常用工程场景,不自动包含 -其云端、桌面端、浏览器扩展、组织后台或托管执行能力。对齐项分为三类: - -| 类别 | 处理方式 | -|---|---| -| 通用工程能力 | 由 BitFun 原生实现并保持自己的运行时语义,例如会话、工具、权限、上下文和结构化执行。 | -| 可迁移资产 | 按类型处理:规则和 Skill 优先作为持续兼容来源,MCP/模型作为待确认配置;显式导入只是可选快照。 | -| 生态专属能力 | 仅在有真实消费方、安全评审和兼容测试时适配;不复制对方完整运行时。 | - -竞品官方文档仅用于维护能力基线,不构成 BitFun 内部接口规范: - -- [Codex CLI](https://learn.chatgpt.com/docs/codex/cli) 与 - [Codex 非交互模式](https://learn.chatgpt.com/docs/non-interactive-mode) -- [OpenCode CLI](https://opencode.ai/docs/cli/) 与 - [OpenCode 配置](https://opencode.ai/docs/config/) -- [Claude Code CLI](https://code.claude.com/docs/en/cli-reference) 与 - [Claude Code 交互模式](https://code.claude.com/docs/en/interactive-mode) - -### 1.3 非目标 - -当前设计明确不包含: - -- 逐像素复制其他产品的 TUI,或复用其内部主题键、快捷键模型和界面状态。 -- 向 OpenCode、Codex 或 Claude Code 配置文件做双向写回,或让外部原始类型成为 BitFun 内部数据模型;只读兼容 - 结果可以随来源变化刷新。 -- 同时建立 OpenCode、Codex 和 Claude Code 三套插件运行时;首个插件执行兼容对象只有 OpenCode。 -- 在产品定义或 TUI 布局选择中加入任意命令、动态代码、renderer、源码文本替换或运行时 Hook。 -- 为每个白标产品 Fork 一套 Rust/React/TUI 实现。 -- 为追求接口完整而提前发布无消费方的代码接口;完整需求矩阵和阶段计划仍必须记录全部官方稳定能力。 -- 一次性把 `bitfun-core` 的全部行为迁移到 Agent Runtime;所有职责迁移仍需行为等价证明。 - -## 2. 当前基础与主要缺口 - -当前主线已经具备以下基础: - -- 交互式 TUI、Markdown/代码/Diff/工具卡片、主题、模型/Agent/MCP/Skill/Subagent/Session 选择;OpenCode 对齐的 `/diff` 通过 Runtime Git port 查看当前工作区 staged、unstaged 与 untracked 的 `HEAD` 相对差异;权限请求默认询问, - 提供 `Allow once / Allow always / Reject`,其中 `Allow always` 只对当前运行上下文中的同名工具有效。 -- `exec` 支持 stdin、会话恢复/分叉、Patch 输出和 `text/json/stream-json`。非交互执行默认拒绝权限请求, - 显式 `--auto` 才在本次调用内自动批准;兼容参数 `--confirm` 隐藏并映射到安全默认值。`Ctrl+C` 会请求取消 - 当前 turn;失败完成事件、事件流失步和 Patch 写入失败均返回错误结果。 -- Agent、模型、MCP、会话、用量、诊断、ACP 外部 Agent 和插件来源管理命令。 -- BitFun 原生插件目录的发现、内容校验、来源确认,以及 OpenCode custom tool 静态名称预览。 -- CLI 本地 Agent 入口以类型化 `RuntimeServices` 调用 `ProductAssembler`,选择 `DeliveryProfile::Cli`, - 并把 `ProductRuntimeParts`、Rust Runtime SDK、本地工作区快照 owner port、事件源和调用级审批策略保存在一个 `CliRuntimeContext` 中。 -- TUI、`exec`、会话、用量和交互模式下的 Peer Host 复用同一上下文。Rust Runtime SDK 已承接会话创建(包括 - `exec --session-id` 和缺失后端会话通过独立固定 ID 方法按原 ID 重建)/列举/删除/恢复、类型化转录、本地分支、 - 用量生成、轮次提交/取消和精确结算;普通创建 - DTO 只增加可选工作区 ID 与模型 ID 事实,固定 ID 冲突返回 `InvalidRequest`。会话模型更新、工具确认/拒绝和用户问题回答也通过 - Rust Runtime SDK 的窄端口回到 Core owner,TUI 与 ACP 的活动会话模式更新也复用同一窄端口;模型/模式目录和提供方配置仍由产品入口解释。 - TUI 模式切换以异步待提交状态调用 Core,期间终端输入、resize 和重绘保持响应,新的对话提交不会消费用户输入; - 只有 Core 校验并持久化成功后才更新本地显示,失败时保留原模式并给出可重试提示,同值选择不产生持久化写入。 - 等待期间可以切换或新建会话;只有原会话的发送继续等待。首次退出请求在持久化成功后自动退出,失败时留在界面提示重试;再次退出允许立即离开, - 并明确提示下次恢复以 Core 的持久化模式为准。恢复主会话时,已从当前目录移除的持久化模式由 Core 迁移到可执行回退模式; - TUI 对比恢复前后的会话摘要并显示模式变化,如果同时携带启动输入,只预填而不自动执行,须由用户确认后发送。TUI 用量卡片通过固定语义的 - 完成态本地命令轮次端口持久化。Peer Host 的本地工作区准备、会话文件清单、类型化快照统计和工作区文件回滚通过 - 独立的本地 owner port 回到现有 Core 快照实现;该端口不进入共享 Agent Runtime API 或公开 Agent SDK,不接受远程身份,也不承载历史截断、维护锁或完整 checkpoint/rewind。 - 账号同步、富历史及 Peer Host/ACP 的其余维护缺口继续由一个 Core 兼容接口转发给原 owner。 -- Agentic Event Queue 仍是唯一事件 owner;TUI、`exec` 与 Peer Host 使用独立广播订阅,不互相消费事件。 -- 有界旧队列只承担兼容存储;达到容量时不得抑制广播。CLI 保持一个后台 drain,订阅方一旦报告 lag/closed, - 必须取消活动 turn 并显式失败,不能在状态不完整时继续报告成功。 -- 会话 ID 在进入存储路径前统一校验;运行时索引同时绑定 ID 与规范化存储路径,并以待提交 claim 计数保护 - 并发恢复。同一进程不能把另一个工作区中已加载的同 ID 会话当作当前会话,单个失败恢复也不能释放其他 - 同路径恢复仍在使用的绑定;主会话提交不做前置完整 restore,只有 Runtime owner 返回结构化 `NotFound` 时才恢复或 - 按原 ID 重建并重试一次,其他资源缺失与后端错误原样失败。删除路径不能通过 - 相对路径、绝对路径或分隔符越出 sessions 根目录。 -- TUI 终端句柄由恢复守卫持有;初始化中途失败、正常返回、错误返回或 panic 展开都会尽力退出 alternate screen、 - 关闭输入捕获、关闭 raw mode 并显示光标。真实 PTY/ConPTY 启动页进程冒烟测试已验证 resize 后仍可交互、 - 多行输入、空闲 Ctrl+C 和可观察的终端清理序列;Chat 活动 turn 的 resize 静默期已有状态单测,窄屏流式 - reflow 已有 TestBackend 回归,Linux PTY 与 Windows ConPTY 活动 turn 的 resize/取消已有本地确定性流式模型夹具进程测试; - `exec stream-json` 的 Ctrl+C 也由真实 PTY/ConPTY 进程验证断流、非零退出和单一取消最终状态。OS 级初始化失败与 - 异常退出仍需独立验收。 -- Startup 与 Chat 共用 CLI 私有输入读取器;一次读取同时受 256 个事件和 50ms 限制,跨批次仅延续快速文本尾部, - 短批次普通按键保持原有路由。被识别为粘贴的文本按批次写入输入缓冲,每批只刷新一次命令菜单;粘贴内容中的 - Tab 明确转换为四个空格。 -- 初始化按入口分级:交互模式启动 Peer Host 与 MCP,`exec` 只启动 MCP;本地 session 管理和 usage 查询不启动 - Peer Host/MCP。该分级不改变 Agentic/Terminal owner,也不等同于管理命令已有独立轻量 Runtime。 -- Peer Host 保持既有 HostInvoke / DeviceEvent wire schema 与 Relay 路由,但执行已接入上述调用级上下文: - 对话提交、精确取消、会话创建/基础恢复/重命名/归档、thread-goal 查询和模型更新走 Rust Runtime SDK;本地快照文件清单、统计和文件回滚走窄 owner port; - 富历史及其余维护缺口走单一 Core 兼容接口。Peer Host 在进入本地端口前对远程身份与远程路径返回明确不支持错误,并继续拥有回滚前取消、维护锁、历史截断、部分失败提示和事件转换。Peer Host 只跟踪由 Peer 提交的根 turn、 - 其子 turn 与待确认工具;可确认工具始终由控制器确认,即使宿主全局策略跳过确认,Agent 也会暂停等待控制器。 - 该 Peer 专属确认要求会沿精确后台结果 follow-up 保留。后台结果按 Core 内部元数据中的精确父 turn 与来源子 turn - 继承 ownership;仅在父 turn 仍运行时注入,否则排在 - 无关 turn 之后并保留 Peer ownership。来源 turn 完成而结果仍等待会话串行化时,仅保留有界、一次性的完成标记; - 会话清理或事件流中断会移除它。最后一个控制器离线 - 或分离、事件订阅 lag/closed 时取消这些 turn;事件失步同时投递既有失败 terminal event,最终状态在实际发送尝试前 - 不提前清理,队列关闭时改走同一直接投递路径。事件以入队时的控制器快照为上界,每个目标发送前再确认仍连接; - 单目标投递租约将分离或离线移除与本地 Relay 入队尝试串行化。显式断开无法确认取消时,本地界面仍安全退出并 - 显示警告。不承诺本次变更范围外的 ACK、重放或重连恢复。 -- `doctor` 与 `health` 构造并校验真实 Runtime Parts,区分 assembly-ready、Core compatibility owner 和不可用扩展。 - 它们证明必需能力已注册,不把 Core 的 Network/Git/MCP compatibility marker 描述为外部服务实时可用。 -- 独立 CLI 测试与打包工作流;主 CI 的 Windows/macOS/Linux workspace check 同时覆盖 Cargo package - `bitfun-cli` 编译,原生发布归档包含主入口 `bitfun` 和废弃兼容入口 `bitfun-cli`,上传前校验 SHA-256 摘要, - 并从解压后的目录验证两个入口及废弃告警。 - -上述切换不等于运行时 owner 已迁移,也不表示 CLI-P0 全部完成。CLI crate 仍以 `bitfun-core/product-full` -承载协调器、调度器、持久化、工具管线和 Rust Runtime SDK 当前未覆盖的部分,但 Peer Host 不再自行构造这些 owner;ACP 的 stdio、 -连接和协议转换仍由 `interfaces/acp` 持有,后端已切换至 `DeliveryProfile::Acp` 与组装后的 Rust Runtime SDK;插件命令 -仍以来源管理和静态预览为主。兼容接口只转发,不重新计算或写入同一事实。 - -目标态仍存在以下结构缺口: - -| 缺口 | 影响 | 本设计的处理 | -|---|---|---| -| CLI 主会话客户端已仅消费 Rust Runtime SDK;本地工作区快照的准备、文件清单、统计和文件回滚已有 Desktop/Peer Host 共用的窄 owner port,但快照记录/持久化/事件、账号同步、富历史及 Peer Host/ACP 的其余维护仍由现有 Core owner 提供 | 窄端口只消除重复宿主转发,不代表完整快照系统、远程快照或公开 SDK 能力已迁移;过早删除其余兼容路径会改变行为 | 保持快照实现和工具拦截在 Core,远程与历史维护留在宿主;仅在新的真实调用方、独立语义和行为等价测试齐备后继续迁移。 | -| TUI 编排、输入、命令、副作用和渲染仍有大文件聚集 | 交互回归难以隔离,终端状态与业务状态容易耦合 | 在现有模块上逐步拆成事件、状态处理、副作用和渲染四个边界,不重写全部 TUI。 | -| CLI 配置只覆盖入口本地选项,除 C0a 外部 MCP 快照导入外仍缺少统一层级、来源解释和通用兼容导入 | 用户无法安全复用其他 CLI 资产,也难以解释最终配置来源 | 保留现有 MCP 窄入口,后续按真实资产建立 BitFun Canonical Config、持续来源视图和可选的显式导入报告。 | -| OpenCode 来源发现与真实执行尚未形成完整流程 | “来源可识别”容易被误解为“插件可执行” | 先完成一个无外部依赖的样例;取得真实 `execute` 并注册到 Tool Runtime 后才显示可用。 | -| 当前 CLI 使用 `product-full`,OHOS target 图包含多组未验证的平台依赖 | 不能据依赖可解析、`hdc shell` 或移动 Remote App 推导 PC 本地 CLI/TUI 可用 | 问题与风险统一记录在平台规约;具体工作另立专题,HAP 不作为替代。 | -| Product Capability 已有,但品牌、资源、默认策略和发行配置没有统一产品定义 | 白标需要修改多处常量和工作流,能力隐藏不等于后端禁用 | 产品定义只在组装/构建边界选择身份、资源、能力包、默认策略和发行事实。 | -| CLI 已有 Linux、PTY/ConPTY、本地模型和发布归档测试 | 真实供应商审批与 OS 初始化故障仍可能漏检 | 只补剩余真实故障,不重复建立三平台编译矩阵。 | - -## 3. 分阶段产品需求 - -### 3.1 CLI-P0:产品基础统一 - -CLI-P0 的目标是建立后续功能补齐所需的稳定边界,不改变现有用户主路径。 - -CLI-P0 不是一个统一重构 PR。静态 profile、真实 Runtime Services、Runtime Parts、调用级审批、共享事件源和 -本地 Agent 入口已接入;旧兼容接口仅在后续归属迁移的行为等价成立后退出。配置解释、产品定制消费和 TUI -进一步拆分仍需独立交付。CLI 托管的 ACP 服务端已独立切换到 ACP profile 与组装后的 Rust Runtime SDK;启动页 -PTY/ConPTY 生命周期、Chat 活动 turn 的 resize/取消和发布归档冒烟测试已存在;resize 静默期与窄屏流式 reflow -分别有确定性状态单测和 TestBackend 回归。真实 `stream-json` 进程已保护 Patch 写入失败、本地模型 HTTP 403 授权拒绝、 -流中断后的重试失败和 `exec` Ctrl+C 单一取消最终状态;真实供应商审批交互与 OS 级终端初始化故障注入仍需另行完成。 - -其余工作独立立项,不能与 profile 迁移互相充当完成条件: - -| 切片 | 范围 | 退出条件 | -|---|---|---| -| 调用级审批 | TUI、`exec` 与 ACP 已使用各自调用级策略且不写全局配置 | Runtime-context `Allow always`、审批规划、`exec` 安全默认值和显式 `--auto` 有 focused test;真实模型/PTY 审批流与 ACP 仍需另行验收 | -| 输出协议 | 保留 `text/json/stream-json`,复用现有 Agent 事件 | 单一最终状态、失败优先级和 `success=false` 规则见下文;真实供应商审批仍需验收 | -| 配置解释 | Canonical Config 层级、全局/项目持续来源、加载状态和非 MCP 兼容导入 dry-run | 除已单独评审的 MCP C0a 快照导入外不自动写入;冲突、未知字段、待确认能力和凭据引用可解释 | -| 产品定制 | 消费最小产品定义、组装结果和已注册 TUI layout/theme ID | 第二个真实 CLI 产品复用后再提升公共字段 | -| TUI 边界 | 增量提取终端恢复守卫、命令分发和副作用边界 | 不改版视觉设计;Linux PTY 与 Windows ConPTY 活动 turn 的 resize/取消、恢复可编辑状态和正常退出清理可单独验证,macOS 活动 turn 与 OS 级初始化失败注入另行补齐 | - -CLI-P0 不包含插件 JS/TS 执行、完整 checkpoint/rewind 或大规模 TUI 重写。 -CLI-P0/P1/P2 在 Windows、macOS、Linux 完成不表示 HarmonyOS PC 已支持;HarmonyOS PC 的具体适配由未来独立专题 -设计和验证,不能通过关闭必需编码能力直接宣称“本地编码就绪”。 - -### 3.2 CLI-P1:常用 CLI 工程完整流程 - -#### 交互式 TUI - -CLI-P1 应提供: - -- 新建、恢复、继续、分叉、压缩和中断会话;所有动作使用同一 Session/Turn Runtime 语义。 -- `@` 文件/目录引用和受控 `!` shell 请求;shell 仍进入工具、权限、取消和审计路径。 -- TUI 的 `@` 入口遵循 OpenCode 交互:仅在输入开头或空白后触发,Enter 选择文件/目录,Tab 对目录继续下钻,文件可附带 `#start[-end]` 行范围;不新增平行斜杠命令。 -- TUI 只维护 composer 文本、候选框和结构化引用位置。按 Session 绑定的搜索、路径/类型/行范围校验、持久化与历史恢复由 Agent Runtime 端口和 Core owner 负责;Core 不提前读取文件内容,Agent 仍通过既有 Read/Glob 工具进入权限、取消和审计路径。远程工作区当前返回明确的不可用原因,不回退到本地文件系统。 -- OpenCode 对齐的 `/editor`、`/copy`、`/export` 只提供这些命令名,不增加 alias 或默认快捷键。三者从 TUI 当前 `ChatState`/composer 做本地投影,不新增 Runtime operation、IPC 消息或通用 Export/Process port。`/editor` 与 export 的 editor 路径仅使用 `VISUAL` 后 `EDITOR`,由用户提供阻塞等待参数;CLI 在主循环渲染 loading 状态后临时释放 terminal guard,编辑结束后完整重建 raw mode、alternate screen、mouse 与 bracketed paste。编辑失败、非零退出或空文件不替换原草稿;重接管失败时退出而不在半初始化 TUI 中继续。结构化 `@` 引用只在标记唯一或重复标记数量精确匹配时重定位,否则降级为普通文本。 -- `/copy` 固定排除 reasoning、工具输入/结果和 TUI 本地 System 通知,并限制在 Idle,避免慢剪贴板 helper 阻塞活动 Turn 的事件流;`/export` 的专用表单可显式加入 reasoning/工具详情、选择保存及 editor review。导出根目录是 CLI 客户端启动目录,不使用 Remote/Shared Runtime 的 workspace;空值、绝对路径、Windows root/prefix 与 `..` 被拒绝。首次发布使用原子 create-new,竞争出现的文件会回到二次确认,确认后才使用 `services-core` 的严格 UTF-8 原子替换。剪贴板复用本地系统 provider 的平台诊断。Shared TUI 仅共享已有 transcript 事实,文件、剪贴板、临时文件与 editor 进程始终留在客户端。 -- OpenCode 对齐的 `/timeline` 只列出当前 `ChatState` 中的用户消息并按最新优先展示,方向键移动时预览对应位置,Enter 保留跳转结果;不复制 `/fork`、`/undo` 或 `/copy` 的业务动作。时间线与 fork 复用同一个会话点选择器,跳转与常规滚动复用逐消息 render cache 和行布局,不新增 transcript 副本、Runtime operation 或 IPC 消息。`/timestamps`、`/toggle-timestamps`、`/thinking`、`/toggle-thinking` 严格沿用 OpenCode 入口;工具详情只通过 command palette 切换,不增加 slash alias。三项默认展示策略由 CLI 本地配置持久化,View 仅消费配置投影;单个 thinking/tool block 使用同一套相对默认值的 disclosure override。Embedded 与 Shared TUI 行为一致,活动 Turn 中仍可只读导航和切换展示。 -- 图片附件沿用 OpenCode 的 paste 入口和 `[Image N]` 占位符,不新增 slash command、alias 或默认快捷键。Startup 与 Chat 共享同一个 composer draft、图片读取器和 source-range 协调器;PNG/JPEG/GIF/WebP 在粘贴时读取、按真实格式解码并快照为不可变字节,单张上限 20 MiB、单次最多 5 张,只保留文件名而不把绝对路径交给 Runtime。文件读取与剪贴板 PNG 编码在写入期间受硬上限约束,文件解码分配上限为 64 MiB;系统剪贴板 provider 在返回前已完成平台格式解码,BitFun 只能在返回后校验像素尺寸并限制 PNG 编码结果,不能把该 provider 的预解码宣称为受自身上限控制。提交时才投影到已有 `AgentInputAttachment::remote_image`,不新增 Runtime operation、图片专用会话 owner 或第二套发送路径。本地历史、删除重编号、`/editor` 和同一 TUI 进程内按 Session 隔离的成功提交 undo/redo 栈复用原草稿字节,切换 Session 不会混用或丢失关联;输入历史与成功提交栈各自全局最多保留 50 条草稿,并共享 200 MiB 历史图片预算。占位符被删除、变得歧义、超出历史预算或无法与成功提交栈安全关联时只丢弃对应结构化元数据。Shared IPC 的请求帧有界,因此 Shared TUI 在 paste 和提交两层显式拒绝图片并保留草稿,不把 data URL 填入 IPC。 -- 对话 checkpoint 与工作区 checkpoint 的独立事实;rewind 必须明确选择只回退对话、只回退工作区或两者。 -- 后台 Agent/工具/工作流的状态、取消和结果回收,不允许无结果的隐式 detached task。 -- 命令历史、详情/用量视图和终端能力降级。 -- 鼠标关闭、低色彩、窄终端、无剪贴板、非 TTY、屏幕阅读器和不可用通知能力下的纯文本回退。 -- 基于真实长会话建立首屏反馈、按键到绘制、滚动和峰值内存基线,再设置回归预算;不先拍脑袋固定阈值。 - -其中: - -- compact 只重建模型上下文,不删除权威 transcript。 -- rewind 只有在对应持久化和工作区提供方支持时才可用;不支持时返回类型化原因。 -- `!` 不成为绕过 Tool Runtime 的第二条 shell 执行路径。 -- detached 只有在存在明确生命周期归属和结果回收入口时才允许;否则 CLI 退出必须取消任务并返回结果。 - -#### 非交互自动化 - -CLI-P1 应保证: - -- stdin、显式 prompt、固定/恢复/继续/分叉会话互斥关系可验证。 -- `stream-json` 每行一个完整事件;`json` 只输出一个完整结果文档;日志和诊断默认进入 stderr。 -- 失败使用稳定退出码分类:输入/配置、认证、权限、运行时、取消、超时、工具/工作流、输出写入。 -- 大型工具结果和二进制附件只在事件中传递存储引用,不把 data URL 或大块内容写入事件流。 -- 结构化模式下 Patch 只能进入最终结果、已有事件、存储引用或显式文件,不能混入 `stream-json` stdout。 - -当前协议直接采用同类产品的通用输出心智,不建立 BitFun 专属的平行事件分类: - -| 模式 | 当前约束 | -|---|---| -| `text` | 最终助手文本写 stdout;进度、思考、工具状态、日志和诊断写 stderr。显式 `--output-patch -` 是用户选择的额外 stdout 内容。 | -| `json` | stdout 只写一个结果对象,包含 `type=result`、`subtype`、`is_error`、`result`,以及已建立时的 `session_id`/`turn_id`、本 turn 累计 `usage` 和可用的 `patch`。准备 Session 时若命中跨进程单写冲突,额外返回稳定的 `error_code=session_in_use`;其他错误不猜测分类。 | -| `stream-json` | 每行直接序列化一个现有 Agent 事件对象;不增加 `schema_version`、`sequence` 或第二套 CLI 事件分类。准备 Session 时若命中单写冲突,复用 `SystemError`,令 `error=session_in_use`、`recoverable=true`。 | -| 最终状态 | 精确结算和 Patch 交付完成后只发布一次。优先级是:结算失败、Patch 失败、Turn 结果;前两类统一替换为 `SystemError`。一次执行最多发布一个最终事件和一条 `BITFUN_EXIT` 分类。 | -| 事件范围 | 只输出本次 session/turn 的事件,以及与其明确关联的 subagent link/tool 事件;同 session 的其他并发 turn 不得混入。 | -| Patch | `json` 可把 `--output-patch -` 放入最终对象;`stream-json` 要求显式文件路径。Patch 是写出显式 Patch 文件前捕获的仓库 `HEAD` 相对工作区快照,包含 staged、unstaged、untracked 及命令启动前已有改动,不包含输出 artifact 本身,也不表达改动归因。 | -| 权限 | 非交互默认拒绝并返回权限失败;`--auto` 只改变当前提交策略,不修改持久化配置。 | -| 人工输入 | 非交互 `exec` 不暴露 `AskUserQuestion`;调用方必须在初始输入中提供完整上下文。该事实沿 Task、SessionMessage 及其自动回复链传播,避免子 Agent 或后续 turn 等待不存在的 stdin 处理器。 | -| 终止 | 最终事件的 `success=false` 不能映射为成功。`Ctrl+C` 只请求取消;若取消与完成/失败竞争,以实际观察结果为准。到期限仍无最终事件时发布 `SystemError` 并非零退出;只有实际取消使用 `BITFUN_EXIT: cancelled:`。`session_in_use` 同样非零退出,`recoverable` 仅表示关闭另一 writer 后可重新执行,不触发自动重试。当前不公开 Agent Turn 总时限参数。 | - -CLI 不提供 `--output-schema v1`。Codex/Claude 同类参数表达的是调用方提供的 JSON Schema,用于约束最终模型 -响应,不是协议版本选择;如未来支持,应复用该语义并独立设计,不能借此重定义事件对象。 - -#### 与公开 Agent SDK 的关系 - -Headless CLI 和公开 Agent SDK 都调用同一 Agent Runtime API,但交付形态不同。本文件只保留 CLI 约束: - -- `bitfun exec` 面向 shell、CI 和一次性任务,使用 stdin/stdout/stderr、退出码与 `text/json/stream-json`。 -- `bitfun` 不承载隐藏 SDK Host 子命令,也不依赖 SDK Host 协议;独立 `bitfun-sdk-host` app 与 CLI - 分别选择 SDK/CLI profile 和 submission source,只复用同一 Runtime owner,以及由共享产品事实生成的等价能力集合。 -- CLI 不在进程内执行 Python/TypeScript Tool、Permission 或 Hook callback。 -- 公开 SDK 不解析 `stream-json` 作为正式双向协议;它通过版本化 SDK Host 获得 callback 与连接生命周期。 -- 两者的能力对照、共同 fixture 和等价门槛以 - [Agent SDK 产品与宿主架构第 9 节](agent-sdk-product-architecture.md#9-headless-cli-与-agent-sdk)为唯一事实源。 - -交互式 TUI 另有一个显式部署选项:`bitfun --shared` 或 `bitfun chat --shared`。它通过 CLI 私有本机 IPC adapter 连接同一 Agent Runtime,不经过 SDK Host,也不改变 Headless CLI 或公开 SDK 的协议。当前范围如下: - -| 形态 | 默认部署 | 当前 Shared 范围 | -|---|---|---| -| 交互式 TUI | Embedded | 显式 `--shared` 后支持 Session list/create/restore/delete/fork、transcript、当前 Session rename/Agent mode/model、声明式上下文 reload、当前 Session 手动 context compaction、Session undo/redo、只读 workspace diff、Turn submit/cancel、Permission 和 UserInput | -| `bitfun exec` / CI | Embedded | 不接受 Shared;保持独立进程、stdout/stderr 和退出码语义 | -| ACP / SDK Host / GUI / Remote / Peer | 各自既有部署 | 不消费 TUI IPC,也不因本开关改变生命周期 | - -Shared TUI 不提供 Session archive、模型目录/默认值、Agent/Subagent 管理、MCP/扩展、账号同步、用量、observer、replay 或通用 controller transfer;对应入口给出明确的 Embedded 恢复建议,不在 Client 进程初始化第二套 Core owner。 -Shared 模式的斜杠命令、快捷键帮助和底部提示使用同一能力投影:OpenCode 对齐的 `/fork` 以 `Full session` 或历史用户提示词选择分支边界;选择提示词时 fork 只复制该 Turn 之前的历史,并把提示词放回 composer 而不自动发送。`/rename ` 修改当前 Session 名称;`/agent`、Tab 和 Shift+Tab 只切换当前 Session 的 Agent mode;`/models` 只切换当前 Session 的 model;`/reload [skills|instructions]` 刷新下一条消息使用的声明式上下文;OpenCode 对齐的 `/compact` 及其 `/summarize` alias 以一个可取消的 maintenance Turn 压缩当前 Session 上下文,不增加自创命令或快捷键。该 Turn 与普通对话共用 Session 原子准入,取得所有权后再读取待压缩上下文;权威 transcript 保留完整 tool payload,但重建模型上下文时排除该 maintenance Turn。Embedded 与 Shared 的 `/help` 都从 Action Registry 展示这些入口;在 slash menu 中选择 rename 只预填命令并等待用户输入名称。若外部来源使用相同命令名,用户明确选择的 BitFun 命令可完成这一次参数提交,即使偏好保存失败也不会重新弹出来源选择。它们不进入管理页面,也不修改未来 Session 的默认值。其他不支持动作不显示为可执行入口。Session 切换或 fork 失败保留原控制权;Shared fork 只有在新 Session 与 transcript 的响应可编码后才原子转移 controller。单个连接已有活动 Turn 时拒绝重复提交、fork、manual compaction 以及 Session rename/mode/model update,但允许 reload 只影响下一条消息;事件订阅失效后当前视图立即失效并要求重启 Shared TUI。 - -`/diff` 只使用 OpenCode 已有命令名,不增加 alias 或全局快捷键,并且只在当前 Turn 空闲时进入。查看器以 `n`/`p` 切换文件、`]`/`[` 跳转 hunk,并保留方向键和 PageUp/PageDown 滚动;窄屏隐藏文件栏但保留当前 patch。TUI 只持有快照和导航状态,Git 仓库发现、差异计算及 staged/unstaged/untracked 事实由 `services-integrations` 中的 Runtime provider 负责。补丁与 OpenCode working-tree 视图一致,是 `HEAD` 到当前工作树的单一净补丁;staged/unstaged/untracked 仅作为文件状态展示,不在本阶段引入分区补丁或 source switch,相互抵消时仍保留文件和状态。冲突文件保留 `U` 状态并显示明确占位,不把 libgit2 的不完整两路投影伪装成三路冲突内容。Embedded 与 Shared 调用同一个 `AgentRuntime::workspace_diff`;TUI 仅在 Session execution workspace 与 Runtime 绑定的 project workspace 相同时调用,其他 worktree 与 Remote Session 明确返回不支持,避免显示错误的本地工作区事实。Shared 的无请求体只读 operation 不取得 Session lease,但要求连接没有活动 Turn,加载期间同一 Shared 客户端不再发起其他 Runtime 请求。单文件文本 diff 上限 1 MiB、总文本 diff 上限 3 MiB、文件数上限 256,超限和二进制文件显示明确占位。该入口不包含 refresh watcher、split diff、stage/reset/commit、变更归因、Remote 或 Relay 映射。 - -OpenCode 对齐的 `/undo` 与 `/redo` 只提供这两个命令名,不增加 alias、快捷键或通用 checkpoint API。Core 以持久化的 staged boundary 同步裁剪可见 transcript、模型上下文和该边界后的受跟踪工作区改动;`/undo` 把被撤销的用户提示词放回 composer,连续调用可继续向前,`/redo` 逐级恢复直到清除 boundary,usage 与 fork 也只读取该 boundary 前的可见事实。活动 Turn 必须先由 scheduler 完成取消和 drain,失败时不写入 boundary;Shared 仍要求 current controller,并以 Runtime 返回的权威 transcript 更新 TUI。暂存期间提交新提示词或执行既有本地 snapshot accept/reject/rollback mutation,会先进入可恢复的 committing 阶段,永久删除隐藏后缀再建立新分支;同一阶段的 snapshot record 被拒绝,避免追加到隐藏历史。崩溃恢复会按持久化阶段重放 workspace/context 对齐或完成提交;跨文件系统与 Session 存储不宣称原子事务,部分失败返回 `outcome_unknown` 并要求恢复 Session 后检查。该能力当前只支持本地工作区,Remote 在任何回退写入前返回 `NotAvailable`。 - -部署差异由 CLI Runtime client 封装。Embedded 以 Rust 类型直接调用 `AgentRuntime`,不初始化 IPC 或执行 JSON 编解码;Shared 将同一业务请求映射为一个有界本机 frame,Client/Server 各自只编码一次,再交给同一 Runtime owner。多 TUI 复用一个 Runtime 进程,连接和队列保持有界,不按 TUI 数量复制 Session owner。详细的 4+1 视图、帧上限和并发边界见 -[`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md)。 - -#### 管理与诊断 - -CLI-P1 应统一以下命令的文本和结构化只读视图: - -- Agent、模型、MCP、Skill、Subagent、Session、Plugin、用量和运行时健康状态。 -- Provider/认证来源的可用性、失效原因和登录/退出入口;密钥值只进入受控凭据提供方,不进入普通配置。 -- 配置来源、被覆盖项、策略拒绝、未支持能力和降级原因。 -- 外部 ACP 智能体与 OpenCode-compatible 插件必须作为两个独立能力展示。 -- CLI-P1 的通用配置入口只允许显式应用已支持的非执行型候选;规则引用、Skill、MCP 启用和插件包仍按各自生命周期 - 处理。Claude Code/Codex 命令 Hook C0 是独立的精确审阅快照切片,不由通用配置入口取得执行资格。 - -### 3.3 CLI-P2:扩展、定制与差异化 Agent 能力 - -CLI-P2 是 CLI-P0/CLI-P1 稳定后的规划集合,不是一个 PR 或统一退出阶段。以下路线分别立项、验收和发布: - -| 路线 | 用户价值 | 不并入该路线 | -|---|---|---| -| 插件执行 | 用户可以在 CLI/TUI 会话中调用当前有效策略允许的插件工具,并看到来源、权限结果、执行结果和失败原因 | 安装分发、可写钩子、界面贡献和多生态运行时 | -| 本地插件安装 | 用户可以从明确选择的本地来源安装或卸载插件;失败不改变已有插件和激活状态 | 插件执行器、自动更新、在线仓库、组织策略和产品内置扩展生命周期 | -| TUI 扩展 | 用户可以使用宿主接受的插件命令、状态、通知和主题语义角色,并能识别冲突或终端能力降级 | GUI 路由、组件、主题键和可执行界面代码 | -| 产品定制 | 用户获得与产品身份一致的 CLI 品牌、能力和内置扩展,并能看到缺失或隔离导致的降级原因 | 通用产品包格式、签名和更新实现 | -| Agent 能力 | 用户可以恢复复杂任务、理解上下文来源并获得可复核的多智能体结果 | 通过插件或 TUI 专用分支替代共享运行时能力 | - -OpenCode、Codex 和 Claude Code 的配置资产覆盖可以继续扩展,但不改变 CLI-P1 已固定的资产分类、写入边界和各生态的执行能力状态。 - -CLI-P2 的 OpenCode 路线以尽可能兼容现有本地/软件包插件、Bun/JS 行为和稳定 Hook 为目标;无法等价的原始 -TUI renderer、实验性接口和完整外部 Server 协议按总矩阵明确降级。CLI-P2 不发布 Codex/Claude 插件执行 ABI。 - -## 4. 目标架构 - -### 4.1 分层与归属 - -| 层/模块 | 负责 | 不负责 | -|---|---|---| -| `src/apps/cli` | Clap 入口、TUI 状态/渲染、终端事件、入口本地设置、命令展示与结构化输出 | 会话状态机、工具执行、权限裁决、插件内部 ABI、品牌能力真值 | -| CLI Runtime client | 屏蔽 Embedded/Shared 部署差异,将 CLI 的类型化调用映射到进程内 Runtime 或私有本机 IPC | 实现 Session 业务规则、暴露公开 SDK 或在两种部署中复制行为 | -| `adapters/agent-runtime-ipc` | Shared TUI 的私有本机 transport、严格握手、frame 上限、连接控制和封闭 operation 映射 | 服务 Embedded、公开协议、Remote transport 或 Runtime 业务 owner | -| `assembly/product-capabilities` | Delivery Profile、Product Capability 计划、静态 eligibility、服务需求和组装计划 | 品牌资源读取、动态可用性、用户配置、UI 状态、具体服务创建 | -| 产品构建期校验 | 校验产品定义、品牌资源、TUI 布局选择和内置扩展版本,输出产品组装结果 | 创建运行时服务、实现终端行为或保存用户配置 | -| Product Assembly | 读取产品组装结果中本次 CLI 需要的字段,选择能力/服务/扩展,构建 Runtime Parts | 读取原始品牌资源、实现 Agent/Tool/插件适配器/终端行为或运行构建脚本 | -| Runtime Configuration Service | 规范配置层级、来源解释、导入预览/应用、原子写入、回滚和来源记录 | 解析外部生态格式、决定权限或读取凭据值 | -| 外部来源目录与激活策略 | 聚合用户/项目来源、资产清单、加载偏好和可读状态;结合各 owner 给出自动应用、需确认或限制结果 | 解释生态格式、写配置、管理 worker、保存凭据或代替调用时权限判断 | -| `agent-runtime` | Session/Turn/Task、调度、取消、上下文、事件、checkpoint fact、Subagent 和用量事实 | CLI 命令、TUI 状态、品牌、外部配置格式 | -| Tool/Harness/Runtime Services | 工具 ABI、工作流、类型化服务和平台端口 | 产品命令、入口默认策略、外部生态权威状态 | -| `PluginRuntimeClient` | 当前校验请求和响应,并管理期限、同一插件串行调用、重复请求结果和故障诊断;目标再增加队列上限、取消后的结果失效,并拒绝旧连接结果 | 持有 OS 进程树、拥有插件生命周期、直接写权限/审计/工具结果、解释 TUI 或品牌资源 | -| `ScriptToolRuntime` / services 实现 + Plugin Host | 前者持有进程树、资源和物理健康,后者在子进程加载/执行 JS/TS 插件 | 把主应用内实现与 Plugin Host 混为一谈,或决定工具权限、业务结果、TUI 和品牌资源 | -| 生态配置适配器 | 解析受支持外部格式并生成导入候选/诊断 | 直接写运行时配置、读取密钥、决定最终权限 | - -```mermaid -flowchart LR - Exec["bitfun exec"] --> Choice{"Session"} - Choice -->|"new / free"| Embedded["Embedded"] - Choice -->|"already owned"| Reject["typed occupied error"] - - TUI["bitfun chat"] --> Deploy{"deployment"} - Deploy -->|"default"| EmbeddedTui["Embedded"] - Deploy -->|"--shared"| SharedTui["private local IPC"] - SharedTui --> Runtime["one Shared Runtime owner"] -``` +# BitFun CLI 产品线架构 -Embedded 只意味着 Runtime 与 CLI 同进程,不意味着绕过持久化单写规则。新 Session 取得自己的写入权;恢复既有 Session 时, -CLI 必须先取得该 Session 的写入权。如果 Shared Agent Runtime 或另一个 `exec` 已持有,Headless CLI 返回明确的 -“Session 已占用”;它不会自动切换部署。只有用户显式选择 `--shared` 的交互式 TUI 才连接 Shared Runtime,且同一 Session 同时只有一个 controller。 - -CLI/TUI 的会话创建、列出、删除、恢复和历史转录读取通过 Rust Runtime SDK 的类型化端口完成;TUI 只把 -`SessionTranscript` 转换为本地渲染状态,不再消费 Core `Message`。Peer Host 的对话提交、精确取消、基础会话控制、thread-goal 查询、会话模型更新和 -工具确认/拒绝通过 Agent Runtime API 回到 Core owner;本地会话分支通过显式本地范围的内部端口完成,携带远程身份的请求返回类型化 -`NotAvailable`,本轮不扩展远程分支。TUI 用量卡片通过固定语义的完成态本地命令轮次端口持久化,不暴露通用 transcript writer。 -本地工作区快照准备、会话文件清单、类型化统计和工作区文件回滚通过 `runtime-ports` 中不属于公开 SDK 的窄 owner port 完成, -由 Desktop 和 Peer Host 分别转换为现有协议;Desktop 保留既有远程空结果,Peer Host 返回明确不支持错误,远程请求都不进入本地实现。快照记录、持久化、事件、历史截断与维护编排仍在原归属模块。 -账户同步、富历史及其他未覆盖操作继续使用经过审查的 Core compatibility 方法,直到各自具备明确 owner、稳定 DTO、远程语义和行为等价测试。 -这是一条垂直链路迁移,不是删除整个兼容接口或新建 CLI 专用服务层。 - -交互式命令 `/rename ` 复用已有 Session rename owner。Runtime 只写名称相关 metadata,再发布内存名称;写入失败时先恢复旧 metadata,无法确认恢复结果则返回 `outcome_unknown`。Shared 请求写入后的超时或断连也返回 `outcome_unknown`。两种情况都要求恢复 Session 后检查,不自动重试可能已经生效的写操作;发送前编码失败或请求过大则明确未执行并保留连接。Session 选择器不保留第二套内联重命名状态。 - -Runtime Configuration Service 当前由 `bitfun-core/service/config` 负责。在经评审的 port/provider -迁移完成前,CLI 和生态适配器不得另建写入器;adapter 只做 discover/parse/normalize,配置服务才能 -预览/应用、记录来源,并通过远程工作区 provider 写目标层。产品定义、品牌资源、界面布局选择 -和内容摘要校验由构建期校验器按 -[`product-customization-blueprint.md`](product-customization-blueprint.md) 负责;Product Assembly 只消费 -已经解析和校验的入口字段,不另设含糊的 Product Bootstrap 服务。 - -### 4.2 产品定义、布局、运行时配置与可用状态必须分离 - -| 对象 | 生命周期 | 权威归属 | 示例 | -|---|---|---|---| -| 产品定义 | 构建/产品组装期,通常不可变 | 构建期校验器 | 品牌身份、能力上限、默认策略引用、内置扩展和发行事实 | -| TUI 布局选择 | 构建/产品组装期,通常不可变 | CLI/TUI 宿主与构建期校验器 | 已注册 layout、panel、command、status、keymap 和 theme ID | -| Delivery Profile | 组装期稳定枚举 | Product Capability | CLI、Desktop、ACP、SDK | -| Runtime Configuration | 用户/项目/会话期可变 | 配置服务 | 模型选择、MCP、主题、快捷键、入口行为 | -| Capability Availability | 启动和运行期派生 | 单一能力可用性读模型 | available、status-only、unsupported、policy-denied | - -产品定义不能替代用户配置;TUI 布局选择只决定入口结构和可见内容;用户配置和运行时插件都不能启用 -未被产品定义允许的能力。隐藏一个 TUI 入口也不能视为能力已禁用。 - -本文的产品定义描述 BitFun 发行产品;它与 SDLC Harness 用于描述目标仓库事实和质量策略的 -[`Project Profile`](../sdlc-harness/architecture/project-profile-integration.md) 是两个独立概念,不能共享 -schema、存储或优先级。 - -### 4.3 产品启动流 - -```mermaid -flowchart LR - Result["产品组装结果"] --> Assembly["CLI 产品组装根"] - Entry["CLI 入口选择 DeliveryProfile::Cli"] --> Assembly - Assembly --> Plan["静态 Capability Plan"] - Providers["类型化服务/适配器提供方"] --> Assembly - Bundled["锁定的产品内置扩展"] --> Assembly - Assembly --> Runtime["Agent Runtime Parts"] - Plan --> Availability["版本化 Capability Availability 读模型"] - Providers --> Availability - Policy["运行时策略"] --> Availability - Runtime --> View["CLI 能力服务与只读视图"] - Availability --> View - View --> TUI["交互式 TUI"] - View --> Exec["非交互 exec"] - View --> Admin["管理/诊断命令"] -``` +本文记录 CLI 产品入口的稳定边界、当前运行路径和剩余架构缺口。它不是用户手册,也不记录单个 PR 的完成日志。 -启动规则: +相关专题由各自文档负责: -- 产品组装结果、TUI 布局引用、能力依赖或资源校验失败时构建/启动失败,不静默退回 full 产品。 -- Product Assembly 在构建 Runtime Parts 前必须验证组装结果绑定的 TUI 布局摘要、 - `DeliveryProfile::Cli`、Surface ID、schema 和宿主版本;不匹配时失败,不能接受来源不明的替换内容。 -- 可选服务不可用时进入 Capability Availability;必需服务缺失时组装失败。 -- CLI 不直接创建新的全局 manager;现有兼容路径按等价测试逐步迁移。 -- 静态 Plan 只记录是否可用、依赖和服务要求;动态健康、策略和故障暂停状态不固化进 Plan。 -- TUI、Exec 和管理命令消费同一带版本的 Capability Availability 读模型,不能分别维护可用性判断。 +- 仓库分层与产品边界:[`product-architecture.md`](product-architecture.md) +- Agent Runtime 服务归属:[`agent-runtime-services-design.md`](agent-runtime-services-design.md) +- Embedded / Shared 部署:[`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md) +- 公开 Agent SDK:[`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md) +- 产品定制:[`product-customization-blueprint.md`](product-customization-blueprint.md) +- 外部 AI 工作来源:[`extensions/external-ai-work-sources-design.md`](extensions/external-ai-work-sources-design.md) +- OpenCode 兼容矩阵:[`extensions/opencode-extension-compatibility.md`](extensions/opencode-extension-compatibility.md) +- 插件 Runtime:[`extensions/plugin-runtime-design.md`](extensions/plugin-runtime-design.md) +- Detached Dispatch:[`detached-task-dispatch.md`](detached-task-dispatch.md) +- 平台可移植性:[`platform-portability-design.md`](platform-portability-design.md) -### 4.4 TUI 内部边界 +设计文档中的目标能力不等于已交付能力。判断现状必须同时检查生产调用点、测试和当前 CLI 帮助。 -现有 TUI 采用增量拆分,不另建平行框架: +## 1. 产品范围 -| 边界 | 职责 | -|---|---| -| Terminal Session | raw mode、alternate screen、鼠标/粘贴、panic/取消后的恢复 | -| Input/Event | 键盘、鼠标、resize、paste、runtime event 的标准化输入 | -| State/Reducer | 纯状态转移;不直接执行文件、网络、配置或 Agent 操作 | -| Effect/Controller | 把状态意图映射为能力服务请求,并把结果重新投递为事件 | -| View/Widget | 根据状态渲染;不读取具体 manager 或写配置 | -| Action Registry | 统一 action id、slash/palette/help、上下文、可用性、处理器和默认键位;不持有业务状态 | -| Keymap Resolver | 根据当前模式和焦点把用户显式配置或默认键位解析为 action id;不直接执行业务副作用 | +BitFun CLI 是独立的 Agent 产品入口,覆盖: -`modes/chat.rs` 当前主要保留 `ChatMode` 外壳、共享上下文和私有子文件组织;生命周期、输入/命令、选择器、MCP、 -会话与能力副作用按职责保留在同一 Rust 模块的 `modes/chat/` 子文件中。现有 `ui/chat/state.rs`、`input.rs`、 -`render.rs` 等模块继续作为拆分基础。该拆分不形成公共 TUI 框架,也不改变交互规格;后续仍以可测试边界为目的, -不以文件数量为目标。 +- 交互式 TUI +- 非交互 `exec` 与结构化输出 +- Session、模型、Agent、MCP、Skill、Subagent 和诊断入口 +- ACP 服务端、Peer Device Host、Detached Dispatch 和 Shared TUI 适配 +- 产品组装结果在终端形态下的消费 -### 4.5 Action 与快捷键 +CLI 不拥有 Session、Turn、Tool、Permission、Context、Workspace、MCP 或 Subagent 的产品逻辑。它也不通过 Fork 竞品 Runtime 来实现兼容。 -Slash 自动完成、命令面板、帮助、快捷键展示和执行分发必须读取同一 action 条目。Clap 子命令、flags、stdout 和 -exit code 保持独立强类型协议,但可以调用相同 controller。Registry 只描述宿主动作,不复制 Session、Tool 或 -Plugin Runtime 状态。 +竞品对齐只用于降低用户学习成本和补齐常用工程流程: -默认键位以当前真实 dispatch 为兼容基线;serde 补出的默认值不等于用户选择。只迁移配置文件中显式保存的旧值。 -冲突必须稳定并显示来源;退出、终端恢复和活动 turn 中断始终保留宿主 fallback。最低测试覆盖无配置、显式旧配置、 -冲突配置和真实输入 dispatch。 +1. 等价入口优先采用 OpenCode 的命令名与交互。 +2. Codex、Claude Code 和 OpenCode 的配置或扩展格式是外部来源,不是 BitFun 内部模型。 +3. 只有存在真实消费方、安全边界和兼容测试时才增加生态专属能力。 +4. 不为“接口完整”发布没有运行路径的占位 API。 -### 4.6 HarmonyOS PC 原生终端产品 +## 2. 分层与所有权 -HarmonyOS PC 复用现有 `DeliveryProfile::Cli`、action、TUI 和 Runtime 语义;平台 target 不成为新的 Delivery -Profile。目标产物是普通用户在系统真实终端中直接执行的本地 `bitfun`,不是 HAP、ArkUI/ArkWeb 终端模拟器、 -`hdc shell` 工具或现有 HarmonyOS 手机 Remote App。 +```text +CLI surface + Clap / terminal lifecycle / TUI state / rendering / local effects + | + v +CLI adapters + CliAgentRuntimeClient / Shared IPC / output projection / Peer host adapter + | + v +Agent Runtime SDK and typed owner ports + | + v +Core owners + Session / execution / ToolPipeline / permissions / persistence / workspace + | + v +Platform services + terminal / filesystem / git / network / remote execution +``` -问题清单、风险和旧设计完整流程统一见[平台规约](platform-portability-design.md)。具体鸿蒙化工作、OpenCode 平台资格、 -HarmonyOS PC GUI 与移动端均另立专题。 +边界规则: -## 5. CLI/TUI 对产品定制结果的消费 +- CLI 可以决定“如何进入、如何展示、当前按键做什么”,不能重新决定产品事实。 +- Adapter 负责协议与形态转换,不重新计算 owner 已经给出的状态。 +- DTO 或 trait 提取只是依赖边界,不代表 Runtime owner 已迁移。 +- 本地 UI effect 可以留在 CLI;会改变 Session、工作区或外部系统的操作必须进入共享 owner。 +- Remote 不支持的本地能力必须返回明确 unsupported,不能静默在控制端本机执行。 -产品定义、品牌资源、TUI 布局选择、产品组装结果和内置扩展的通用边界由 -[`product-customization-blueprint.md`](product-customization-blueprint.md) 定义。本节只约束 CLI/TUI 消费。 +## 3. 部署形态 -当前 C0a 只消费已校验解析结果中的 localized 产品名和 binary name,并由 `cli:dev`、`cli:build` -的同一 wrapper 通过显式 `--product-config` 选择非默认定义。内部 Cargo target 仍为 `bitfun`;build 产物按解析后的 -名称暂存。安装、更新、用户数据隔离、完整运行时品牌替换以及下表中的布局、命令组、状态、键位或主题选择均未实现。 +### 3.1 Embedded -目标 CLI 入口只接收已校验的产品组装结果和当前 Delivery Profile 对应的 TUI 布局字段,不读取原始品牌资源, -也不运行构建脚本。首期 TUI 布局只允许引用宿主已注册的稳定 ID: +默认 TUI、`exec` 和一次性管理入口在当前进程组装 Runtime。一次 invocation 只构造一份产品 Runtime context,TUI、事件订阅和同一进程内的 Peer Host 复用它。 -| 定制面 | CLI/TUI 消费 | 宿主保留决定权 | -|---|---|---| -| 品牌 | text/compact Logo、产品名、帮助/法律资源 | Unicode/纯文本回退、宽度裁剪和终端恢复 | -| Layout | layout preset、panel region、默认 mode | resize、窄终端折叠和焦点 | -| Commands | capability-backed command group、顺序和帮助分组 | dispatcher 映射、权限和冲突处理 | -| Status | 已注册 status/notice/只读视图 | 事件归一化、刷新和降级 | -| Keymap | 已注册 preset 与可覆盖范围 | 冲突、平台按键和用户允许覆盖 | -| Theme | TUI preset/语义主题 ID | ANSI/truecolor/monochrome 适配 | +### 3.2 Shared TUI -TUI 布局选择不携带 renderer、终端句柄、shell helper、GUI key、任意脚本或运行时插件状态。Runtime -Configuration 只能覆盖产品定义明确允许的默认值;用户插件只能向允许的 TUI 扩展点提交贡献,不能改写 -构建期布局、产品身份、产品能力上限或内置扩展版本。 +`bitfun chat --shared` 通过本机版本化 IPC 连接工作区 Runtime: -产品内置扩展来自只读产品 source root,随产品升级。BitFun 原生包继续使用现有来源确认、激活、更新、禁用和 -卸载路径;OpenCode 配置和标准目录来源自动发现,低风险内容按用户偏好自动应用或先询问,可执行来源首次启用或 -能力扩大时非阻塞确认。确认后的运行语义兼容优先,用户、产品或组织策略仍可限制。三者可以复用插件内部 ABI、Plugin Host 进程边界 -和经 BitFun 能力接口的权限/审计,但不能共享来源根、安装状态或用一种泛化信任记录互相授权。 +- 多个 TUI 可以复用一个工作区 Runtime。 +- 一个 TUI 同时控制至多一个 Session;一个 Session 同时只有一个 controller。 +- 有副作用的请求携带稳定 identity,并声明 controller、idle、序列化和 side-effect 规则。 +- 超时或断线后无法证明是否已提交的请求返回 `OutcomeUnknown`,关闭连接并按已知 turn id 取消;客户端不能盲目重试。 +- IPC 只暴露经过评审的闭集操作,不演变成通用 Tool 或 Core RPC。 +- 不支持的附件或本地 effect 在 IPC 前失败。 -## 6. Canonical Config、持续兼容来源与显式导入 +Automation、Desktop、Server、Relay 和公开 SDK 不因 Shared TUI 自动改用同一协议。 -### 6.1 BitFun 配置层级 +## 4. 关键运行路径 -普通设置按以下优先级解析: +### 4.1 普通对话 ```text -命令行/本次运行参数 - > 工作区本地设置 - > 项目设置 - > 用户设置 - > 产品定义声明为可覆盖的运行时默认值 +ComposerDraft + -> CliAgentRuntimeClient + -> AgentRuntime::submit_dialog_turn + -> Core Session / execution owner + -> Agent events + -> CLI ChatState and rendering ``` -项目设置是可共享的仓库事实;工作区本地设置是机器/工作区实例私有且不随仓库同步的覆盖。二者不能 -只靠路径巧合区分,远程工作区必须由 provider 显式给出使用范围和写入能力。 - -组织策略不是普通覆盖层。最终能力和权限是“用户请求与组织上限的交集”,低层配置不能放宽 -组织策略、隔离要求、数据范围或扩展来源限制。 +`ComposerDraft` 统一保存文本、结构化工作区引用和图片。附件只在提交时转换为 Runtime DTO;Shared TUI 当前不支持的图片在序列化前拒绝。 -每个有效值必须可解释:值、来源层、来源文件/策略标识、是否被覆盖、是否被策略限制。配置解析失败时 -只可在同一使用范围和同一版有效策略下保留上一个有效结果并产生诊断;没有有效结果时失败。安全相关配置 -不得回退到更宽松的旧结果或默认值。 +### 4.2 显式 Shell 输入 -### 6.2 来源发现与导入流程 - -外部资产有“兼容来源”和“显式导入”两种消费方式。兼容来源不改写已有 OpenCode 文件;OC-R1 只有不启动 -外部进程、不 import 第三方 module、不读取凭据且不主动联网的 L1 字段可以按用户偏好自动应用或先询问。 -Plugin/Tool、可执行 Skill/Command、MCP/LSP/Formatter、远程 Reference 等 L2/L3 内容在 OC-R2 完成归属模块保护 -前只发现和展示;完成后仍须在首次启用或能力扩大时确认。它们无需先迁移; -显式导入用于用户希望取得 BitFun 独立管理快照的场景。当前只落地两个经过评审的窄切片:Desktop 与 -`bitfun mcp import` 可以预览 OpenCode、Claude Code 与 Codex 中语义等价的 MCP 安全声明,只有显式 `--apply` 才原子写入现有 -BitFun MCP 配置;`bitfun hooks` 和统一 `/hooks` 可预览 Claude Code / Codex 中受支持的同步 command Hook,并用精确 -计划指纹确认后复制到现有原生 Hook 层。两者都不写回来源文件,也不表示通用 Canonical Config 导入已进入 CLI-P1。 -MCP 的凭据、header、env、cwd、通用导入记录和 undo 均未实现;Hook 的 OpenCode、非 command 或依赖 -外部 Runtime 的 handler 仍只静态展示。其他资产在 CLI-P0 仍截止到 Dry-run,只有各自经过评审的 apply 切片才能写入: +空 composer 键入首个 `!` 进入 SHELL,这是 OpenCode 的既有入口,不增加 `/shell`: ```text -持续兼容:后台发现 -> 解析 -> 风险分级 -> L1 自动应用/先询问 | L2/L3 待确认 -> 同一次状态提交切换 -MCP C0a:发现 -> 安全投影 -> 预览 | 显式 apply -> 原子写入 disabled 原生条目 -> 既有 MCP 管理 -Hook C0:脱敏发现 -> 精确命令预览 | 指纹确认 -> 原子发布本地快照 -> 既有 AgentHookEngine -其他显式导入:选择来源 -> 归一化 -> 冲突分析 -> Dry-run | 后续评审切片:用户选择 -> 原子写入 BitFun 层 +SHELL composer + -> CliAgentRuntimeClient::run_user_shell_command + -> AgentUserShellCommandPort + -> Core coordinator + -> ToolPipeline(ExecCommand) + -> TerminalPort or RemoteExecPort + -> UserDialog + ModelRound + ToolResult ``` -交互式 CLI/TUI 以一条非阻塞摘要说明来源产品、全局/项目使用范围、资产数量、自动应用项和待确认项;详细内容进入 -统一来源与插件状态入口。MCP 快照入口固定为 `bitfun mcp import`,Hook 快照入口固定为 `bitfun hooks`;其他资产的命令名在有真实调用方时再固定。非交互命令只有在当前操作实际依赖待确认资产时才 -返回类型化 `action-required`;无关待办只进入结构化状态或 `stderr` 摘要,不等待不可见输入,也不自动批准。 -当前只能静态预览的 custom tool 名称只显示“已发现,未执行”。 - -Codex MCP 快照沿用同一 `bitfun mcp import` 心智:local stdio 只在没有 environment 且无需 effective working directory -时接纳;显式 cwd 或当前 workspace 形成的隐式 cwd 都返回“需要设置”,避免导入后改用 BitFun 进程目录。remote 只接纳无 -userinfo/query/fragment/header/bearer 和额外 OAuth 语义的纯 HTTPS URL;其他字段返回“需要设置”或“不支持”,不做降级 -复制。导入结果仍为 disabled、`autoStart: false`。Desktop 在已有导入卡内默认选中全部 eligible 项并允许逐项取消; -plan stale 后只保留旧选择与新 eligible 项的交集,新候选不自动选中。CLI 保持默认全量和重复 `--candidate` 缩小集合, -仅在展示名重复时附带既有 candidate ID 消歧,不新增生态专用命令。 - -导入预览只使用四种用户可读结论:可直接使用、需要转换、会发生功能降级、输入无效。每项同时说明是原地 -引用、写入 BitFun 配置、继续保持外部来源还是不支持;不得用“已映射”推导为已写入、已信任或已启用。 - -兼容来源不写入 BitFun 层,也不双向修改原文件。Hook C0 只保存用户/工作区范围的私有不可变快照,并提供启停、更新和 -删除,不实现字段级撤销。以下分层导入记录与撤销语义是后续通用目标,不是 MCP C0a 或 Hook C0 已实现能力:项目级来源默认写入 BitFun 项目层,用户级来源默认写入用户层;用户可以在确认时选择更窄的目标层, -但不能写入组织强制策略。导入记录保留来源产品、 -来源范围、内容摘要和导入时间,并按字段保存目标层、导入前值及其版本/摘要和导入值。已导入字段以 BitFun 原生 -配置为准,不再重复应用外部值;外部来源变化时提示重新导入并展示差异,不做双向写回。撤销只自动恢复当前值 -仍等于导入值的字段;用户后续修改、来源变化或部分重新导入造成冲突时,逐字段选择“保留 BitFun / 重新导入 -外部 / 手工处理”,不得整批覆盖。 - -下表描述目标覆盖范围;显式配置导入能力仍仅限上文列出的 MCP C0a 与 Hook C0。Skill 的原地发现与调用使用下文所述 -的既有 Skill Registry 路径,不属于显式配置导入,也不能由本表推导出其他资产已经实现。 - -| 来源 | 目标可导入 | 目标不导入 | -|---|---|---| -| OpenCode | 规则/instructions、Agent、Mode、Skill、References、Command、MCP、LSP、Formatter、模型、Theme、Keybind 和稳定配置进入兼容来源图;非执行资产可显式导入 | 凭据值双向复制、把 OpenCode 原始类型变成 BitFun 内部类型;Plugin/Tool 经来源确认后由独立 Runtime 加载,不通过配置导入执行 | -| Codex | `AGENTS.md` 原地引用;受支持的 MCP、稳定配置和 Skill 可选择原地引用或导入;同步 command Hook 可经精确审阅复制为 BitFun 原生层 | `auth.json` 等凭据、私有/未文档化字段、Codex App Server 状态、依赖未观察会话/信任语义的 Hook | -| Claude Code | `CLAUDE.md` 原地引用;受支持的 MCP、稳定设置和 Skill 可选择原地引用或导入;同步 command Hook 可经精确审阅复制为 BitFun 原生层 | OAuth/Token、插件执行、非 command 或异步 Hook、managed Hook 例外、组织强制策略降级 | - -规则文件优先复用项目已有文件,不复制出第二份内容。若不同生态规则冲突,导入报告必须展示目标文件、 -优先级和冲突段,不能自动拼接。 - -本地 Workspace Instructions 先读取用户级生态来源,再追加项目来源。用户级固定顺序是 OpenCode、Codex、Claude Code: -OpenCode 读取 `$XDG_CONFIG_HOME/opencode/AGENTS.md`(默认 `~/.config/opencode/AGENTS.md`),不存在时回退 -`~/.claude/CLAUDE.md`;随后按 `config.json`、`opencode.json`、`opencode.jsonc` 的原生覆盖顺序读取最终 -`instructions` 数组,支持 workspace 相对、`~/`、绝对本地精确文件和有界 glob,不获取 HTTP/HTTPS URL。Codex 在 -`$CODEX_HOME`(默认 `~/.codex`)中读取首个非空的 `AGENTS.override.md` 或 `AGENTS.md`;空 override 继续回退到基础文件。Claude Code -从 `$CLAUDE_CONFIG_DIR`(默认 `~/.claude`)读取 `CLAUDE.md`、不带 `paths` front matter 的 `rules/**/*.md`,并只在 -该用户配置根内跟随深度最多 5 的 `@import`。跨生态按 canonical path 保留首个来源,用户来源合计限制为 256 个文件、 -单文件 1 MiB、总内容 2 MiB;进入提示词的名称只显示 `~`、环境变量根、`` 或 ``。环境变量 -指定的用户根必须是绝对路径,无法确定用户根时跳过该生态而不回退进程 cwd。Codex 只读取用户根的 Instruction 文件, -不扫描 `$CODEX_HOME/plugins/cache` 等产品插件缓存。用户与项目来源合成后再次执行一个共享的 256 文件、2 MiB 最终 -渲染预算;按既定顺序保留完整文件前缀,不截断单个 Instruction 文档。 - -项目来源仍由 Workspace Instructions owner 通过本地文件系统或 `WorkspaceFileSystem` 端口统一解析。固定顺序是: -`AGENTS.override.md`(存在时替代 `AGENTS.md`,空文件也不回退)、根 `CLAUDE.md` 或 `.claude/CLAUDE.md`、 -`CLAUDE.local.md`、不带 `paths` front matter 的 `.claude/rules/**/*.md`,最后是项目根与 `.opencode` 中 -`opencode.json/jsonc` 的本地 `instructions` 文件或 glob。Claude `@import` 只跟随工作区内文件,深度上限为 5,并对 -重复和循环引用去重;所有目录遍历都跳过符号链接。运行时尚无稳定的嵌套活动目录事实,因此不声明 root-to-cwd 级联。 -递归扫描跳过 VCS、依赖与构建目录,并对扫描节点、文件数量、单文件和总内容字节设置固定上限,避免宽 glob 阻塞本地 -或 Remote 工作区。Remote 只使用端口可见的项目来源,绝不回退到控制端的本机用户目录。单个用户生态读取失败只隔离 -该生态,项目来源和其他用户生态仍可用;I/O 或解析失败时这次 user context 构建不写缓存,下一条消息会重试。确定性 -遍历预算超限则跳过对应 OpenCode glob 或 Claude rules 集合、保留已读取的基础文件并允许缓存,避免每条消息重复同一宽扫描。 - -Claude path-scoped rules、OpenCode 远程 URL、managed/organization policy、变化监听、冲突报告和 Plugin Runtime 不属于 -当前实现。 - -现有对 `.claude/.codex/.opencode/.agents` Skill 根的直接发现已经保留来源身份和全局/项目使用范围,并在 GUI/TUI -展示来源和默认覆盖状态,模式配置再展示实际采用项;固定根顺序保持为 Skill Registry 的独立回归契约。Registry 仅按 -既有 source slot 在内部选择来源方言,不向用户增加主选择器,也不让本地与 Remote 分支各自猜测路径。 -Skill Registry 还保留来源资产声明的隐式调用意图:Claude `SKILL.md` 的 `disable-model-invocation: true` 与 Codex -`agents/openai.yaml` 的 `policy.allow_implicit_invocation: false` 都会让 Skill 不进入模型自动目录,但不影响 `/skills`、 -模式配置和显式加载。Claude `user-invocable: false` 与上述模型调用策略相互独立:它只让 Skill 不进入 Web/CLI 的用户 -调用选择器,不从管理目录删除,也不改变模型目录或现有启停状态。缺省时 Skill 可由用户调用;`argument-hint` 只作为 -选择器提示显示,不自动写入输入框。Web 与 CLI/TUI 选择 Skill 后统一生成 `[$skill-name]` 引用,用户可以直接在后面继续 -输入参数,不需要先导入、复制或学习第二种启用流程。 - -显式调用仍由现有 `SkillTool` 和 Skill Registry 加载实际优先级赢家,本地与 Remote 分支沿用同一加载语义。工具的可选 -`arguments` 字段使用共享的静态模板展开:支持原始 `$ARGUMENTS`、从零开始的 `$ARGUMENTS[N]` 和 `$N`、单/双引号 -分组以及 `\$` 转义;缺失的位置参数保留原占位符,模板没有未转义占位符时才追加 `ARGUMENTS:` 段。该展开器只处理 -字符串,不执行命令、脚本或动态变量。未携带 `arguments` 的旧工具调用保持原 Skill 正文不变。 - -Claude Skill 使用目录名作为稳定调用身份;frontmatter `description` 可缺省并回退正文首个非空段落,可选 -`when_to_use` 只能与已有描述合并,合并后限制为 1536 个 Unicode 字符。`arguments` 可声明为空白分隔字符串或字符串列表,并按顺序把 -`$name` 绑定到同一个调用参数列表;缺失命名参数展开为空,位置参数兼容规则不变。Codex Skill 在缺少 `name` 时回退 -目录名,但仍要求 `description`。`.agents/.opencode/.bitfun/.cursor` 继续使用原有严格语义。Claude `allowed-tools` 不授予 -预批准;`context`/`fork`、`agent`、`model`、`effort`、`hooks`、`paths`、`shell`、`runtime` 及动态 shell/runtime 变量等未接通行为会 -整体拒绝加载,而不是静默忽略后部分执行。 - -这项能力不新增导入记录、来源图、后台 watcher 或第二套刷新生命周期。用户只需要一个手动入口:`/reload` 同时刷新 -Skill Registry 并失效当前 Session 的 Workspace Instructions 缓存;`/reload skills` 与 `/reload instructions` 用于只刷新 -一类内容。Desktop、Embedded CLI 与 Shared TUI 共用同一 core 协调入口,但 Skill Registry 刷新和 Session -`UserContext` 缓存失效仍由各自既有 owner 完成。指令变更从下一条消息开始生效;运行期不承诺文件监听或当前生成中的 -消息热替换。缓存 generation 会拒绝活动 Turn 在失效之后写回的旧构建结果;旧 `/reload-skills` 输入仅作为隐藏兼容别名 -映射到 `/reload skills`,不增加第二个命令入口。 - -外部 Prompt Command 的 shell 上下文只支持 OpenCode JSON/JSONC/Markdown Command 与 Claude legacy -`commands/**/*.md` 的 `!shell` 语义。生态 adapter 只解析来源、参数和 shell 偏好;Product Assembly 生成包含 -执行域、候选版本、工作目录、解析后的绝对 shell 路径与精确命令的审批指纹,并委托现有 Terminal owner 通过不加载 profile 的隔离式 argv 并发执行。GUI/TUI -只展示当前计划并提交“仅本次”或“记住静态计划”的决定,后端必须重新发现并校验完整指纹后才能执行;参数改变命令的 -动态计划不能记住。仅 stdout 按模板顺序进入最终 Prompt,stderr 只被排空;为保持 OpenCode Command 语义,进程正常启动并退出后即使退出码非零也使用 stdout。文件读取、进程启动、超时或超限失败时不发送部分 Prompt,已经发生的进程副作用不可回滚。安全模式和 Remote -工作区保持明确不支持,不回退到控制端本机;这条路径不依赖也不扩展 Plugin Host Runtime。 - -本切片也不实现 `allowed-tools` 的权限预批准、`context`、`fork`、`agent`、`model`、动态 shell/runtime 变量、URL、祖先目录 -级联、插件 Runtime 或 OpenCode 复杂 Hook。后续只有在存在稳定消费方和独立安全边界时才扩展这些语义。 - -Skill 说明和索引可按 L1 处理,脚本、URL 和外部依赖按 L2 确认;显式导入仍不得复制凭据值。MCP 启用状态按 -OpenCode 来源解释,首次连接、策略限制和凭据缺失分别显示。 - -### 6.3 凭据边界 - -- 凭据发现、凭据使用和配置导入是三条独立路径。 -- 默认只报告可用认证来源,不复制 token、OAuth refresh token 或 API key。 -- 只有外部产品明确稳定支持的授权方式才能成为产品级 provider;读取私有文件格式只能作为可关闭的 - 本地兼容能力,并提供失效诊断。 -- 日志、结构化事件、导入报告和诊断不得包含原始凭据或完整敏感路径。 - -## 7. 扩展与 OpenCode-compatible 能力 - -### 7.1 三条能力必须分开 - -| 能力 | 入口 | 说明 | -|---|---|---| -| 外部 ACP 智能体 | `bitfun acp ...` | 启动外部智能体进程并通过 ACP 协作。 | -| 配置兼容与导入 | 启动时持续兼容来源;`bitfun config import ...` 显式迁移 | 前者后台发现并按风险应用/确认 OpenCode 来源,后者写入 BitFun 原生配置;两者都不在解析线程执行插件。 | -| 运行时插件 | `bitfun plugins ...` | 当前只提供 BitFun 专用包的来源、启用和静态工具名称预览;目标直接运行 OpenCode 插件。 | - -三者不能共享“已安装/已启用”状态,也不能互相推导来源确认或运行权限。 - -### 7.2 插件阶段 - -| 阶段 | 目标交付 | 边界 | -|---|---|---| -| 发现 | 直接发现 OpenCode 用户/项目配置、插件目录、工具目录和软件包来源,形成使用范围与能力摘要;同时保留 BitFun 原生包 | 不要求 OpenCode 作者复制到 `.bitfun/plugins` 或维护 BitFun 清单;不把静态预览称为可用 | -| 确认 | 可执行来源首次按来源、插件身份和执行域确认;能力扩大重新确认 | 非阻塞待办,不阻止项目或无关会话;同一摘要下不逐层重复询问 | -| 准备 | 只为已允许的新版本异步解析依赖并记录当前执行版本;更新时停止旧 Host 后再启动新 Host 并初始化插件 | 不在 TUI 输入线程安装依赖或加载插件,确认前不 import module,也不让新旧插件代码并行运行 | -| 启用 | 展示来源、插件、真实贡献、策略差异和运行状态 | 用户一次确认可以完成后续内部阶段,但不绕过 import 前策略重算 | -| 执行 | 真实工具、稳定钩子、兼容 Client 和 TUI Plugin 经主机调用现有归属模块 | 插件不能直接写权限、工具结果、审计、会话或 Ratatui Frame | -| 管理 | 查看、停用、恢复、更新和卸载;区分更新失败、暂时过期、明确删除和重新出现 | 安装成功不等于运行健康,服务入口和 TUI 入口分别管理 | - -OpenCode 适配器必须读取真实外部来源;来源确认后才自动准备执行环境。通用配置的显式导入只用于把非执行配置迁移为 -BitFun 原生配置,不能成为运行插件的前置条件。与其分离的 Hook C0 只把用户明确审阅的 Claude Code/Codex 命令 Hook -复制为现有 `AgentHookEngine` 的原生层;它不进入插件执行阶段,也不扩展到 OpenCode Hook 或通用 Hook Runtime。 - -CLI 只有在后端已经从脚本进程取得真实定义和执行函数、注册到现有 Tool Runtime 且当前 worker 健康时,才显示 -“工具可用”。静态名称、准备中、制品不受支持、策略限制或执行进程不可用时,分别显示预览或具体原因,不能把 -来源已记录或 `plugins activate` 成功解释为工具可调用。 - -现有 `plugins activate` 仍只授权读取静态预览。首个执行能力交付前,命令回执和 `list/status` 必须直接说明 -“仅静态预览,插件代码未执行”。目标一级状态统一使用[外部 AI 工作内容设计](extensions/external-ai-work-sources-design.md#7-状态与提示规则) -定义的集合,并附带原因与恢复动作;准备完成、重启中、已暂停等 Host 阶段只作为详情,不形成另一套 CLI 状态。 - -工具进入可调用集合后,排队、权限确认、运行、成功、失败和取消继续使用现有 Tool Runtime;终端插件状态使用 -统一 TUI 状态/事件路径,不新增插件专用工具调用状态机。 - -### 7.3 CLI 扩展贡献 - -OpenCode TUI Plugin v1 的宿主操作按独立设计适配:Route/导航、Command/Slash Alias、Keymap/Layer/Binding/Mode、 -Dialog/Toast/Prompt、全部 host slots、Theme、Attention/系统通知/声音、State、KV、Client、Events 和插件启停均有 -目标映射,详见 [`opencode-tui-plugin-adapter-design.md`](extensions/opencode-tui-plugin-adapter-design.md)。 - -键位冲突、终端颜色映射、文本回退、焦点和命令排序由 CLI 宿主决定。插件不能持有终端句柄或直接写 -Ratatui Frame。依赖 OpenCode 原始 `CliRenderer`、Solid/OpenTUI 组件的 Route、Slot 和 Dialog 首期明确返回 -渲染器不兼容,其他命令、事件和状态继续工作;不得用少量声明式贡献冒充全部 TUI Plugin 兼容。 - -## 8. Agent 能力加强 - -CLI Agent 能力加强必须落在共享 Agent Runtime、Tool Runtime 或 Harness,而不是只在 TUI 增加分支。 - -### 8.1 会话、上下文与恢复 - -- Session、Dialog Turn、Model Round 和后台 Task 维持稳定身份与事件顺序。 -- 长程目标公开预算、进度、阻塞原因、continuation 和完成事实,TUI 只负责展示和操作入口。 -- compact 产生带来源和预算事实的新上下文,原 transcript 保持可审计。 -- 对话 checkpoint 与工作区 checkpoint 分离;workspace 恢复依赖 Git/文件系统提供方并报告未跟踪文件风险。 -- rewind 不承诺跨会话存储和文件系统原子提交。它先持久化 plan/基线和影响预览,再执行并验证 workspace - provider,最后提交对话指针;取消和重入使用同一 operation id。 -- 任一步失败先尝试补偿;无法补偿时返回 `partially-applied`,逐项报告 workspace、transcript、未跟踪文件 - 和可恢复动作,不得报告成功或留下无解释状态。 -- 指令、Skill、规则、用户上下文和工作区事实保留来源与优先级,便于解释上下文为何进入模型。 -- Prompt cache、压缩和模型切换不能改变权限、工具清单或隐藏的安全上下文。 - -### 8.2 执行可靠性 - -- Turn、Tool、Subagent 和 Harness Step 都支持取消,并产生类型化 outcome。 -- 工具调用使用唯一调用身份,以便识别取消、迟到响应和重复响应;重试必须区分可重试传输错误、模型错误、 - 权限拒绝和确定性工具错误,不默认重复有副作用的调用。 -- 后台任务必须有结果投递、显式 detached 状态或取消结果,不能仅依赖日志。 -- 不以字符串或次数硬编码阻止 Agent loop;先从工具语义、上下文、模型交互和状态同步定位根因。 -- 大型结果写入对象存储,并在后续轮次通过受控引用进入上下文。 - -### 8.3 多 Agent 与工作流 - -- Subagent 声明角色、输入、工具/能力边界、预算和期望输出;父 Agent 负责汇总结果。 -- 并行任务必须有并发上限、取消传播和写冲突策略;需要写同一工作区时优先隔离工作树或串行化。 -- 委派结果包含来源 Agent、完成状态、产物和未解决风险,不把子会话原始上下文全部注入父会话。 -- Deep Review、Debug、Research 等复杂流程进入 Harness Provider,不在 CLI 写专用运行时循环。 - -### 8.4 模型与评测 - -- 模型路由保持 provider-neutral;产品只选择策略和默认值,不在内核按品牌分支。 -- fallback 必须展示原因,并保留模型、用量、缓存和失败事实。 -- Agent 改进使用固定任务集和重复运行评估:完成率、错误恢复率、工具失败率、人工确认次数、Token/缓存、 - 执行时长和产物正确性。 -- 权限绕过、未授权副作用、敏感信息泄露和不可解释的部分应用属于零容忍保护项,不用平均成功率抵消。 -- 竞品对比必须记录版本、模型、权限、工作区、运行次数和失败分类;单次结果不能作为架构完成标准。 -- 共享 Harness/Evaluation 模块维护版本化评测清单;每份清单固定任务/工作区摘要、模型/provider、 - 工具与权限策略、成功判定、性能预算和对比基线。每个用例对候选与基线各重复至少 3 次,报告原始结果、 - 中位数和失败分布;更高统计要求由评测清单声明,不在 CLI 写专用评测循环。 - -## 9. 安全、错误与日志、指标和诊断 - -- TUI 无论正常退出、取消、panic 或初始化失败,都必须恢复终端状态。 -- stdout 只承载用户请求的结果/协议;日志和诊断默认写 stderr 或日志文件。 -- 文件、shell、网络、浏览器、桌面、远程和 MCP 等经 BitFun 能力接口发起的动作统一进入能力/副作用与权限路径。插件直接使用 Bun 文件、网络或进程接口的副作用不伪装成已逐项拦截;严格策略没有真实操作系统隔离时,应禁用相应插件并报告策略限制。 -- 非交互模式遇到需要人工确认的动作时默认失败并返回类型化诊断;只有显式策略才能自动批准。 -- 插件执行使用独立期限、有界队列、Plugin Host/当前 standalone worker 生命周期和崩溃恢复。兼容进程级事实 - 相同的 package plugins 默认共享 Host,不按 workspace 或 session 创建进程。来源完成首次确认后,默认本地兼容策略 - 提供 OpenCode 插件正常运行所需的当前用户环境;诊断和日志始终脱敏,用户或组织可显式限制环境变量、网络、 - 文件和进程能力。 -- 配置导入、诊断、事件和崩溃报告统一脱敏;绝对路径只在本地明确需要时显示。 -- Remote/Server 不支持的本地能力必须在启动前或调用时给出明确 unsupported,不静默落到本机执行。 -- 用户可见文案走 CLI 自有本地化资源;日志保持英文且不使用 emoji。 - -### 9.1 主要挑战与控制策略 - -| 挑战 | 主要风险 | 控制策略 | -|---|---|---| -| 兼容接口迁移 | 职责重复、全局状态和行为不一致 | 小步迁移职责、旧路径等价测试、协议/权限显式版本化 | -| 终端差异与大会话 | Windows/PTY 差异、闪烁、输入丢失、内存增长 | 终端 guard、纯文本降级、真实会话性能基线和三平台 PTY 测试 | -| 外部生态持续变化 | 未知字段被误映射、live source 成为第二真相源 | 版本化 fixture、资产分类、一次性 plan/apply、unknown fail visible | -| 第三方执行 | 凭据泄露、直接脚本副作用、宿主崩溃 | 明确来源和执行用户、独立进程、期限、取消、有界队列、脱敏、崩溃恢复,以及用户可调权限策略 | -| CLI 产品配置 | 产品组装结果/TUI 布局不一致、品牌/数据串用 | 使用产品定制模块的内容摘要、独立命名空间、签名与回滚验证 | -| Agent 非确定性 | 单次评测误判、并发写冲突、部分恢复 | 重复评测、guardrail、并发/取消策略和 typed partial outcome | - -## 10. 验证与完成标准 - -### 10.1 验证矩阵 - -| 范围 | 必须验证 | -|---|---| -| Capability/Profile | 产品组装结果/TUI 布局引用、依赖闭包、冲突、未知能力、缺失资源、产品能力上限和后端/入口一致性 | -| TUI | Reducer/命令单测、渲染 snapshot、PTY resize/paste/interrupt/restore、Approval Policy、纯文本/屏幕阅读器、性能预算 | -| Exec | 单结果 `json`、现有 Agent 事件流、stdout/stderr、权限默认值、取消、超时、resume/fork、Patch 与大结果引用 | -| Config | 层级合并、来源解释、策略约束、资产处置、三类外部 fixture、MCP disabled、Skill 可执行资源、冲突、回滚和脱敏 | -| Plugin | OpenCode 直接来源、依赖与真实导出、工具/全部稳定 Hook/Client、server 与 tui 两类入口、加载顺序、超时、崩溃、过载、恢复、策略差异和 unsupported fixture | -| Agent Runtime | session/turn/cancel、compact/checkpoint/rewind 的补偿/partial/re-entry、后台投递、Subagent、Hook 顺序和持久化恢复 | -| Evaluation | 版本化评测清单、候选/基线重复运行、原始结果、阈值、失败分布和安全保护项 | -| Product build | 当前产品组装结果/TUI 布局生成 CLI smoke artifact;通用 GUI/TUI、品牌、内置扩展和发行验证按产品定制文档执行 | -| 平台 | Windows、macOS、Linux 的 build/smoke;Windows 单独覆盖 ConPTY、Ctrl+C、路径和进程树清理;HarmonyOS PC 仅遵循平台规约,具体验证由未来专题定义 | -| Action/Keymap | registry 唯一性、Slash/Palette/Help/dispatch 一致、配置键位真实输入、冲突来源和终端恢复 fallback | - -通用 `cargo check --workspace` 负责三平台 CLI 编译保护;独立 CLI CI 运行 -`cargo test --locked -p bitfun-cli -p bitfun-acp -p bitfun-agent-runtime`。Linux 启动页 PTY 生命周期冒烟随独立 CLI -测试运行,Windows 启动页 ConPTY 生命周期冒烟复用通用 Windows job;Windows x64、macOS 和 Linux 的原生发布归档 -在上传前完成 SHA-256、双入口和解压执行验证。真实供应商模型进程级交互、macOS 活动 PTY 与 OS 级终端故障进程矩阵仍按对应切片补入门禁;Linux PTY -与 Windows ConPTY 的 Chat 活动 turn resize/取消及 `exec` Ctrl+C 已由本地确定性流式模型夹具覆盖,不能用它替代上述验收。 - -### 10.2 阶段退出条件 - -CLI-P0 完成: - -- CLI 使用真实 Runtime Parts 和统一能力可用性,不新增入口侧产品逻辑。 -- Slash、Palette、Help、Keymap 和 dispatch 从同一 action registry 派生,已支持的快捷键有真实输入测试。 -- 结构化输出、Approval Policy、配置来源和产品组装结果/TUI 布局消费均有可复核契约测试。 -- 两个已解析产品输入能在不修改源码的情况下生成当前平台最小 CLI smoke artifact。 -- CLI 独立 CI 成为必需检查。 - -CLI-P1 完成: - -- 常用交互与非交互场景在三平台通过自动化测试。 -- compact/checkpoint/rewind、后台任务和 `@`/`!` 不绕过 Runtime、Tool 和权限语义。 -- 用户能够解释配置、能力、模型 fallback 和失败原因。 - -CLI-P2 各路线独立完成: - -- 插件执行:真实 OpenCode 项目无需手工重打包即可完成来源发现、依赖准备、服务插件加载、一次真实工具调用和 - 稳定 Hook 变换;不支持的单项能力不使整个插件或界面失效。 -- 本地插件管理:安装、启用、停用、更新和卸载状态清楚分离;来源仍启用且健康旧进程仍合规时,代码或依赖更新 -失败不破坏旧进程;旧进程丢失后只有经内容摘要校验的旧版本副本仍存在时才重建,否则明确不可恢复。停用、删除、来源 - 撤销、权限收紧或安全策略失效必须停止并确认共享 Host 进程树退出,再撤下旧贡献并只恢复仍合规的插件;来源缺失或损坏时仍可清理残留状态。 -- TUI 扩展:除原始 OpenTUI/Solid 组件渲染外,TUI Plugin v1 宿主操作都有版本化适配和真实样例;渲染降级 - 不导致空白页面、输入固定或终端无法恢复。 -- 产品定制:CLI 产物正确消费产品定制归属模块生成的身份、资源、能力、数据和更新信息。 -- Agent 能力:每项改进由自己的重复评测证明,且没有通过放宽权限或扩大上下文掩盖问题。 - -## 11. 已统一的架构决策 - -1. 使用 BitFun 统一能力内核,不嵌入或 Fork OpenCode Runtime 作为产品内核。 -2. CLI 是产品入口,产品逻辑进入共享 Runtime/Capability/Harness,TUI 实现留在 `src/apps/cli`。 -3. 产品定义、Delivery Profile、Runtime Configuration 和 Capability Availability 分离。 -4. 外部源文件可以保持只读;非执行兼容结果可直接生效,可执行结果只有达到对应执行阶段后才激活;也可以显式 - 导入 BitFun 原生配置,凭据和插件执行分别处理。 -5. OpenCode 是首个插件执行兼容生态;Codex/Claude 首期只做配置资产导入。 -6. CLI 只消费产品组装结果和 TUI 布局选择;产品构建脚本、GUI 布局、品牌和发行信息由产品定制模块管理。 -7. 先补产品组装、协议、配置和测试基础,再扩展插件执行和复杂 TUI 功能。 -8. Agent 能力以共享运行时语义和重复评测加强,不用 CLI 专属硬编码模拟高级能力。 -9. HarmonyOS PC 是真实系统终端中的原生 CLI/TUI 目标,与 HarmonyOS 手机 Remote App 和后续 PC GUI 独立;没有 - 普通用户安装渠道、TTY、本地 Agent 与编码流程真机证据时不宣称本地支持,也不改用 HAP 或 Remote 代替。 +稳定语义: + +- Shell mode 只是 UI 状态;命令执行不属于 CLI。 +- 只允许 idle Session;命令使用 Session 已解析的本地或远程 workspace。 +- 非交互、`tty=false`,不接受图片或结构化 `@` 引用。 +- `/` 在 Shell mode 中是命令文本,不进入 slash registry。 +- 显式用户命令自动处理交互式 `ask`,但 project/global/profile 的 `deny` 仍由 ToolPipeline 执行。 +- 取消、工具事件、审计、上下文和持久化复用正常 Runtime 路径。 +- 保存为普通 `UserDialog` 与 `ExecCommand` ModelRound,因此 CLI、Desktop 和恢复流程消费同一事实。 + +这不是通用 Tool SDK,不向 UI 暴露任意 Tool 调用,也不允许 CLI 直接 spawn 进程。 + +### 4.3 本地 UI effects + +`/editor`、复制和导出只改变当前客户端状态或本地目标,不构造产品 turn。它们可以留在 CLI,但必须: + +- 在活动 Runtime 操作之外执行,避免阻塞事件消费。 +- 失败时保留原 draft 或明确报告部分结果。 +- 不把本地 effect 伪装成 Shared/Remote 已支持的能力。 +- 终端让渡后无论成功、失败或 panic 都恢复 TUI 状态。 + +### 4.4 非交互执行 + +`exec` 复用同一 Agent Runtime owner,不维护第二套 Agent loop。 + +- `text` stdout 只包含最终文本。 +- `json` stdout 是一个最终对象。 +- `stream-json` stdout 每行是一个完整 Agent event。 +- 日志与诊断进入 stderr 或日志文件。 +- 默认拒绝需要人工确认的操作;只有显式调用级策略可以自动批准。 +- 取消、事件失步、失败完成和 Patch 失败不能报告成功。 + +## 5. TUI 内部边界 + +TUI 增量保持四个可测试边界: + +1. 输入归一化:终端事件、paste、resize、mouse。 +2. 状态转换:composer、popup、history、selection、processing。 +3. Effect:Runtime 请求、搜索、clipboard、editor、export。 +4. 渲染:只读取状态,不访问文件系统、网络或 Agent owner。 + +Slash、Palette、Help、快捷键和 availability 从同一 Action Registry 派生。竞品已有等价入口时不自创命令;局部 UI 状态不进入 Agent Runtime contract。 + +终端恢复是强约束:正常退出、取消、初始化失败、错误返回和 panic 都要尽力恢复 raw mode、alternate screen、mouse/paste capture 与 cursor。 + +## 6. 配置、产品和外部来源 + +### 6.1 配置 + +CLI-local 配置只保存终端形态偏好与调用入口设置。共享权限、模型、Agent、MCP 和产品策略由各自 owner 解析。显式导入是快照操作,持续兼容来源是只读视图,两者不能共享“已启用”推断。 + +凭据发现、凭据使用和配置导入保持分离。日志、结构化输出、导入报告和诊断不能包含原始 token、header、secret 或不必要的绝对路径。 + +### 6.2 产品组装 + +CLI 通过 `DeliveryProfile::Cli` 消费经过校验的产品 Runtime parts。产品定义、Delivery Profile、Runtime Configuration 和 Capability Availability 是不同概念: + +- 隐藏入口不证明后端依赖被移除。 +- CLI 不读取 authoring product definition 作为运行时业务配置。 +- 品牌、资源、数据 namespace、更新渠道和内置扩展由产品定制 owner 生成,CLI 只消费结果。 + +### 6.3 外部来源与插件 + +CLI 只消费 typed summary 与 typed action: + +- 静态发现不等于代码执行或服务健康。 +- 配置导入不授予插件执行权限。 +- ACP、MCP import、Hook import、可执行插件和 TUI contribution 使用独立状态与生命周期。 +- 插件不能直接持有 Ratatui Frame、终端句柄、Session writer 或权限存储。 + +详细阶段和 OpenCode 能力映射由 extensions 专题文档维护,避免在本文件复制一套会漂移的矩阵。 + +## 7. 剩余架构工作 + +下列是演进方向,不代表已经排期: + +| 方向 | 当前问题 | 下一步约束 | +| --- | --- | --- | +| Core 兼容面收敛 | 部分 Peer/ACP/维护操作仍通过兼容 facade | 只按真实调用方提取窄 port;保持旧路径等价测试,不把 DTO 提取描述成 owner migration。 | +| TUI 模块化 | 少数编排文件仍聚集输入、effect 与生命周期 | 沿现有模块增量提取,不重写 TUI,不复制状态机。 | +| Shared Runtime 闭集扩展 | 各形态能力仍有差异 | 逐操作评审 identity、lease、timeout、cancel 和 unsupported;不做通用 RPC。 | +| 配置来源解释 | 非 MCP 资产的来源/冲突解释仍不统一 | 共享 owner 给出 typed provenance;CLI 只展示,不建立第二套合并器。 | +| 插件真实执行 | 静态来源与真实可调用能力容易混淆 | 只有 worker 健康、定义已加载并注册到 Tool Runtime 后才显示 available。 | +| 平台验证 | Windows/macOS/Linux 的终端和进程行为不同 | 在对应切片增加 PTY/ConPTY、取消、路径和进程清理测试;HarmonyOS PC 以平台专题的真机证据为准。 | + +架构优化优先减少 owner 重复、依赖方向和失败歧义,而不是增加抽象数量。没有第二个真实消费者时,不提升为通用平台 API。 + +## 8. 验证 + +| 变更范围 | 最小验证 | +| --- | --- | +| TUI state/input/render | focused reducer/input/render test + `cargo test -p bitfun-cli` | +| Agent Runtime SDK/port | `cargo test -p bitfun-agent-runtime` + owner focused test | +| Shared IPC | protocol round-trip、controller/idle、timeout/outcome-unknown、disconnect cancel | +| Core turn/tool | 权限 allow/ask/deny、取消、事件、上下文、持久化恢复 | +| `exec` output | stdout/stderr、单一最终状态、session conflict、Ctrl+C、Patch | +| terminal lifecycle | Linux PTY / Windows ConPTY 对应 smoke 或 deterministic fixture | +| product/packaging | product assembly + archive 双入口和签名/摘要 smoke | + +跨共享 Rust owner 的变更最终遵循仓库 `AGENTS.md` 的 workspace 验证矩阵。不能运行的检查必须在 PR 中说明原因、替代证据和剩余风险。 diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 473fd559b8..3da0ae7f35 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -6,7 +6,7 @@ export const forbiddenContentRules = [ reason: 'agent-runtime-ipc operation scope is frozen to the reviewed Shared TUI slice', patterns: [ { - regex: /^\s+(?!(?:Health|ListSessions|CreateSession|RestoreSession|DeleteSession|ForkSession|RenameSession|UpdateSessionMode|UpdateSessionModel|ReloadSessionContext|CompactSession|UndoSession|RedoSession|SearchWorkspaceReferences|WorkspaceReferencesForMessage|WorkspaceDiff|SubmitTurn|CancelTurn|PendingPermissions|RespondPermission|SubmitUserAnswers|Unit|Sessions|SessionCreated|SessionRestored|SessionForked|SessionReverted|WorkspaceReferenceSearch|WorkspaceReferences|TurnAccepted|TurnCancelled|None|CurrentController|AttachExisting|UncontrolledTarget|Self|RuntimeIpcSessionRequirement|RuntimeIpcOperationRules|RuntimeSessionForkRequest|AgentContextReloadRequest|AgentDialogTurnRequest|AgentMessageWorkspaceReferencesRequest|AgentSessionCompactionRequest|AgentSessionCreateRequest|AgentSessionCreateResult|AgentSessionListRequest|AgentSessionModeUpdateRequest|AgentSessionModelUpdateRequest|AgentSessionRevertRequest|AgentSessionRevertResult|AgentSessionSummary|AgentTurnCancellationRequest|AgentTurnCancellationResult|AgentWorkspaceReference|AgentWorkspaceReferenceSearchRequest|AgentWorkspaceReferenceSearchResult|SessionTranscript|WorkspaceDiffSnapshot)\b)[A-Z][A-Za-z0-9_]*\b/, + regex: /^\s+(?!(?:Health|ListSessions|CreateSession|RestoreSession|DeleteSession|ForkSession|RenameSession|UpdateSessionMode|UpdateSessionModel|ReloadSessionContext|CompactSession|UndoSession|RedoSession|SearchWorkspaceReferences|WorkspaceReferencesForMessage|WorkspaceDiff|SubmitTurn|RunUserShellCommand|CancelTurn|PendingPermissions|RespondPermission|SubmitUserAnswers|Unit|Sessions|SessionCreated|SessionRestored|SessionForked|SessionReverted|WorkspaceReferenceSearch|WorkspaceReferences|TurnAccepted|TurnCancelled|None|CurrentController|AttachExisting|UncontrolledTarget|Self|RuntimeIpcSessionRequirement|RuntimeIpcOperationRules|RuntimeSessionForkRequest|AgentContextReloadRequest|AgentDialogTurnRequest|AgentMessageWorkspaceReferencesRequest|AgentSessionCompactionRequest|AgentSessionCreateRequest|AgentSessionCreateResult|AgentSessionListRequest|AgentSessionModeUpdateRequest|AgentSessionModelUpdateRequest|AgentSessionRevertRequest|AgentSessionRevertResult|AgentSessionSummary|AgentTurnCancellationRequest|AgentTurnCancellationResult|AgentUserShellCommandRequest|AgentWorkspaceReference|AgentWorkspaceReferenceSearchRequest|AgentWorkspaceReferenceSearchResult|SessionTranscript|WorkspaceDiffSnapshot)\b)[A-Z][A-Za-z0-9_]*\b/, message: 'agent-runtime-ipc may not add archive, replay, observer, general controller-transfer, or other operations beyond the reviewed Shared TUI slice', }, diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 9c8a8eef73..32b953edc1 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -4956,6 +4956,8 @@ async fn release_baseline_claim(release: BaselineClaimRelease) -> Result<(), Dis runtimeIpcOperationPattern.test(' WorkspaceDiff {') || runtimeIpcOperationPattern.test(' WorkspaceDiffSnapshot,') || runtimeIpcOperationPattern.test(' SubmitTurn {') || + runtimeIpcOperationPattern.test(' RunUserShellCommand {') || + runtimeIpcOperationPattern.test(' AgentUserShellCommandRequest {') || runtimeIpcOperationPattern.test(' SessionForked {') || runtimeIpcOperationPattern.test(' SessionReverted {') ) { diff --git a/src/apps/cli/AGENTS.md b/src/apps/cli/AGENTS.md index 43a33ed8f7..25dc050338 100644 --- a/src/apps/cli/AGENTS.md +++ b/src/apps/cli/AGENTS.md @@ -1,118 +1,119 @@ # BitFun CLI Agent Guide -Scope: this guide applies to `src/apps/cli`. +Scope: `src/apps/cli`. -Read [`docs/architecture/cli-product-line-design.md`](../../../docs/architecture/cli-product-line-design.md), -[`docs/architecture/product-architecture.md`](../../../docs/architecture/product-architecture.md), and -[`docs/architecture/product-customization-blueprint.md`](../../../docs/architecture/product-customization-blueprint.md) -before product-definition, TUI layout, branding, packaging, runtime, or plugin architecture changes. +Read the repository `AGENTS.md` first. For architecture-sensitive work, also +read: + +- [`cli-product-line-design.md`](../../../docs/architecture/cli-product-line-design.md) +- [`product-architecture.md`](../../../docs/architecture/product-architecture.md) +- [`agent-runtime-deployment-design.md`](../../../docs/architecture/agent-runtime-deployment-design.md) +- [`product-customization-blueprint.md`](../../../docs/architecture/product-customization-blueprint.md) when changing product assembly, branding, or packaging ## Ownership -- This app owns Clap commands, TUI state and rendering, terminal input/lifecycle, - CLI-local settings, structured output projection, and user-facing CLI diagnostics. -- Peer Device Mode **host** support lives in `src/peer_host/`: after `/login` - (same Auth Server / Username / Password flow and `~/.bitfun` session/hint - files as Desktop), device routing stays up so Desktop controllers can - HostInvoke this process. CLI is not a Peer Mode controller. Same-machine - Desktop+CLI share one `device_id`; last AuthConnect wins. -- Shared session, turn, task, tool, permission, context, checkpoint, Subagent, - Harness, MCP, plugin, and capability facts belong to their runtime owners. -- Existing `bitfun-core/product-full` compatibility paths may remain during a - reviewed migration. Do not add new concrete managers, global mutable services, - or CLI-only copies of shared product behavior. - -## Product and extension boundaries - -- Assemble CLI behavior through `DeliveryProfile::Cli`, capability plans, typed - services, and capability availability. Hiding a command is not a backend - capability restriction. -- The target CLI consumes product identity, theme resources, data namespaces, - bundled product extensions, update channels, and TUI layout IDs from the - validated product assembly result. Resolved Product Manifest and TUI Blueprint - are retired design terms, not migration inputs. Do not read authoring product - definitions at runtime, add hard-coded branding/source - rewrites, or treat user plugins as product assembly inputs. Runtime capability - hiding does not prove code was physically removed. -- Product assembly may expose only the immutable protection IDs allowed by the - customization design. CLI must not turn them into user/source plugin policy or - store plugin activation, update, permission, or health state in the assembly result. -- OpenCode Prompt Commands from standard user and project configuration are - read-only live sources. CLI may send only the fully expanded prompt through the - existing agent owner; it must re-confirm changed conflict participants. Static - workspace file references and reviewed shell directives are expanded by their - shared owners before that handoff. CLI must not execute directives that the - prompt-command contract marks unsupported or add another approval store. -- OpenCode standalone JavaScript tools may execute only through the shared - external-source approval, conflict, Tool Runtime, and script-worker owners. - CLI/TUI consumes typed snapshots and actions; it must not import modules, - spawn tool workers, bypass a pending decision, or implement a second approval - store. TypeScript, dependency loading, package plugins, and hooks remain - non-executable until their own reviewed capability slice lands. -- OpenCode external subagents may execute only through the shared source - decision and existing Subagent owner. TUI consumes typed summaries and - actions checked against the current Subagent and preference revisions; it must not parse agent files, inject source - prompts directly, invent model fallbacks, or offer follow-up for the current - fresh single-run compatibility slice. -- The managed-package OpenCode adapter remains a static-preview path. Other - OpenCode plugin capabilities, Codex, and Claude remain import/reference sources - unless their own reviewed adapter design explicitly changes. Never copy - credentials or silently ignore unsupported fields. -- Keep native instruction references, explicit import records, executable plugin - sources, and credentials as separate asset classes. Importing non-executable - config must not establish executable-source policy. CLI consumes the external - source status and typed actions; it must not add another activation layer on top - of the source/plugin decision or claim that post-import confirmation can undo - candidate-module side effects. -- CLI plugin screens consume capability services, read-only status, and typed - diagnostics. They must not depend on raw PluginRuntimeClient contracts or raw ecosystem - payloads. -- Non-interactive commands return `action-required` only when the current operation - actually depends on a pending external asset. Unrelated confirmations remain in - structured status or `stderr` summaries and must not block the command. -- External ACP agents, external config import, and managed plugins are separate - capabilities with separate trust and lifecycle state. - -## TUI and automation - -- Keep terminal session restore, event normalization, state transitions, effects, - command dispatch, and rendering independently testable. Reducers and views do - not perform filesystem, network, config, or Agent operations directly. -- Slash commands, palette actions, and root CLI commands should map to the same - stable capability requests instead of reimplementing behavior per entrypoint. -- `json` is one result document; `stream-json` is one complete event per line. - Keep protocol stdout free of logs and preserve schema/exit-code compatibility. -- Keep `src/modes/exec.rs` as the stable module facade. The current private split - keeps lifecycle/event settlement in `exec/lifecycle.rs` and Patch capture/write - behavior in `exec/patch.rs`; further private splits are allowed when they keep - one executor, one output schema, and one lifecycle owner. -- Approval policy is invocation-scoped: interactive TUI defaults to ask; - non-interactive execution fails when confirmation is required unless an - explicit argument or managed policy approves it. Do not mutate a global - confirmation flag to implement an entrypoint default. -- Shell shortcuts, file references, background work, compact, checkpoint, and - rewind must use shared Tool/Agent Runtime, permission, cancellation, artifact, - and audit paths. -- Always restore raw mode, alternate screen, mouse capture, and paste mode after - normal exit, cancellation, initialization failure, or panic. +CLI owns only surface concerns: + +- Clap entrypoints and CLI-local configuration +- terminal acquisition/restoration and input normalization +- TUI state, rendering, popups, local draft history, and local effects such as + clipboard or external-editor integration +- projection of Runtime events into text, JSON, JSONL, and user diagnostics +- Shared Runtime client/server adaptation and Peer Device host presentation + +Session, turn, model round, tool execution, permissions, cancellation, +persistence, context, workspace binding, MCP, Subagent, and other product facts +belong to their shared owners. Do not add CLI-only managers or reproduce shared +behavior behind a TUI branch. + +Existing Core compatibility forwarding may remain until a reviewed owner +migration has behavior-equivalence tests. A typed port is not evidence that the +runtime owner moved. + +## Runtime paths + +Normal interactive submissions follow: + +```text +ChatView -> CliAgentRuntimeClient -> AgentRuntime SDK + -> Core owner -> Session / Agent execution / ToolPipeline +``` + +Shared TUI inserts versioned local IPC between `CliAgentRuntimeClient` and the +same Agent Runtime SDK. It must not create a second product implementation. +Side-effecting operations need stable identities, controller/idle rules, +bounded frames, and outcome-unknown handling before a connection can retry. + +Explicit Shell input follows: + +```text +SHELL composer -> AgentUserShellCommandPort -> Core coordinator + -> ToolPipeline(ExecCommand) -> TerminalPort / RemoteExecPort + -> standard UserDialog + ModelRound persistence and events +``` + +CLI must never spawn the submitted command directly or expose a generic tool or +process API. Explicit user input may auto-approve an interactive `ask`, but +static `deny` rules, workspace routing, cancellation, audit, and tool +restrictions remain enforced. + +## TUI rules + +- Derive slash commands, palette actions, help, availability, and key bindings + from the action registry. Do not add a second command table. +- Match established competitor entry flows when equivalent behavior exists. + Prefer OpenCode names and interactions; do not invent `/shell` or aliases for + the `!` Shell entry. +- Keep terminal input, state transitions, effects, and rendering independently + testable. Views and reducers do not perform filesystem, network, config, or + Agent operations. +- Shell mode is CLI presentation state only. It accepts an empty-composer `!`, + keeps chat/shell histories separate, treats `/` as command text, and rejects + images and structured `@` references before Runtime submission. +- Direct paste, `Ctrl+V`, and bracketed paste share `ComposerDraft`. Shared TUI + rejects unsupported image payloads before IPC. +- Local effects such as `/editor`, copy, and export stay local. Product work + such as shell execution, session mutation, and permissions goes through typed + Runtime owners. +- Always restore raw mode, alternate screen, mouse capture, paste mode, and the + cursor on success, error, cancellation, initialization failure, or panic. +- Protocol stdout contains only the selected result format. Logs are English, + contain no emoji, and use stderr or log files. + +## Product and external-source boundaries + +- Assemble CLI through `DeliveryProfile::Cli` and validated product Runtime + parts. Hiding a command is not a backend capability restriction. +- CLI consumes typed external-source summaries and actions. It does not parse + source files, import executable modules, start plugin workers, duplicate + approval state, or treat static discovery as runtime availability. +- ACP agents, configuration import, executable plugins, Hooks, and Peer Device + hosting have separate trust and lifecycle state. Do not infer one from + another. +- Remote-unsupported local effects must fail visibly; never fall back to the + controller machine. + +Detailed compatibility rules belong in the dedicated architecture documents, +not in this file. ## Verification -Run the smallest checks matching the change: +Run the smallest checks matching the changed path: ```bash cargo check -p bitfun-cli cargo test -p bitfun-cli ``` -Also run focused protocol/PTY tests when structured output, terminal lifecycle, -input, session control, config import, plugin management, or product assembly -behavior changes. Theme/color changes require `pnpm run theme:color-audit:all`. -Packaging or branding changes require the CLI package smoke path and a clean-tree -two-product build assertion. +Also run focused owner tests when a surface crosses a shared boundary: -## Install for end users +- Agent Runtime port/SDK changes: `cargo test -p bitfun-agent-runtime` +- Shared IPC/protocol changes: `cargo test -p bitfun-agent-runtime-ipc` +- Core turn/tool/persistence behavior: the focused `bitfun-core` tests, then + the repository shared-Rust verification row +- terminal lifecycle/input changes: the nearest PTY/ConPTY or input test +- product/packaging changes: product assembly and archive smoke paths -Use [`install.ps1`](install.ps1), [`install.sh`](install.sh), and [`README.md`](README.md) for -platform-native per-user installation. Document `bitfun` as primary; ship `bitfun-cli` only as the -deprecated compatibility entrypoint, and use `bitfun` in all new examples and integrations. +Use [`README.md`](README.md) for user-facing behavior and installation. Keep +developer internals here or in architecture docs instead of expanding the user +guide. diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index 1697dc16fc..90a70f82b7 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -1,388 +1,188 @@ # BitFun CLI -Terminal UI for BitFun (chat, tools, `/login` account + Peer Host). +BitFun CLI provides an interactive terminal UI, non-interactive Agent runs, +session management, and machine-owned background tasks. Use `bitfun` for all +new scripts and integrations; `bitfun-cli` is a deprecated compatibility +entrypoint. -The local Agent paths build the CLI product profile once per invocation. Interactive chat, `exec`, -session commands, and usage reports use that invocation-scoped runtime context and event source. -Local management queries do not start Peer Host or MCP; `exec` starts MCP but not Peer Host. -Core remains the compatibility owner for execution and persistence operations not yet covered by the -Agent Runtime SDK. When interactive mode enables Peer Host, Peer dialog submission, cancellation, -and agent-event fan-out reuse the same runtime context; Peer Host does not construct another -scheduler, persistence manager, or event queue. Plugin execution is not enabled by this assembly path. +## Install -## Common commands +From the repository root: ```bash -bitfun # interactive TUI -bitfun exec "summarize this project" # non-interactive, rejects permission requests -bitfun exec "run tests" --auto # approve tool requests for this invocation -bitfun sessions list -bitfun usage -bitfun doctor -bitfun health -bitfun mcp import # preview safe OpenCode / Claude Code / Codex MCP declarations -bitfun mcp import --apply # copy eligible declarations as disabled native entries -bitfun mcp import --apply --candidate # repeat to select a subset -bitfun mcp import --apply --candidate --native-id -bitfun mcp import --format json # versioned machine-readable plan/result -bitfun update # GitHub first, openbitfun.com fallback -bitfun update --check # report only; do not install +pnpm run cli:install ``` -`bitfun mcp import` is an explicit snapshot operation, not continuous sync. It -does not copy credentials, headers, environment values, or explicit working -directories. Apply revalidates the preview and never overwrites an existing -native entry; imported entries remain disabled until reviewed and enabled -through the existing MCP manager. Use `--format json` for the versioned -machine-readable plan or result. - -Official Linux archive installations check for updates before interactive TUI -startup at most once every six hours. That check only fetches the release -manifest — a few KB, bounded at ten seconds — and hands the actual archive to a -detached background process, so launch never waits on a download. Set -`behavior.auto_update = false` in the CLI config or export -`BITFUN_CLI_DISABLE_AUTO_UPDATE=1` to disable automatic checks. Development and -nightly binaries are never replaced by the stable automatic updater. - -Both GitHub and `https://openbitfun.com/release/linux-binaries.json` are read, -and whichever advertises the newer version decides what gets installed. When -both carry it, each is probed with a short ranged request and the archive is -downloaded from whichever is measurably faster — a source that is merely slow -rather than broken no longer holds the update hostage. Downloads resume rather -than restart, across a switch of source and across runs (a background install -killed at 90% picks up where it stopped). Concurrent updates are serialised by -`update.lock` in the CLI config directory. - -Two checks run before anything is replaced: - -- **Checksum**, fetched from the canonical GitHub URL even when the archive came - from a mirror. A `.sha256` served by the same host as the archive only proves - the transfer was intact — whoever serves one serves the other. Falling back to - the origin's own checksum happens only when GitHub is wholly unreachable, and - says so. -- **Signature** (`.sig`, minisign), verified against the release key compiled - into official builds — the same trust root the Desktop updater uses. This is - the check a mirror or third-party proxy cannot forge. Official builds refuse - to install an unsigned release; builds without the key (local, forks) fall - back to checksum-only. - -`bitfun-cli` is a deprecated compatibility entrypoint. It writes -`Warning: \`bitfun-cli\` is deprecated; use \`bitfun\` instead.` to stderr; new scripts and -integrations must use `bitfun`. Official installers and archives ship both commands as a pair; a -standalone legacy launcher is an incomplete installation and reports how to reinstall the pair. -The naming change is limited to the shell command: the Cargo package, archive prefix, service -identifiers, and persistent paths retain the `bitfun-cli` name. - -The TUI asks before protected tool calls and offers `Allow once`, `Allow always`, and `Reject`. -`Allow always` applies only to matching tools in the current runtime context; it does not update the -global configuration. Non-interactive `exec` rejects permission requests by default. Use `--auto` -only when the current invocation may approve tool requests. Non-interactive `exec` does not expose -`AskUserQuestion`; provide all required input in the initial prompt. The hidden legacy `--confirm` -flag maps to the safe default and should not be used in new automation. - -### Interactive session and context commands - -The Embedded and Shared TUI use the same session command names: - -- `/sessions` opens the session browser; `/resume`, `/continue`, and `/history` are aliases. -- `/new` starts a fresh conversation session; `/clear` is its OpenCode-compatible alias and does not - merely clear the terminal display. -- `/fork` opens an OpenCode-compatible fork dialog. `Full session` copies through the latest - persisted turn; choosing a previous user prompt forks immediately before that turn and copies the - prompt into the composer without sending it. Forking requires an idle session. -- `/timeline` opens the OpenCode-compatible user-message timeline, newest first. Moving the - selection previews that location in the current transcript; Enter keeps the selected message in - view. Timeline navigation is read-only and remains available while a turn is running. -- `/status` opens a transient view of current session, runtime, workspace, approval, and latest - primary-model request facts observed by this TUI. It is not a cumulative usage report; use - `/usage` for cumulative session usage in Embedded TUI. -- `/compact` compacts the current session's model context without deleting saved conversation - history; `/summarize` is its OpenCode-compatible alias. Compaction is available only while the - session is idle. -- `/editor` opens the current composer draft in the command configured by `VISUAL`, then `EDITOR`. - The command must wait until editing is complete (for example, include the editor's wait flag). - BitFun does not guess or install an editor. Missing commands, non-zero exits, and empty files - leave the existing draft unchanged; structured `@` references are retained only when their - edited markers remain unambiguous. A terminal reacquisition failure exits instead of continuing - in a partially initialized TUI. -- `/timestamps` (`/toggle-timestamps`) and `/thinking` (`/toggle-thinking`) use OpenCode's command - names to toggle message timestamps and reasoning-block visibility. Tool-detail visibility is a - command-palette action only, matching OpenCode rather than introducing another slash command. - Timestamps and thinking default to hidden; tool details default to shown. These client-local - preferences are saved in CLI configuration, while clicking an individual reasoning or tool block - still overrides its current presentation. -- `/copy` copies a safe Markdown snapshot of the visible User/Assistant transcript. Reasoning and - tool payloads are excluded by default; local System notices are never exported. Copy is idle-only - so a slow platform clipboard helper cannot stall an active Turn's event stream. -- `/export` opens a TUI form for the relative output filename, reasoning/tool-detail options, - optional external-editor review, and an explicit save toggle. Exports resolve under the local - directory where this CLI process started, reject absolute or parent-traversal paths, confirm - overwrites, publish a new file without clobbering a racing creator, and use strict atomic - replacement only after confirmation. These commands work the same in Embedded and Shared TUI; - they remain local client effects and do not add Runtime or IPC operations. - -### Image attachments - -The Embedded TUI follows OpenCode's existing paste interaction instead of adding a command or a -new shortcut. Paste an image from the system clipboard, or paste one local PNG, JPEG, GIF, or WebP -path, using the configured paste action (Ctrl+V by default) or terminal bracketed paste. Quoted -paths, `file://` URLs, and POSIX shell-escaped paths are accepted. The composer renders -`[Image N]`, keeps at most five images, and rejects files larger than 20 MiB or files whose decoded -format does not match a supported image format. - -Image bytes are read and validated when pasted, so later file changes do not alter the submitted -Turn and absolute paths are not sent to the Agent Runtime. File decoding is limited to 64 MiB of -allocation. The platform clipboard API supplies already-decoded pixels, so BitFun validates its -dimensions and applies the encoded-size limit after that API returns; the provider's own decoding -cannot be bounded by BitFun. Local input history, deletion and relabeling, per-Session successful -submission undo/redo within the same TUI run (including after switching Sessions), startup-page -submission, and `/editor` draft reconciliation retain the same attachment state when their -placeholders remain unambiguous and can be safely associated. Local draft histories share a 200 MiB -image-byte budget; older retained placeholders become plain text when that budget is exceeded. Slash -commands do not accept image attachments. Shared TUI reports image paste as unsupported and keeps -the current draft unchanged; image data is not placed in the bounded Shared IPC frame. - -The interactive TUI supports per-session worktree isolation through `/worktree`. Run the command -without arguments to toggle it, or use `/worktree on`, `/worktree off`, and `/worktree status`. -The header shows the active branch and `Worktree: on|off`; detached managed worktrees use their base -commit as the branch label. The existing session lifecycle owner creates, persists, restores, and -releases the managed worktree. Isolation can only be changed before the session's first message, and -a released worktree with local or unpublished work is retained with its path reported in the chat. -This command is TUI-only and does not change the non-interactive `exec` contract. - -### Structured output - -| Format | stdout contract | -|---|---| -| `text` | Assistant text. Progress, tool status, logs, and diagnostics use stderr. | -| `json` | One final result object with status and result, plus session/turn identity once established, turn-accumulated usage, and available Patch facts. A Session writer conflict adds `error_code: "session_in_use"`. | -| `stream-json` | JSONL containing existing Agent event values; no separate CLI event schema. A Session writer conflict reuses `SystemError` with `error: "session_in_use"` and `recoverable: true`. | - -Select a format with `--output-format text|json|stream-json`. When `--output-patch -` is used with -`json`, the Patch is included in the final object. For `stream-json`, write the Patch to an explicit -file path so protocol stdout remains valid JSONL. A Patch is the repository's `HEAD`-relative -workspace snapshot captured before an explicit Patch artifact is written. It includes staged, -unstaged, untracked, and pre-existing changes, excludes the output artifact itself, and does not -attribute changes to this invocation. - -`Ctrl+C` requests cancellation of the active turn and briefly drains its terminal events before -returning. Cancellation, an unsuccessful completion event, -and a requested Patch that cannot be generated or written are error outcomes. An explicit Patch -file is created even when the diff is empty. +The installer builds and installs both entrypoints for the current platform. +The default install directory is `~/.local/bin` on macOS/Linux and +`%LOCALAPPDATA%\BitFun\bin` on Windows. Open a new terminal after installation +so the updated `PATH` is visible. -If another BitFun process is writing the requested Session, `exec` exits non-zero without waiting -or taking over. Close that Session in the other instance and run the command again. `recoverable` -describes that later retry; it does not mean the current command retries automatically. +Official release archives contain both executables. Keep them together when +extracting an archive; the compatibility launcher requires its sibling +`bitfun` executable. -`doctor` and `health` validate product assembly and required capability registrations. They are not -live probes for Network, Git, or MCP integrations that are currently represented by compatibility -registrations. +Prerequisites for a source install are a Rust toolchain and this repository. +See the repository [contribution guide](../../../CONTRIBUTING.md) for development +setup and build commands. -## Detached task dispatch - -`bitfun dispatch` is the target-side, machine-readable interface used by -Desktop, Server Host, SSH, and encrypted account-device RPC. Requests are JSON -on stdin and responses are one JSON value on stdout: +## Quick start ```bash -printf '%s' '{"workspacePath":"/srv/app"}' | bitfun dispatch probe -printf '%s' '{"jobId":"job-123","cursor":0}' | bitfun dispatch status -printf '%s' '{}' | bitfun dispatch list -printf '%s' '{"jobId":"job-123"}' | bitfun dispatch cancel +bitfun # interactive TUI +bitfun exec "summarize this project" # one non-interactive Agent run +bitfun exec "run tests" --auto # approve interactive tool asks for this run +bitfun sessions list +bitfun doctor ``` -`submit` additionally requires `protocolVersion`, `sessionId`, -`workspacePath`, `agentType`, `prompt`, and an explicit `approvalPolicy`: -`auto`, `reject-and-report`, or `remote`. With `remote`, `status` returns -`pendingPermissions`; answer one with `dispatch answer`. `dispatch append` -adds a steering message to a queued or running job. Call `probe` first and -honor its protocol version and capability list rather than assuming that -different BitFun releases are compatible. - -Jobs and their event logs live under `~/.bitfun/dispatch/jobs/`. The detached -worker and target-side session remain authoritative after the submitting -client disconnects. Each target workspace is serialized so two jobs do not -modify it concurrently. Terminal jobs and managed snapshots are retained for -30 days. Event history is bounded; `status` reports cursor resets, truncation, -and omitted-event counts so clients never mistake a partial transcript for a -complete one. - -Workspace transfer is a separate controller operation. An exact snapshot -includes hidden and ignored regular files, excludes `.git`, rejects links and -special files, verifies an archive and manifest digest, and publishes into the -target-managed dispatch directory atomically. It is a one-time input snapshot, -not live or bidirectional synchronization; target changes are not copied back -automatically. See -[`docs/architecture/detached-task-dispatch.md`](../../../docs/architecture/detached-task-dispatch.md) -for the ownership and transport contract. - -## Always-on account device host (daemon) - -Account multi-device access requires the target device to hold a live relay connection. On a -server that is usually not true while no interactive CLI is running. The daemon solves this: it is -a headless Peer Host process that restores the persisted account session and holds the relay -device-routing connection, so other devices on the account can reach this machine whenever it is -up. - -Full setup flow on a server: +The interactive TUI asks before protected Agent tool calls. Non-interactive +`exec` rejects permission requests by default; use `--auto` only when the +current invocation may approve them. + +Run `bitfun --help` or `bitfun --help` for the complete command and +option reference. + +## Interactive TUI + +The most frequently used commands follow established OpenCode names where an +equivalent exists: + +| Input | Effect | +| --- | --- | +| `/sessions` | Browse and restore sessions. | +| `/new` or `/clear` | Start a new session. | +| `/timeline` | Navigate persisted user messages without changing the session. | +| `/fork` | Fork the full session or fork immediately before a selected prompt. | +| `/compact` or `/summarize` | Compact model context without deleting the saved transcript. | +| `/undo` / `/redo` | Move the persisted session timeline backward or forward. | +| `/diff` | Review staged, unstaged, and untracked workspace changes. | +| `/editor` | Edit the current draft with `VISUAL`, then `EDITOR`. | +| `/copy` / `/export` | Copy or export the visible transcript as Markdown. | +| `/status` / `/usage` | Inspect current-session status or cumulative usage. | +| `/reload [skills\|instructions]` | Refresh declarative context for the next message. | + +The command palette and shortcut help show the bindings active in the current +configuration. `/editor` does not install or guess an editor. For GUI editors, +configure a command that waits until the file is closed; missing commands, +non-zero exits, and empty editor output leave the current draft unchanged. + +### Shell mode + +With an empty composer, type `!` to enter **SHELL** mode, matching OpenCode's +entry flow. The `!` marker becomes the input label and is not part of the +command. Press `Esc`, or press `Backspace` while the command is empty, to return +to chat mode. Shell and chat keep separate input histories. + +Press Enter to run the command in the session workspace. Shell mode is +non-interactive: it does not allocate a PTY and does not accept image or +structured `@` attachments. A leading `/` is shell text, not a BitFun slash +command. The command uses the shared Agent Runtime, normal `ExecCommand` tool, +workspace binding, cancellation, audit, and static permission rules. Because +the command was explicitly typed by the user, an interactive `ask` is approved +without a second prompt; a configured `deny` still blocks execution. + +The command and tool result are saved as a standard turn. Restoring the session +therefore shows the same tool card in CLI and Desktop instead of a CLI-only +transcript item. -```bash -pnpm run cli:install # build + install both entrypoints (see below) -bitfun # start the TUI, then /login with your account -bitfun daemon install # register auto-start; device stays reachable after exit/reboot -bitfun daemon status # verify: daemon running + service installed/active -``` +### Image attachments -```bash -bitfun daemon status # daemon liveness + auto-start service status -bitfun daemon install # register and start the auto-start service (requires /login first) -bitfun daemon uninstall # stop and remove the auto-start service -bitfun daemon run # foreground mode (used by the service manager; also for debugging) -``` +In Embedded TUI, use the configured paste action (`Ctrl+V` by default) or +terminal bracketed paste to attach a clipboard image or a local PNG, JPEG, GIF, +or WebP path. Quoted paths, `file://` URLs, and POSIX shell-escaped paths are +accepted. A message may contain up to five images, each no larger than 20 MiB. -- Prerequisites: a persisted account session (`/login` inside the TUI first), and on Linux a - working systemd user session (`systemctl --user`). Containers, WSL, and some minimal images do - not have one; there `daemon install` reports a clear error and you can instead run - `bitfun daemon run` under your own supervisor (tmux, nohup, a custom unit, ...). -- Linux: installs a systemd user unit (`~/.config/systemd/user/bitfun-cli-daemon.service`) and - enables linger, so the daemon starts at boot and keeps running without an interactive login - session. macOS: installs a LaunchAgent. Windows is not supported for auto-start; use - `daemon run` instead. -- The interactive CLI detects a running daemon and skips its own relay connection (same-machine - processes share one `device_id`; last AuthConnect wins). Without a daemon, the interactive CLI - connects by itself as before. -- Logging out (`/logout`) signals the daemon to shut down so the device goes offline immediately; - a daemon whose token is rejected by the relay exits on its own instead of staying "online" with - a doomed token. -- Logs land in `~/.config/bitfun/cli-logs//app.log` (the daemon starts a new - session directory per process start). - -### Account settings sync - -Both the interactive CLI and the daemon continuously sync account settings (model configs, -default model, agent preferences, ...) with the account cloud: - -- Local changes (TUI model picker / model forms, `bitfun models set-default`, or a peer - controller's `set_config`) upload after a ~5s debounce, deduped by content hash. -- Cloud changes from other devices are pulled right after process start, then every ~30s, and - applied to the running process (AI client cache invalidated, config reloaded). -- While a desktop controller is attached (Peer Device Mode), the host fans out - `account://settings-applied` after applying or uploading settings, so the controller's - model list / settings UI refreshes without reconnecting. -- The sync cursor persists at `~/.bitfun/account_sync/.settings.json`, so restarts do not - re-apply unchanged settings. - -### Upgrading - -Re-run `pnpm run cli:install`, or use the direct Bash/PowerShell command below for your platform. -The installer stages and verifies both entrypoints before replacing an existing pair; a failed -replacement restores the previous pair. It also restarts the daemon's auto-start service when one -is installed (a running daemon otherwise keeps executing the old binary). If you supervise the -daemon yourself (`daemon run` under tmux/nohup/a custom unit), restart it manually after upgrading. - -## One-click install (Windows / macOS / Linux) +Images are read when pasted, so later file changes do not alter the submitted +turn and local absolute paths are not sent to the Runtime. Slash commands and +Shell mode do not accept images. Shared TUI currently reports image paste as +unsupported and keeps the draft unchanged. -From the repository root: +### Shared TUI ```bash -pnpm run cli:install +bitfun chat --shared ``` -The dispatcher selects PowerShell on Windows and Bash on macOS/Linux. Direct platform commands are: +Shared TUI lets multiple terminal processes reuse one workspace Runtime. Each +TUI controls at most one session and a session has one controller. Core chat, +Shell mode, session navigation, model/mode selection, permissions, and +transcript events use the same behavior as Embedded TUI. Some local management +and attachment capabilities remain Embedded-only and report that limitation +instead of silently falling back. -```bash -# macOS / Linux (amd64 + arm64) -bash src/apps/cli/install.sh +Exit all Shared TUI clients and wait briefly before returning to the default +Embedded mode for the same workspace. -# Windows x64 -powershell.exe -NoProfile -ExecutionPolicy Bypass -File src/apps/cli/install.ps1 -``` +## Non-interactive output -Both installers: +Select output with `--output-format text|json|stream-json`: -1. `cargo build -p bitfun-cli --release` (native host CPU) -2. Install `bitfun` and the deprecated `bitfun-cli` compatibility entrypoint -3. Verify the primary command and the exact compatibility warning -4. Add the install directory to PATH idempotently unless requested otherwise +| Format | stdout contract | +| --- | --- | +| `text` | Final Assistant text. Progress and diagnostics use stderr. | +| `json` | One final result object, including session/turn identity and usage when available. | +| `stream-json` | JSONL containing existing Agent events. | -The Unix default directory is `~/.local/bin`; the Windows default is -`%LOCALAPPDATA%\BitFun\bin`. Unix updates managed blocks in `~/.bashrc` and `~/.zshrc`; -Windows updates the current user's PATH. +`Ctrl+C` requests cancellation of the active turn. Session writer conflicts, +unsuccessful completion, an invalid event stream, and requested Patch failures +produce a non-zero result instead of reporting partial success. -The installer process cannot update the shell that launched it. Open a new terminal, then run: +## Other command groups ```bash -bitfun +bitfun agents --help +bitfun models --help +bitfun mcp --help +bitfun plugins --help +bitfun hooks --help +bitfun config --help +bitfun acp --help ``` -For the current shell, either invoke the printed direct path or temporarily prepend the install -directory to `PATH` using the copyable command printed by the installer. - -### Release archives - -Official macOS/Linux archives contain `bitfun`, deprecated `bitfun-cli`, `README.md`, and -`PROJECT-README.md`; the Windows x64 ZIP contains the matching `.exe` pair and documents. Extract -the whole archive and keep the two executables together in the same directory. Run `./bitfun` on -macOS/Linux or `.\bitfun.exe` in PowerShell, then add that directory to `PATH` if desired. - -Do not copy only `bitfun-cli` from an archive. The deprecated command is intentionally a thin -launcher for its sibling `bitfun`; if the sibling is missing, it reports an incomplete installation -with recovery guidance instead of attempting another lookup. +`bitfun mcp import` is an explicit preview/apply snapshot. It does not copy +credentials, headers, environment values, or explicit working directories, and +new native entries remain disabled until reviewed. -Every archive ships an adjacent `.sha256` — the complete verification surface across all -platforms and both the stable and nightly channels. The release also carries a `SHA256SUMS` -aggregate, but it covers only the macOS and Windows archives: Linux archives are published by -`.github/workflows/linux-binaries.yml` in a separate workflow run (the only producer that also -covers nightly, and the one that must hold the CLI and Relay archives together to emit -`linux-binaries.json`), so they cannot be folded into that file deterministically. Verify Linux -downloads with their `.sha256` sidecar. +### Persistent tasks -On a server that should stay reachable for account multi-device access, continue with the -[daemon section](#always-on-account-device-host-daemon) above after `/login`. +`bitfun dispatch` is the machine-readable target-side interface used by other +BitFun surfaces. Jobs remain owned by this machine after the submitting client +disconnects. Controllers should call `dispatch probe` and honor the returned +protocol version before submitting or inspecting jobs. See the +[detached task architecture](../../../docs/architecture/detached-task-dispatch.md) +for the transport and workspace-snapshot contract. -### Options / environment +### Always-on account device host -| Variable | Meaning | -|----------|---------| -| `BITFUN_CLI_BIN_DIR` | Install directory (default `~/.local/bin`) | -| `BITFUN_CLI_SKIP_SHELLRC` | Set `1` to skip bashrc/zshrc edits | -| `CARGO_TARGET_DIR` | Cargo target dir (e.g. `$HOME/bitfun-build/target` on shared mounts) | -| `CARGO_BUILD_JOBS` | Limit rustc parallelism on small VPS | - -Windows accepts `-BinDir ` and `-SkipPathUpdate` arguments. Unix accepts the environment -variables above. - -Example on a small arm64 VPS: +After signing in with `/login`, a server can keep its account device route +online without an interactive TUI: ```bash -CARGO_BUILD_JOBS=1 bash src/apps/cli/install.sh +bitfun daemon install +bitfun daemon status ``` -### Prerequisites - -- Rust toolchain (`rustup` / `cargo`) -- Repository checked out with workspace `Cargo.toml` at the root +Linux uses a systemd user service and macOS uses a LaunchAgent. Windows does not +currently install an auto-start service; use `bitfun daemon run` under a +supervisor instead. Run `bitfun daemon --help` for lifecycle commands and +platform diagnostics. -## Dev commands (from repo root) +## Updates and troubleshooting ```bash -pnpm run cli:dev # cargo run -pnpm run cli:build # cargo build --release -pnpm run cli:install # dispatch to install.ps1 on Windows or install.sh on macOS/Linux -``` - -Default builds require no product argument. Product-author and CI builds may -select one validated definition explicitly: - -```bash -pnpm run cli:build -- --product-config path/to/product.jsonc +bitfun update --check +bitfun update +bitfun doctor +bitfun health ``` -Standard Cargo `--target` triples are supported. The wrapper owns the final -target-directory layout; set `CARGO_TARGET_DIR` before invoking the build rather -than passing `--target-dir` or `--config build.target-dir=...`. +Official Linux archive installations perform a small, rate-limited update check +before interactive startup. Set `behavior.auto_update = false` in CLI config or +`BITFUN_CLI_DISABLE_AUTO_UPDATE=1` to disable it. Stable updates verify the +published checksum and, in official builds, the compiled release signing key +before replacing either entrypoint. -The current customization slice changes only the localized CLI name, binary -name, and build-output isolation. It does not customize installation, user-data -paths, layouts, themes, bundled plugins, updates, or signing; see the -[product customization architecture](../../../docs/architecture/product-customization-blueprint.md). +Use `doctor` for product/runtime assembly diagnostics and `health` for required +capability registration. They do not claim that external Network, Git, or MCP +services are currently reachable. diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs index 8f5e8db9e4..5ea308e226 100644 --- a/src/apps/cli/src/agent/runtime_client.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -19,10 +19,11 @@ use bitfun_agent_runtime::sdk::{ AgentSessionListRequest, AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, AgentSessionRenameRequest, AgentSessionRestoreRequest, AgentSessionRevertRequest, AgentSessionRevertResult, AgentSessionUsageRequest, AgentTurnCancellationRequest, - AgentTurnSettlementRequest, AgentUserAnswersRequest, AgentWorkspaceReference, - AgentWorkspaceReferenceSearchRequest, AgentWorkspaceReferenceSearchResult, PermissionReply, - PermissionRequest, PermissionRequestEventReceiver, PortError, PortErrorKind, RuntimeError, - SessionTranscript, SessionTranscriptRequest, SessionUsageReport, WorkspaceDiffSnapshot, + AgentTurnSettlementRequest, AgentUserAnswersRequest, AgentUserShellCommandRequest, + AgentWorkspaceReference, AgentWorkspaceReferenceSearchRequest, + AgentWorkspaceReferenceSearchResult, PermissionReply, PermissionRequest, + PermissionRequestEventReceiver, PortError, PortErrorKind, RuntimeError, SessionTranscript, + SessionTranscriptRequest, SessionUsageReport, WorkspaceDiffSnapshot, }; use bitfun_agent_runtime_ipc::{ RuntimeIpcClient, RuntimeIpcClientError, RuntimeIpcClientEvent, RuntimeIpcErrorCode, @@ -1318,6 +1319,69 @@ impl CliAgentRuntimeClient { submission } + pub(crate) async fn run_user_shell_command( + &self, + command: String, + agent_type: &str, + ) -> Result { + let session_id = self.ensure_session(agent_type).await?; + let turn_id = uuid::Uuid::new_v4().to_string(); + let request = AgentUserShellCommandRequest { + session_id: session_id.clone(), + turn_id: turn_id.clone(), + command, + }; + *self.current_turn_id.lock().await = Some(turn_id.clone()); + + let submission: Result = async { + match &self.backend { + CliAgentRuntimeBackend::Embedded(runtime) => { + let accepted = match runtime.run_user_shell_command(request.clone()).await { + Ok(accepted) => accepted, + Err(error) if Self::is_session_not_found_error(&error) => { + tracing::warn!( + "Session missing when starting Shell turn, attempting recovery and retry: session_id={}", + session_id + ); + self.ensure_backend_session_alive(&session_id, agent_type) + .await?; + runtime + .run_user_shell_command(request) + .await + .map_err(|error| anyhow::anyhow!(error.into_message()))? + } + Err(error) => return Err(anyhow::anyhow!(error.into_message())), + }; + if accepted.session_id == session_id && accepted.turn_id == turn_id { + Ok(accepted.turn_id) + } else { + Err(anyhow::anyhow!( + "Runtime accepted a Shell command with an unexpected identity" + )) + } + } + CliAgentRuntimeBackend::Shared(client) => match client + .request(RuntimeIpcOperation::RunUserShellCommand { request }) + .await + { + Ok(RuntimeIpcOperationResult::TurnAccepted { + session_id: accepted_session, + turn_id: accepted_turn, + }) if accepted_session == session_id && accepted_turn == turn_id => { + Ok(accepted_turn) + } + Ok(_) => Err(unexpected_shared_result("run_user_shell_command")), + Err(error) => Err(anyhow::Error::new(error)), + }, + } + } + .await; + if submission.is_err() { + *self.current_turn_id.lock().await = None; + } + submission + } + pub(crate) async fn search_workspace_references( &self, query: String, diff --git a/src/apps/cli/src/modes/chat/commands.rs b/src/apps/cli/src/modes/chat/commands.rs index 60eb3cdf79..13047de375 100644 --- a/src/apps/cli/src/modes/chat/commands.rs +++ b/src/apps/cli/src/modes/chat/commands.rs @@ -1548,6 +1548,7 @@ impl ChatMode { chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) -> Result> { + let shell_mode = chat_view.is_shell_mode(); let draft_has_images = chat_view.draft_snapshot().has_images(); if draft_has_images && chat_view.command_menu_visible() { chat_view.set_status(Some(IMAGE_ATTACHMENTS_REQUIRE_MESSAGE.to_string())); @@ -1564,18 +1565,24 @@ impl ChatMode { } let trimmed = chat_view.input_text().trim(); - if draft_has_images && trimmed.starts_with('/') { + if shell_mode && draft_has_images { + chat_view.set_status(Some("Images are unavailable in Shell mode".to_string())); + return Ok(None); + } + if !shell_mode && draft_has_images && trimmed.starts_with('/') { chat_view.set_status(Some(IMAGE_ATTACHMENTS_REQUIRE_MESSAGE.to_string())); return Ok(None); } - if !trimmed.starts_with('/') { + if shell_mode || !trimmed.starts_with('/') { self.selected_native_command_once = None; } let pending_for_current_session = self .pending_session_operation .as_ref() .is_some_and(|pending| pending.session_id == chat_state.core_session_id); - if session_update_blocks_typed_submission(pending_for_current_session, trimmed) { + if (shell_mode && pending_for_current_session) + || session_update_blocks_typed_submission(pending_for_current_session, trimmed) + { chat_view.set_status(Some( "Waiting for the pending Session operation to finish before sending.".to_string(), )); @@ -1583,20 +1590,26 @@ impl ChatMode { } if chat_state.is_processing { - if trimmed.starts_with('/') { + if !shell_mode && trimmed.starts_with('/') { if let Some(input) = chat_view.send_input() { return self.handle_command(&input.text, chat_view, chat_state, rt_handle); } } else if !trimmed.is_empty() { - chat_view.set_status(Some( + chat_view.set_status(Some(if shell_mode { + "Currently processing. Wait for the turn to finish or interrupt it.".to_string() + } else { "Currently processing. Type a /command, or use the interrupt shortcut." - .to_string(), - )); + .to_string() + })); } return Ok(None); } if let Some(input) = chat_view.send_input() { + if shell_mode { + self.send_shell_command(input, chat_view, chat_state, rt_handle); + return Ok(None); + } tracing::info!("User input: {}", input.text); if input.text.starts_with('/') { return self.handle_command(&input.text, chat_view, chat_state, rt_handle); @@ -1606,6 +1619,40 @@ impl ChatMode { Ok(None) } + fn send_shell_command( + &mut self, + draft: crate::ui::composer::ComposerDraft, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + if let Err(error) = self.materialize_requested_worktree(chat_view, chat_state, rt_handle) { + tracing::error!("Failed to prepare worktree for Shell command: {error}"); + chat_view.set_status(Some(format!("Error: {error}"))); + chat_state.add_system_message(error); + chat_view.set_draft(draft); + return; + } + + chat_view.set_status(Some("Running Shell command...".to_string())); + let agent = self.agent.clone(); + let agent_type = self.agent_type.clone(); + match tokio::task::block_in_place(|| { + rt_handle.block_on(agent.run_user_shell_command(draft.text.clone(), &agent_type)) + }) { + Ok(turn_id) => { + tracing::info!("Started Shell turn: {}", turn_id); + chat_view.remember_submitted_shell_command(&chat_state.core_session_id, &draft); + chat_view.exit_shell_mode(); + } + Err(error) => { + tracing::error!("Failed to start Shell command: {error}"); + chat_view.set_status(Some(format!("Error: {error}"))); + chat_view.set_draft(draft); + } + } + } + fn cancel_active_turn( &self, chat_view: &mut ChatView, @@ -1649,6 +1696,10 @@ impl ChatMode { fn apply_composer_paste(&mut self, paste: ImagePaste, chat_view: &mut ChatView) { match paste { ImagePaste::Text(text) => chat_view.insert_paste(&text), + ImagePaste::Image(_) if chat_view.is_shell_mode() => { + chat_view.set_status(Some("Images are unavailable in Shell mode".to_string())); + return; + } ImagePaste::Image(_image) if self.agent.is_shared() => { chat_view.set_status(Some(crate::actions::shared_tui_image_attachment_error())); return; diff --git a/src/apps/cli/src/modes/chat/input.rs b/src/apps/cli/src/modes/chat/input.rs index 6239042e7d..3c0d8f68da 100644 --- a/src/apps/cli/src/modes/chat/input.rs +++ b/src/apps/cli/src/modes/chat/input.rs @@ -460,6 +460,22 @@ impl ChatMode { } } + if chat_view.is_shell_mode() { + match key.code { + KeyCode::Esc => { + chat_view.exit_shell_mode(); + self.selected_native_command_once = None; + return Ok(None); + } + KeyCode::Backspace if chat_view.input_text().is_empty() => { + chat_view.exit_shell_mode(); + self.selected_native_command_once = None; + return Ok(None); + } + _ => {} + } + } + if let Some(action) = self .keymap .resolve(key, self.action_state(chat_state.is_processing, false)) @@ -505,6 +521,12 @@ impl ChatMode { } } + (KeyCode::Char('!'), KeyModifiers::NONE | KeyModifiers::SHIFT) + if !chat_state.is_processing && chat_view.try_enter_shell_mode() => + { + self.selected_native_command_once = None; + } + (KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) if !c.is_control() && c != '\u{0}' => { diff --git a/src/apps/cli/src/shared_runtime.rs b/src/apps/cli/src/shared_runtime.rs index c43e54793a..65b2b18ba5 100644 --- a/src/apps/cli/src/shared_runtime.rs +++ b/src/apps/cli/src/shared_runtime.rs @@ -405,6 +405,15 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { turn_id, }) } + RuntimeIpcOperation::RunUserShellCommand { request } => self + .runtime + .run_user_shell_command(request) + .await + .map(|result| RuntimeIpcOperationResult::TurnAccepted { + session_id: result.session_id, + turn_id: result.turn_id, + }) + .map_err(runtime_ipc_error), RuntimeIpcOperation::CancelTurn { request } => self .runtime .cancel_turn(request) diff --git a/src/apps/cli/src/ui/chat/input.rs b/src/apps/cli/src/ui/chat/input.rs index f70cd486e0..08870fa4ab 100644 --- a/src/apps/cli/src/ui/chat/input.rs +++ b/src/apps/cli/src/ui/chat/input.rs @@ -3,13 +3,19 @@ const MAX_LOCAL_DRAFT_HISTORY_IMAGE_BYTES: usize = 200 * 1024 * 1024; impl SubmittedDraftHistory { fn record(&mut self, session_id: &str, draft: ComposerDraft) { + self.record_with_mode(session_id, draft, ComposerMode::Chat); + } + + fn record_with_mode(&mut self, session_id: &str, draft: ComposerDraft, mode: ComposerMode) { let sequence = self.next_sequence; self.next_sequence = self.next_sequence.wrapping_add(1); let history = self.sessions.entry(session_id.to_string()).or_default(); history.undone.clear(); - history - .active - .push(SubmittedDraftRecord { sequence, draft }); + history.active.push(SubmittedDraftRecord { + sequence, + draft, + mode, + }); while self.record_count() > MAX_LOCAL_DRAFT_HISTORY { if !self.remove_oldest_record() { break; @@ -17,17 +23,33 @@ impl SubmittedDraftHistory { } } + #[cfg(test)] fn undo(&mut self, session_id: &str, text: &str) -> Option { + self.undo_with_mode(session_id, text) + .map(|(draft, _)| draft) + } + + fn undo_with_mode( + &mut self, + session_id: &str, + text: &str, + ) -> Option<(ComposerDraft, ComposerMode)> { let history = self.sessions.get_mut(session_id)?; if history .active .last() - .is_some_and(|record| record.draft.text == text) + .is_some_and(|record| match record.mode { + ComposerMode::Chat => record.draft.text == text, + ComposerMode::Shell => text + .strip_prefix('!') + .is_some_and(|command| record.draft.text == command), + }) { let record = history.active.pop()?; let draft = record.draft.clone(); + let mode = record.mode; history.undone.push(record); - Some(draft) + Some((draft, mode)) } else { // Runtime state is authoritative. If its reverted text no longer matches the // local success stack, fail closed for this Session instead of guessing by text. @@ -152,8 +174,42 @@ impl ChatView { } fn refresh_command_menu(&mut self) { - self.command_menu - .update(&self.text_input.input, self.text_input.cursor); + if self.is_shell_mode() { + self.command_menu.update("", 0); + } else { + self.command_menu + .update(&self.text_input.input, self.text_input.cursor); + } + } + + pub(crate) fn is_shell_mode(&self) -> bool { + self.composer_mode == ComposerMode::Shell + } + + pub(crate) fn try_enter_shell_mode(&mut self) -> bool { + if self.is_shell_mode() + || !self.text_input.text().is_empty() + || !self.workspace_references.is_empty() + || !self.image_attachments.is_empty() + { + return false; + } + self.composer_mode = ComposerMode::Shell; + self.history_index = None; + self.workspace_reference_popup.hide(); + self.refresh_command_menu(); + true + } + + pub(crate) fn exit_shell_mode(&mut self) -> bool { + if !self.is_shell_mode() { + return false; + } + self.composer_mode = ComposerMode::Chat; + self.history_index = None; + self.workspace_reference_popup.hide(); + self.refresh_command_menu(); + true } pub(crate) fn set_external_source_state( @@ -180,9 +236,14 @@ impl ChatView { image_attachments: std::mem::take(&mut self.image_attachments), }; - self.input_history.push_front(draft.clone()); - if self.input_history.len() > MAX_LOCAL_DRAFT_HISTORY { - self.input_history.pop_back(); + let history = if self.is_shell_mode() { + &mut self.shell_input_history + } else { + &mut self.input_history + }; + history.push_front(draft.clone()); + if history.len() > MAX_LOCAL_DRAFT_HISTORY { + history.pop_back(); } self.enforce_draft_history_image_budget(); self.history_index = None; @@ -216,6 +277,9 @@ impl ChatView { &mut self, image: super::composer::ComposerImage, ) -> Result<(), super::composer::ComposerImageInsertError> { + if self.is_shell_mode() { + return Err(super::composer::ComposerImageInsertError::ShellModeUnsupported); + } let mut draft = self.draft_snapshot(); let cursor = draft.safe_insertion_cursor(self.text_input.cursor); let cursor = draft.insert_image(cursor, image)?; @@ -295,6 +359,7 @@ impl ChatView { /// Set input text programmatically (e.g. from skill selection) pub(crate) fn set_input(&mut self, text: &str) { + self.composer_mode = ComposerMode::Chat; self.text_input.set_text(text); self.workspace_references.clear(); self.image_attachments.clear(); @@ -317,10 +382,10 @@ impl ChatView { text: String, workspace_references: Vec, ) -> ComposerDraft { - let mut draft = self - .submitted_drafts - .undo(session_id, &text) - .unwrap_or_else(|| ComposerDraft::from_text(text)); + let restored = self.submitted_drafts.undo_with_mode(session_id, &text); + let (mut draft, mode) = + restored.unwrap_or_else(|| (ComposerDraft::from_text(text), ComposerMode::Chat)); + self.composer_mode = mode; draft.workspace_references = workspace_references; draft.retain_valid_sources(); draft @@ -331,6 +396,16 @@ impl ChatView { self.enforce_draft_history_image_budget(); } + pub(crate) fn remember_submitted_shell_command( + &mut self, + session_id: &str, + draft: &ComposerDraft, + ) { + self.submitted_drafts + .record_with_mode(session_id, draft.clone(), ComposerMode::Shell); + self.enforce_draft_history_image_budget(); + } + pub(crate) fn note_session_redo(&mut self, session_id: &str) { self.submitted_drafts.redo(session_id); self.enforce_draft_history_image_budget(); @@ -364,6 +439,9 @@ impl ChatView { } pub(crate) fn current_workspace_reference_query(&self) -> Option { + if self.is_shell_mode() { + return None; + } super::workspace_reference::workspace_reference_query( &self.text_input.input, self.text_input.cursor, @@ -506,17 +584,22 @@ impl ChatView { } pub(crate) fn history_prev(&mut self) { - if self.input_history.is_empty() { + let history = if self.is_shell_mode() { + &self.shell_input_history + } else { + &self.input_history + }; + if history.is_empty() { return; } let new_index = match self.history_index { None => 0, - Some(i) if i + 1 < self.input_history.len() => i + 1, + Some(i) if i + 1 < history.len() => i + 1, Some(i) => i, }; - if let Some(history_item) = self.input_history.get(new_index) { + if let Some(history_item) = history.get(new_index) { self.text_input.set_text(&history_item.text); self.workspace_references = history_item.workspace_references.clone(); self.image_attachments = history_item.image_attachments.clone(); @@ -526,6 +609,11 @@ impl ChatView { } pub(crate) fn history_next(&mut self) { + let history = if self.is_shell_mode() { + &self.shell_input_history + } else { + &self.input_history + }; match self.history_index { None => {} Some(0) => { @@ -537,7 +625,7 @@ impl ChatView { } Some(i) => { let new_index = i - 1; - if let Some(history_item) = self.input_history.get(new_index) { + if let Some(history_item) = history.get(new_index) { self.text_input.set_text(&history_item.text); self.workspace_references = history_item.workspace_references.clone(); self.image_attachments = history_item.image_attachments.clone(); @@ -563,6 +651,72 @@ mod composer_input_tests { ) } + #[test] + fn shell_mode_enters_only_from_an_empty_composer_and_keeps_the_marker_out_of_input() { + let mut view = ChatView::new(Theme::dark(), Vec::new()); + + assert!(view.try_enter_shell_mode()); + assert!(view.is_shell_mode()); + assert_eq!(view.input_text(), ""); + assert!(!view.command_menu_visible()); + + view.handle_char('/'); + let keymap = + crate::actions::ResolvedKeymap::new(&crate::config::ShortcutsConfig::default()); + view.set_action_state(crate::actions::ActionState::chat(true, false), &keymap); + assert!(!view.command_menu_visible()); + + view.clear_input(); + view.exit_shell_mode(); + view.handle_char('x'); + assert!(!view.try_enter_shell_mode()); + assert!(!view.is_shell_mode()); + assert_eq!(view.input_text(), "x"); + } + + #[test] + fn programmatic_chat_prefill_exits_shell_mode() { + let mut view = ChatView::new(Theme::dark(), Vec::new()); + assert!(view.try_enter_shell_mode()); + + view.set_input("/rename "); + + assert!(!view.is_shell_mode()); + assert_eq!(view.input_text(), "/rename "); + } + + #[test] + fn shell_mode_uses_separate_history_and_restores_shell_undo_identity() { + let mut view = ChatView::new(Theme::dark(), Vec::new()); + view.handle_char('n'); + view.handle_char('o'); + view.handle_char('r'); + view.handle_char('m'); + view.handle_char('a'); + view.handle_char('l'); + assert_eq!(view.send_input().unwrap().text, "normal"); + + assert!(view.try_enter_shell_mode()); + view.insert_paste("git status --short"); + let shell = view.send_input().unwrap(); + view.remember_submitted_shell_command("session-1", &shell); + view.exit_shell_mode(); + + assert!(view.try_enter_shell_mode()); + view.history_prev(); + assert_eq!(view.input_text(), "git status --short"); + + view.exit_shell_mode(); + view.clear_input(); + view.history_prev(); + assert_eq!(view.input_text(), "normal"); + + let restored = + view.restore_undo_draft("session-1", "!git status --short".to_string(), Vec::new()); + assert!(view.is_shell_mode()); + assert_eq!(restored.text, "git status --short"); + } + #[test] fn local_input_history_restores_image_bytes_without_copying_file_paths() { let mut view = ChatView::new(Theme::dark(), Vec::new()); diff --git a/src/apps/cli/src/ui/chat/render.rs b/src/apps/cli/src/ui/chat/render.rs index 0b070bb401..fb61bd27f3 100644 --- a/src/apps/cli/src/ui/chat/render.rs +++ b/src/apps/cli/src/ui/chat/render.rs @@ -822,10 +822,11 @@ impl ChatView { fn render_input(&mut self, frame: &mut Frame, area: Rect, chat_state: &ChatState) { use super::text_input::TextInputStyle; + let shell_mode = self.is_shell_mode(); let block = Block::default() .borders(Borders::ALL) .border_style(self.theme.style(StyleKind::Primary)) - .title(" Input "); + .title(if shell_mode { " SHELL " } else { " Input " }); let inner = block.inner(area); @@ -833,9 +834,13 @@ impl ChatView { frame.render_widget(block, area); let style = TextInputStyle { - first_line_prefix: "> ", + first_line_prefix: if shell_mode { "! " } else { "> " }, continuation_prefix: " ", - placeholder: "Enter message...".to_string(), + placeholder: if shell_mode { + "Enter shell command...".to_string() + } else { + "Enter message...".to_string() + }, text_style: ratatui::style::Style::default(), placeholder_style: self.theme.style(StyleKind::Muted), }; diff --git a/src/apps/cli/src/ui/chat/state.rs b/src/apps/cli/src/ui/chat/state.rs index 928d824cad..15c8841ac3 100644 --- a/src/apps/cli/src/ui/chat/state.rs +++ b/src/apps/cli/src/ui/chat/state.rs @@ -40,6 +40,14 @@ use crate::chat_state::{ChatMessage, ChatState, FlowItem, MessageRole}; struct SubmittedDraftRecord { sequence: u64, draft: ComposerDraft, + mode: ComposerMode, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) enum ComposerMode { + #[default] + Chat, + Shell, } #[derive(Debug, Default)] @@ -197,6 +205,9 @@ pub(crate) struct ChatView { status: Option, /// Input history (for up/down arrows) input_history: VecDeque, + /// Shell command history is intentionally isolated from chat prompts. + shell_input_history: VecDeque, + composer_mode: ComposerMode, /// Drafts accepted by the Runtime, isolated by Session for local undo/redo identity. submitted_drafts: SubmittedDraftHistory, workspace_references: Vec, @@ -332,6 +343,8 @@ impl ChatView { auto_scroll: true, status: None, input_history: VecDeque::with_capacity(50), + shell_input_history: VecDeque::with_capacity(50), + composer_mode: ComposerMode::Chat, submitted_drafts: SubmittedDraftHistory::default(), workspace_references: Vec::new(), image_attachments: Vec::new(), @@ -394,8 +407,7 @@ impl ChatView { .set_mode_switch_allowed(!state.is_processing); self.command_palette.set_action_state(state); if self.command_menu.set_action_state(state) { - self.command_menu - .update(&self.text_input.input, self.text_input.cursor); + self.refresh_command_menu(); } } diff --git a/src/apps/cli/src/ui/composer.rs b/src/apps/cli/src/ui/composer.rs index 08de030430..204af2f6d6 100644 --- a/src/apps/cli/src/ui/composer.rs +++ b/src/apps/cli/src/ui/composer.rs @@ -92,6 +92,8 @@ pub(crate) struct ExternalDraftReconcileOutcome { pub(crate) enum ComposerImageInsertError { #[error("A message can contain at most {MAX_COMPOSER_IMAGES} images")] TooManyImages, + #[error("Images are unavailable in Shell mode")] + ShellModeUnsupported, } impl ComposerDraft { diff --git a/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md b/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md index 9cb0d3642d..fe35071ef8 100644 --- a/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md +++ b/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md @@ -15,7 +15,7 @@ ## 边界 - 只导出 CLI adapter 实际使用的 workspace-private API,且 crate 不得发布,也不得把 wire 作为 SDK 合同。 -- 封闭 operation 范围为 Health、Session list/create/restore/delete/fork(restore/fork 结果包含 transcript)、当前 Session rename、Agent mode/model update、手动 context compaction、Session undo/redo、current-controller 限定的只读工作区引用搜索/持久化引用读取,以及不取得 Session lease 的 Runtime 绑定工作区只读 diff;此外还包括声明式上下文 reload、Turn submit/cancel、pending/respond Permission 和 UserInput answers。delete 只允许作用于未被任何 Client 控制的空闲 Session。fork 要求当前 controller 且 Session 空闲:可以复制到最新持久化 Turn,也可以停在显式选中 Turn 之前;只有包含新 Session 与 transcript 的成功结果完成编码后,Server 才能把连接 lease 从源 Session 原子切换到 fork。手动 compaction 要求当前 controller 且 Session 空闲;Client 在准入前提供精确 Turn ID,使超时或断连 cleanup 可以取消同一个 owned task;Core 开始原子 context commit 后,晚到取消不能暴露错误的空闲状态。undo/redo 要求当前 controller,但可在活动 Turn 中进入,因为取消、drain 与回退写入顺序由 Core 统一负责;成功结果携带权威 transcript,并清除连接侧活动 Turn 投影。该能力只支持本地工作区,不暴露通用 checkpoint 协议。上下文 reload 可在活动 Turn 中执行,不改写该 Turn,并通过缓存保护保证下一条消息重新读取已失效的 instructions。断连 cleanup 属于内部生命周期,不是 detach operation。模型目录和默认值仍是 wire 之外的产品配置;禁止顺带加入 archive、replay、observer、通用 controller transfer、Tool/MCP/Hook 管理或其他产品配置。 +- 封闭 operation 范围为 Health、Session list/create/restore/delete/fork(restore/fork 结果包含 transcript)、当前 Session rename、Agent mode/model update、手动 context compaction、Session undo/redo、current-controller 限定的只读工作区引用搜索/持久化引用读取,以及不取得 Session lease 的 Runtime 绑定工作区只读 diff;此外还包括声明式上下文 reload、Turn submit/用户显式 Shell 执行/cancel、pending/respond Permission 和 UserInput answers。delete 只允许作用于未被任何 Client 控制的空闲 Session。fork 要求当前 controller 且 Session 空闲:可以复制到最新持久化 Turn,也可以停在显式选中 Turn 之前;只有包含新 Session 与 transcript 的成功结果完成编码后,Server 才能把连接 lease 从源 Session 原子切换到 fork。手动 compaction 要求当前 controller 且 Session 空闲;Client 在准入前提供精确 Turn ID,使超时或断连 cleanup 可以取消同一个 owned task;Core 开始原子 context commit 后,晚到取消不能暴露错误的空闲状态。用户显式 Shell 执行同样要求当前 controller、Session 空闲和调用方提供的 Turn ID;它只委托给窄 Runtime port,并复用正常 ToolPipeline、权限、工作区路由、持久化与取消 owner,不是通用 Tool 或进程执行 wire。undo/redo 要求当前 controller,但可在活动 Turn 中进入,因为取消、drain 与回退写入顺序由 Core 统一负责;成功结果携带权威 transcript,并清除连接侧活动 Turn 投影。该能力只支持本地工作区,不暴露通用 checkpoint 协议。上下文 reload 可在活动 Turn 中执行,不改写该 Turn,并通过缓存保护保证下一条消息重新读取已失效的 instructions。断连 cleanup 属于内部生命周期,不是 detach operation。模型目录和默认值仍是 wire 之外的产品配置;禁止顺带加入 archive、replay、observer、通用 controller transfer、Tool/MCP/Hook 管理或其他产品配置。 - 可以复用稳定 Event、Product Domain 和 Runtime Port DTO。禁止依赖 `bitfun-core`、Agent Runtime 实现、SDK Host、services、Tauri、terminal、tool runtime 或远程 transport。 - 只使用 Windows Named Pipe 或 Unix Domain Socket;禁止 TCP、HTTP、WebSocket、浏览器访问或远程 fallback。 - 这是本机同用户隔离,不是沙箱。未来产品 composition 必须提供当前用户私有 runtime 目录。 diff --git a/src/crates/adapters/agent-runtime-ipc/AGENTS.md b/src/crates/adapters/agent-runtime-ipc/AGENTS.md index 8749773ffe..b55d5e5047 100644 --- a/src/crates/adapters/agent-runtime-ipc/AGENTS.md +++ b/src/crates/adapters/agent-runtime-ipc/AGENTS.md @@ -23,9 +23,10 @@ session controller leases, event delivery, connection bounds, and cleanup. It is - Export only the exact workspace-private API needed by the CLI adapter. Do not publish this crate or expose its wire as an SDK contract. - The closed operation budget is Health, Session list/create/restore/delete/fork (including transcript on restore/fork), current-Session rename, Agent mode/model update, manual context compaction, Session undo/redo, current-controller read-only workspace-reference search/persisted-reference lookup, and a read-only diff of the Runtime-bound workspace that does not acquire a Session lease, - declarative context reload, Turn submit/cancel, pending/respond Permission, and UserInput answers. Delete is limited to an idle Session not controlled by any client. + declarative context reload, Turn submit/user-authored Shell execution/cancel, pending/respond Permission, and UserInput answers. Delete is limited to an idle Session not controlled by any client. Fork is a current-controller, idle-only operation. It either copies through the latest persisted Turn or stops immediately before an explicitly selected Turn. The encoded success result carries the authoritative new Session and transcript; only then may the server atomically switch the connection lease from the source Session to the fork. Manual compaction is a current-controller, idle-only Turn operation. The client supplies its exact Turn ID before admission so timeout or disconnect cleanup can cancel the same owned task; once Core begins the atomic context commit, a late cancellation does not expose a false idle state. + User-authored Shell execution is a current-controller, idle-only Turn operation with a caller-supplied Turn ID. It delegates to the narrow Runtime port and normal ToolPipeline, permission, workspace-routing, persistence, and cancellation owners; it is not a generic Tool or process-execution wire. Context reload may run during an active Turn, does not rewrite that Turn, and guards the cache so the next message reads invalidated instructions. Undo/redo is a current-controller operation that may enter during an active Turn because Core owns cancel-and-drain before mutation. Its success response carries the authoritative transcript and clears the connection's active-Turn projection. It is local-workspace only and does not expose a generic checkpoint protocol. Disconnect cleanup is internal lifecycle, not a detach operation. diff --git a/src/crates/adapters/agent-runtime-ipc/src/operation.rs b/src/crates/adapters/agent-runtime-ipc/src/operation.rs index d66d115228..60068f0ec6 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/operation.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/operation.rs @@ -4,9 +4,9 @@ use bitfun_runtime_ports::{ AgentSessionCompactionRequest, AgentSessionCreateRequest, AgentSessionCreateResult, AgentSessionListRequest, AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, AgentSessionRevertRequest, AgentSessionRevertResult, AgentSessionSummary, - AgentTurnCancellationRequest, AgentTurnCancellationResult, AgentWorkspaceReference, - AgentWorkspaceReferenceSearchRequest, AgentWorkspaceReferenceSearchResult, SessionTranscript, - WorkspaceDiffSnapshot, + AgentTurnCancellationRequest, AgentTurnCancellationResult, AgentUserShellCommandRequest, + AgentWorkspaceReference, AgentWorkspaceReferenceSearchRequest, + AgentWorkspaceReferenceSearchResult, SessionTranscript, WorkspaceDiffSnapshot, }; use serde::{Deserialize, Serialize}; @@ -97,6 +97,9 @@ pub enum RuntimeIpcOperation { SubmitTurn { request: AgentDialogTurnRequest, }, + RunUserShellCommand { + request: AgentUserShellCommandRequest, + }, CancelTurn { request: AgentTurnCancellationRequest, }, @@ -129,6 +132,7 @@ impl RuntimeIpcOperation { Self::SearchWorkspaceReferences { request } => Some(&request.session_id), Self::WorkspaceReferencesForMessage { request } => Some(&request.session_id), Self::SubmitTurn { request } => Some(&request.session_id), + Self::RunUserShellCommand { request } => Some(&request.session_id), Self::CancelTurn { request } => Some(&request.session_id), Self::PendingPermissions { session_id } | Self::RespondPermission { session_id, .. } => Some(session_id), @@ -161,7 +165,8 @@ impl RuntimeIpcOperation { | Self::UpdateSessionModel { .. } | Self::RenameSession { .. } | Self::CompactSession { .. } - | Self::SubmitTurn { .. } => { + | Self::SubmitTurn { .. } + | Self::RunUserShellCommand { .. } => { RuntimeIpcOperationRules::new(CurrentController, true, false, true) } Self::ForkSession { .. } => { diff --git a/src/crates/adapters/agent-runtime-ipc/src/protocol.rs b/src/crates/adapters/agent-runtime-ipc/src/protocol.rs index 8134b8905c..140da42209 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/protocol.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/protocol.rs @@ -5,7 +5,7 @@ use crate::{RuntimeIpcOperation, RuntimeIpcOperationResult}; use bitfun_events::AgenticEventEnvelope; use bitfun_product_domains::tool_permissions::PermissionRequestEvent; -pub const PROTOCOL_VERSION: u32 = 11; +pub const PROTOCOL_VERSION: u32 = 12; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] diff --git a/src/crates/adapters/agent-runtime-ipc/src/server.rs b/src/crates/adapters/agent-runtime-ipc/src/server.rs index 25bc0f357f..4a1183e447 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/server.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/server.rs @@ -463,6 +463,11 @@ async fn run_initialized_connection( *active_turn_id = Some(turn_id.clone()); Some(turn_id) } + RuntimeIpcOperation::RunUserShellCommand { request } => { + let turn_id = request.turn_id.clone(); + *active_turn_id = Some(turn_id.clone()); + Some(turn_id) + } RuntimeIpcOperation::CompactSession { request } => { let turn_id = request.turn_id.clone(); *active_turn_id = Some(turn_id.clone()); diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs index 93146b4ddc..f068440ddc 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs @@ -108,7 +108,7 @@ fn protocol_round_trips_read_only_workspace_reference_operations() { #[test] fn protocol_round_trips_workspace_diff_as_a_read_only_workspace_operation() { - assert_eq!(PROTOCOL_VERSION, 11); + assert_eq!(PROTOCOL_VERSION, 12); let operation = RuntimeIpcOperation::WorkspaceDiff; let encoded = serde_json::to_value(&operation).expect("serialize workspace diff operation"); @@ -150,6 +150,35 @@ fn protocol_round_trips_workspace_diff_as_a_read_only_workspace_operation() { assert_eq!(decoded, result); } +#[test] +fn protocol_round_trips_user_shell_as_an_idle_controller_turn() { + let operation = RuntimeIpcOperation::RunUserShellCommand { + request: bitfun_runtime_ports::AgentUserShellCommandRequest { + session_id: "session-1".to_string(), + turn_id: "turn-shell".to_string(), + command: "git status --short".to_string(), + }, + }; + + let encoded = serde_json::to_value(&operation).expect("serialize user shell operation"); + assert_eq!(encoded["operation"], "run_user_shell_command"); + assert_eq!(encoded["request"]["sessionId"], "session-1"); + assert_eq!(encoded["request"]["turnId"], "turn-shell"); + assert_eq!(encoded["request"]["command"], "git status --short"); + let decoded: RuntimeIpcOperation = + serde_json::from_value(encoded).expect("deserialize user shell operation"); + assert_eq!(decoded, operation); + assert_eq!(decoded.session_id(), Some("session-1")); + let rules = decoded.rules(); + assert_eq!( + rules.session_requirement, + RuntimeIpcSessionRequirement::CurrentController + ); + assert!(rules.requires_idle); + assert!(!rules.serializes_session_selection); + assert!(rules.side_effecting); +} + #[test] fn protocol_round_trips_the_reviewed_session_mode_operation() { let operation = RuntimeIpcOperation::UpdateSessionMode { @@ -200,7 +229,7 @@ fn protocol_round_trips_the_reviewed_session_model_operation() { #[test] fn protocol_round_trips_the_current_session_rename_operation() { - assert_eq!(PROTOCOL_VERSION, 11); + assert_eq!(PROTOCOL_VERSION, 12); let operation = RuntimeIpcOperation::RenameSession { request: RuntimeSessionRenameRequest { diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs index 60278e3da0..9c92ec9e98 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs @@ -11,7 +11,8 @@ use bitfun_runtime_ports::{ AgentDialogTurnRequest, AgentSessionCompactionRequest, AgentSessionComposerUpdate, AgentSessionCreateRequest, AgentSessionCreateResult, AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, AgentSessionRevertRequest, AgentSessionRevertResult, - AgentSessionSummary, AgentSubmissionSource, DialogSubmissionPolicy, SessionTranscript, + AgentSessionSummary, AgentSubmissionSource, AgentUserShellCommandRequest, + DialogSubmissionPolicy, SessionTranscript, }; use serde_json::Map; use std::path::Path; @@ -222,6 +223,15 @@ impl RuntimeIpcRequestHandler for FakeHandler { turn_id: request.turn_id.expect("test turn id"), }) } + RuntimeIpcOperation::RunUserShellCommand { request } => { + if let Some(delay) = self.submit_delay { + tokio::time::sleep(delay).await; + } + Ok(RuntimeIpcOperationResult::TurnAccepted { + session_id: request.session_id, + turn_id: request.turn_id, + }) + } RuntimeIpcOperation::CompactSession { request } => { Ok(RuntimeIpcOperationResult::TurnAccepted { session_id: request.session_id, @@ -546,6 +556,16 @@ fn submit_operation(workspace: &Path, session_id: &str, turn_id: &str) -> Runtim } } +fn shell_operation(session_id: &str, turn_id: &str) -> RuntimeIpcOperation { + RuntimeIpcOperation::RunUserShellCommand { + request: AgentUserShellCommandRequest { + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + command: "git status --short".to_string(), + }, + } +} + fn compact_operation(session_id: &str, turn_id: &str) -> RuntimeIpcOperation { RuntimeIpcOperation::CompactSession { request: AgentSessionCompactionRequest { @@ -1371,6 +1391,54 @@ async fn timed_out_submit_closes_and_cancels_its_provisional_turn() { server.finish().await; } +#[tokio::test] +async fn timed_out_shell_command_closes_and_cancels_its_provisional_turn() { + let handler = Arc::new(FakeHandler { + submit_delay: Some(Duration::from_millis(100)), + ..FakeHandler::default() + }); + let mut config = server_config(); + config.request_timeout = Duration::from_millis(20); + let server = TestServer::start(config, handler.clone()).await; + let mut first = server.connect("shell-controller").await; + expect_response( + &mut first, + 2, + restore_operation(server.workspace.path(), "session-a"), + ) + .await; + expect_error( + &mut first, + 3, + shell_operation("session-a", "turn-shell-a"), + RuntimeIpcErrorCode::OutcomeUnknown, + ) + .await; + + wait_for_calls(&handler, |calls| { + calls.iter().any(|call| { + matches!( + call, + RuntimeIpcOperation::CancelTurn { request } + if request.session_id == "session-a" + && request.turn_id.as_deref() == Some("turn-shell-a") + ) + }) + }) + .await; + + let mut second = server.connect("shell-successor").await; + expect_response( + &mut second, + 2, + restore_operation(server.workspace.path(), "session-a"), + ) + .await; + drop(first); + drop(second); + server.finish().await; +} + #[tokio::test] async fn unsettled_disconnect_cancellation_quarantines_the_session_lease() { let handler = Arc::new(FakeHandler { diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index c448632d18..29cc169c43 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -35,6 +35,7 @@ use crate::agentic::goal_mode::{ }; use crate::agentic::image_analysis::ImageContextData; use crate::agentic::memories::{start_memory_startup_task, MemoryStartupRequest}; +use crate::agentic::permission_policy::resolve_effective_permission_policy; use crate::agentic::round_preempt::DialogRoundInjectionSource; use crate::agentic::session::revert::{ resolve_redo, resolve_undo, SessionRevertPhase, SessionRevertTransition, @@ -45,7 +46,9 @@ use crate::agentic::side_question::build_btw_user_input; use crate::agentic::skill_agent_snapshot::{ diff_skill_agent_snapshot, resolve_skill_agent_snapshot, TurnSkillAgentSnapshot, }; -use crate::agentic::tools::pipeline::{SubagentParentInfo, ToolPipeline}; +use crate::agentic::tools::pipeline::{ + PrimaryModelFacts, SubagentParentInfo, ToolExecutionContext, ToolExecutionOptions, ToolPipeline, +}; use crate::agentic::tools::{ miniapp_agent_run_tool_restrictions, tool_restrictions_for_delegation_policy as runtime_tool_restrictions_for_delegation_policy, @@ -59,6 +62,9 @@ use crate::service::bootstrap::{ ensure_workspace_persona_files_for_prompt, is_workspace_bootstrap_pending, }; use crate::service::config::global::GlobalConfigManager; +use crate::service::config::project_permission_store::{ + load_project_permission_config_local, load_project_permission_config_remote, +}; use crate::service::config::types::{model_runtime_binding_fingerprint, AIConfig}; use crate::service::config::{ get_global_config_service, AgentModelDefaultsConfig, SubagentModelSelection, @@ -104,7 +110,7 @@ use bitfun_services_core::workspace_text::{ }; use dashmap::DashMap; use log::{debug, error, info, warn}; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; @@ -124,6 +130,8 @@ const MAX_SESSION_REFERENCES_PER_TURN: usize = 5; const SESSION_REFERENCE_ARTIFACT_STEM_LENGTH: usize = 8; const SESSION_REFERENCE_ARTIFACT_STEM_EXTENSION_LENGTH: usize = 4; const SESSION_REFERENCE_NAME_CHAR_LIMIT: usize = 96; +const USER_SHELL_COMMAND_MAX_BYTES: usize = 64 * 1024; +const USER_SHELL_TOOL_NAME: &str = "ExecCommand"; fn comparable_workspace_path(path: &str) -> String { let path = path.trim(); @@ -11269,6 +11277,441 @@ impl bitfun_runtime_ports::AgentLocalCommandTurnPort for ConversationCoordinator } } +fn validate_user_shell_command_request( + request: &bitfun_runtime_ports::AgentUserShellCommandRequest, +) -> BitFunResult<()> { + bitfun_core_types::validate_session_id(&request.session_id).map_err(BitFunError::Validation)?; + bitfun_core_types::validate_session_id(&request.turn_id) + .map_err(|message| BitFunError::Validation(format!("Invalid turn_id: {message}")))?; + if request.command.trim().is_empty() { + return Err(BitFunError::Validation( + "Shell command must not be empty".to_string(), + )); + } + if request.command.contains('\0') { + return Err(BitFunError::Validation( + "Shell command must not contain NUL characters".to_string(), + )); + } + if request.command.len() > USER_SHELL_COMMAND_MAX_BYTES { + return Err(BitFunError::Validation(format!( + "Shell command exceeds the {USER_SHELL_COMMAND_MAX_BYTES}-byte limit" + ))); + } + Ok(()) +} + +fn user_shell_tool_result_succeeded( + result: &crate::agentic::tools::pipeline::ToolExecutionResult, +) -> bool { + if result.result.is_error { + return false; + } + + if matches!( + result + .result + .result + .get("category") + .and_then(serde_json::Value::as_str), + Some("permission_denied" | "user_rejected" | "cancelled") + ) { + return false; + } + + result + .result + .result + .get("exit_code") + .and_then(serde_json::Value::as_i64) + .is_none_or(|exit_code| exit_code == 0) +} + +impl ConversationCoordinator { + async fn user_shell_tool_options( + agent_type: &str, + workspace: &Option, + workspace_services: &Option, + ) -> BitFunResult { + let global_config: crate::service::config::types::GlobalConfig = + match GlobalConfigManager::get_service().await { + Ok(service) => service.get_config(None).await.unwrap_or_default(), + Err(_) => Default::default(), + }; + let project_rules = match workspace.as_ref() { + Some(workspace) if workspace.is_remote() => { + let services = workspace_services.as_ref().ok_or_else(|| { + BitFunError::service( + "Remote workspace services are unavailable for a shell command".to_string(), + ) + })?; + load_project_permission_config_remote( + services.fs.as_ref(), + &workspace.root_path_string(), + ) + .await? + .rules + } + Some(workspace) => { + load_project_permission_config_local(workspace.root_path()) + .await? + .rules + } + None => Vec::new(), + }; + let profile_id = crate::agentic::agents::resolve_mode_config_profile_id(agent_type); + let agent_profile = global_config.ai.agent_profiles.get(profile_id.as_ref()); + let permission_policy = resolve_effective_permission_policy( + &global_config, + &project_rules, + agent_profile, + None, + None, + &[], + ); + + Ok(ToolExecutionOptions { + allow_parallel: false, + timeout_secs: global_config.ai.tool_execution_timeout_secs, + permission_policy, + // This is an explicit user-authored command. Automatically answer + // only interactive `ask`; ToolPipeline still enforces every deny. + auto_approve_ask: true, + ..ToolExecutionOptions::default() + }) + } + + async fn execute_user_shell_pipeline( + tool_pipeline: &ToolPipeline, + tool_call: ToolCall, + context: ToolExecutionContext, + options: ToolExecutionOptions, + ) -> BitFunResult> { + tool_pipeline + .execute_tools(vec![tool_call], context, options) + .await + } + + #[allow(clippy::too_many_arguments)] + async fn execute_user_shell_command_task( + session_manager: Arc, + execution_engine: Arc, + tool_pipeline: Arc, + event_queue: Arc, + session: Session, + workspace: Option, + workspace_services: Option, + terminal_port: Option>, + remote_exec_port: Option>, + options: ToolExecutionOptions, + session_id: String, + turn_id: String, + command: String, + cancellation_token: CancellationToken, + ) { + let started_at = Instant::now(); + let round_id = format!("{turn_id}-shell-round"); + let tool_id = format!("{turn_id}-shell-command"); + let tool_call = ToolCall { + tool_id: tool_id.clone(), + tool_name: USER_SHELL_TOOL_NAME.to_string(), + arguments: serde_json::json!({ + "cmd": command, + "tty": false, + }), + ..ToolCall::default() + }; + let assistant_message = + Message::assistant_with_tools(String::new(), vec![tool_call.clone()]) + .with_turn_id(turn_id.clone()) + .with_round_id(round_id.clone()); + let context = ToolExecutionContext { + session_id: session_id.clone(), + dialog_turn_id: turn_id.clone(), + round_id, + attempt_id: None, + attempt_index: None, + agent_type: session.agent_type, + workspace, + primary_model_facts: PrimaryModelFacts::default(), + context_vars: HashMap::new(), + subagent_parent_info: None, + permission_delegation: None, + delegation_policy: DelegationPolicy::top_level(), + deferred_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), + allowed_tools: vec![USER_SHELL_TOOL_NAME.to_string()], + runtime_tool_restrictions: ToolRuntimeRestrictions { + allowed_tool_names: BTreeSet::from([USER_SHELL_TOOL_NAME.to_string()]), + ..ToolRuntimeRestrictions::default() + }, + steering_interrupt: None, + workspace_services, + terminal_port, + remote_exec_port, + }; + + let results = match Self::execute_user_shell_pipeline( + tool_pipeline.as_ref(), + tool_call, + context, + options, + ) + .await + { + Ok(results) => results, + Err(_) if cancellation_token.is_cancelled() => { + Self::persist_cancelled_dialog_turn( + event_queue.as_ref(), + session_manager.as_ref(), + None, + &session_id, + &turn_id, + true, + ) + .await; + execution_engine.cleanup_cancel_token(&turn_id).await; + return; + } + Err(error) => { + Self::persist_failed_dialog_turn( + event_queue.as_ref(), + session_manager.as_ref(), + None, + &session_id, + &turn_id, + &error, + true, + ) + .await; + execution_engine.cleanup_cancel_token(&turn_id).await; + return; + } + }; + + let mut new_messages = Vec::with_capacity(results.len() + 1); + new_messages.push(assistant_message); + new_messages.extend(results.iter().map(|result| { + Message::tool_result(result.result.clone()) + .with_turn_id(turn_id.clone()) + .with_round_id(format!("{turn_id}-shell-round")) + })); + for message in &new_messages { + if let Err(error) = session_manager + .add_message(&session_id, message.clone()) + .await + { + Self::persist_failed_dialog_turn( + event_queue.as_ref(), + session_manager.as_ref(), + None, + &session_id, + &turn_id, + &error, + true, + ) + .await; + execution_engine.cleanup_cancel_token(&turn_id).await; + return; + } + } + + let duration_ms = started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64; + if let Err(error) = session_manager + .complete_dialog_turn( + &session_id, + &turn_id, + String::new(), + &new_messages, + TurnStats { + total_rounds: 1, + total_tools: results.len(), + total_tokens: 0, + duration_ms, + }, + ) + .await + { + Self::persist_failed_dialog_turn( + event_queue.as_ref(), + session_manager.as_ref(), + None, + &session_id, + &turn_id, + &error, + true, + ) + .await; + execution_engine.cleanup_cancel_token(&turn_id).await; + return; + } + + let cancelled = cancellation_token.is_cancelled() + || results.iter().any(|result| { + result + .result + .result + .get("category") + .and_then(serde_json::Value::as_str) + == Some("cancelled") + }); + if cancelled { + Self::persist_cancelled_dialog_turn( + event_queue.as_ref(), + session_manager.as_ref(), + None, + &session_id, + &turn_id, + true, + ) + .await; + } else { + let success = results.iter().all(user_shell_tool_result_succeeded); + let _ = session_manager + .update_session_state_for_turn_if_processing( + &session_id, + &turn_id, + SessionState::Idle, + ) + .await; + let _ = event_queue + .enqueue( + AgenticEvent::DialogTurnCompleted { + session_id: session_id.clone(), + turn_id: turn_id.clone(), + total_rounds: 1, + total_tools: results.len(), + duration_ms, + partial_recovery_reason: None, + success: Some(success), + finish_reason: Some(if success { + "complete".to_string() + } else { + "tool_error".to_string() + }), + has_final_response: Some(false), + }, + Some(EventPriority::Normal), + ) + .await; + } + execution_engine.cleanup_cancel_token(&turn_id).await; + } +} + +#[async_trait::async_trait] +impl bitfun_runtime_ports::AgentUserShellCommandPort for ConversationCoordinator { + async fn run_user_shell_command( + &self, + request: bitfun_runtime_ports::AgentUserShellCommandRequest, + ) -> bitfun_runtime_ports::PortResult { + validate_user_shell_command_request(&request) + .map_err(runtime_port_error_preserving_message)?; + self.ensure_session_runtime_ownership(&request.session_id, None) + .map_err(runtime_port_error_preserving_message)?; + let mutation_guard = self + .session_manager + .acquire_session_mutation(&request.session_id) + .await + .map_err(runtime_port_error_preserving_message)?; + let session = self + .session_manager + .get_session(&request.session_id) + .ok_or_else(|| { + runtime_port_error_preserving_message(BitFunError::NotFound(format!( + "Session not found: {}", + request.session_id + ))) + })?; + let workspace = Self::build_workspace_binding(&session.config).await; + let workspace_services = Self::build_workspace_services(&workspace).await; + let terminal_port = self.terminal_port(); + let remote_exec_port = self.remote_exec_port(); + let mut options = + Self::user_shell_tool_options(&session.agent_type, &workspace, &workspace_services) + .await + .map_err(runtime_port_error_preserving_message)?; + self.commit_session_revert_before_persisted_turn_locked( + &request.session_id, + "User shell Turn", + ) + .await + .map_err(runtime_port_error_preserving_message)?; + let turn_index = self.session_manager.get_turn_count(&request.session_id); + let turn_id = self + .session_manager + .start_dialog_turn_locked( + &request.session_id, + session.agent_type.clone(), + format!("!{}", request.command), + Some(request.turn_id.clone()), + None, + ) + .await + .map_err(runtime_port_error_preserving_message)?; + let execution_lease = self.register_session_execution(&request.session_id); + let settlement = self + .turn_settlements + .register_accepted(request.session_id.clone(), turn_id.clone()); + let cancellation_token = CancellationToken::new(); + options.parent_cancellation_token = Some(cancellation_token.clone()); + self.execution_engine + .register_cancel_token(&turn_id, cancellation_token.clone()); + drop(mutation_guard); + + let started_event = AgenticEvent::DialogTurnStarted { + session_id: request.session_id.clone(), + turn_id: turn_id.clone(), + turn_index, + user_input: format!("!{}", request.command), + original_user_input: None, + user_message_metadata: None, + }; + + let session_manager = Arc::clone(&self.session_manager); + let execution_engine = Arc::clone(&self.execution_engine); + let tool_pipeline = Arc::clone(&self.tool_pipeline); + let event_queue = Arc::clone(&self.event_queue); + let session_id_for_task = request.session_id.clone(); + let turn_id_for_task = turn_id.clone(); + let (started_tx, started_rx) = oneshot::channel(); + tokio::spawn(async move { + let _execution_lease = execution_lease; + let _settlement = settlement; + let _ = event_queue + .enqueue(started_event, Some(EventPriority::Normal)) + .await; + let _ = started_tx.send(()); + Self::execute_user_shell_command_task( + session_manager, + execution_engine, + tool_pipeline, + event_queue, + session, + workspace, + workspace_services, + terminal_port, + remote_exec_port, + options, + session_id_for_task, + turn_id_for_task, + request.command, + cancellation_token, + ) + .await; + }); + // The detached task owns completion once the turn is persisted. Waiting + // only for its started-event barrier preserves event ordering for the + // normal caller while remaining cancellation-safe for IPC timeouts. + let _ = started_rx.await; + + Ok(bitfun_runtime_ports::AgentUserShellCommandResult { + session_id: request.session_id, + turn_id, + }) + } +} + #[async_trait::async_trait] impl bitfun_agent_runtime::sdk::AgentInteractionResponsePort for ConversationCoordinator { async fn submit_user_answers( @@ -11693,11 +12136,17 @@ mod tests { SystemPromptCacheIdentity, UserContextCacheIdentity, }; use crate::agentic::skill_agent_snapshot::SkillSnapshotEntry; + use crate::agentic::tools::framework::{ + PermissionIntent, Tool, ToolResult, ToolUseContext, ValidationResult, + }; use crate::agentic::tools::pipeline::SubagentParentInfo; use crate::agentic::tools::registry::ToolRegistry; use crate::agentic::tools::{ToolPipeline, ToolStateManager}; use crate::agentic::TurnSkillAgentSnapshot; use crate::infrastructure::PathManager; + use bitfun_agent_runtime::permission::PermissionRequestManager; + use bitfun_runtime_services::test_support::FakeRuntimePort; + use bitfun_services_core::permission_store::ProjectPermissionSqliteStore; #[test] fn runtime_session_list_preserves_the_runtime_owned_model_selector() { @@ -11737,14 +12186,19 @@ mod tests { AgentLocalCommandTurnPort, AgentLocalCommandTurnRecordRequest, AgentSessionArchiveRequest, AgentSessionCreateRequest, AgentSessionManagementPort, AgentSessionRenameRequest, AgentSubmissionPort, AgentSubmissionRequest, AgentSubmissionSource, - AgentThreadGoalGetRequest, AgentThreadGoalManagementPort, DelegationPolicy, - PermissionEffect, PermissionRule, PermissionRuntimeCeiling, SessionStoragePathRequest, - SubagentContextMode, ThreadGoal, ThreadGoalStatus, + AgentThreadGoalGetRequest, AgentThreadGoalManagementPort, AgentUserShellCommandPort, + AgentUserShellCommandRequest, DelegationPolicy, PermissionEffect, PermissionRule, + PermissionRuntimeCeiling, PortErrorKind, SessionStoragePathRequest, SubagentContextMode, + ThreadGoal, ThreadGoalStatus, }; use std::collections::HashMap; use std::path::PathBuf; - use std::sync::Arc; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; use std::time::Duration; + use tokio::sync::Notify; use tokio_util::sync::CancellationToken; #[test] @@ -12451,10 +12905,100 @@ mod tests { } use tokio::sync::RwLock as TokioRwLock; - fn test_coordinator_with_config_and_ownership( + #[derive(Default)] + struct TestExecCommandTool { + validation_started: Option>, + release_validation: Option>, + call_count: Option>, + } + + #[async_trait::async_trait] + impl Tool for TestExecCommandTool { + fn name(&self) -> &str { + "ExecCommand" + } + + async fn description(&self) -> crate::util::errors::BitFunResult { + Ok("test user shell command".to_string()) + } + + fn short_description(&self) -> String { + "test user shell command".to_string() + } + + fn input_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "required": ["cmd"], + "properties": { + "cmd": { "type": "string" }, + "tty": { "type": "boolean" } + }, + "additionalProperties": false + }) + } + + fn is_readonly(&self) -> bool { + false + } + + fn permission_intents( + &self, + input: &serde_json::Value, + _context: &ToolUseContext, + ) -> crate::util::errors::BitFunResult> { + Ok(vec![PermissionIntent::new( + "bash", + vec![input["cmd"].as_str().unwrap_or_default().to_string()], + )]) + } + + async fn validate_input( + &self, + _input: &serde_json::Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + if let Some(started) = &self.validation_started { + started.notify_one(); + } + if let Some(release) = &self.release_validation { + release.notified().await; + } + ValidationResult { + result: true, + message: None, + error_code: None, + meta: None, + } + } + + async fn call_impl( + &self, + input: &serde_json::Value, + _context: &ToolUseContext, + ) -> crate::util::errors::BitFunResult> { + if let Some(call_count) = &self.call_count { + call_count.fetch_add(1, Ordering::SeqCst); + } + let command = input["cmd"].as_str().unwrap_or_default(); + let exit_code = if command == "exit 7" { 7 } else { 0 }; + Ok(vec![ToolResult::Result { + data: serde_json::json!({ + "exit_code": exit_code, + "output": command, + }), + result_for_assistant: Some(command.to_string()), + image_attachments: None, + }]) + } + } + + fn test_coordinator_with_registry( max_active_sessions: usize, enable_persistence: bool, runtime_ownership: Arc, + registry: ToolRegistry, + permission_request_manager: Option>, ) -> (ConversationCoordinator, Arc) { let event_queue = Arc::new(EventQueue::new(EventQueueConfig::default())); let coordination_database_file = std::env::temp_dir() @@ -12474,11 +13018,15 @@ mod tests { prompt_cache_policy: PromptCachePolicy::default(), }, )); - let tool_pipeline = Arc::new(ToolPipeline::new( - Arc::new(TokioRwLock::new(ToolRegistry::new())), + let mut tool_pipeline = ToolPipeline::new( + Arc::new(TokioRwLock::new(registry)), Arc::new(ToolStateManager::new(event_queue.clone())), None, - )); + ); + if let Some(manager) = permission_request_manager { + tool_pipeline = tool_pipeline.with_permission_request_manager(manager); + } + let tool_pipeline = Arc::new(tool_pipeline); let execution_engine = Arc::new(ExecutionEngine::new( Arc::new(RoundExecutor::new( Arc::new(StreamProcessor::new(event_queue.clone())), @@ -12509,6 +13057,20 @@ mod tests { (coordinator, session_manager) } + fn test_coordinator_with_config_and_ownership( + max_active_sessions: usize, + enable_persistence: bool, + runtime_ownership: Arc, + ) -> (ConversationCoordinator, Arc) { + test_coordinator_with_registry( + max_active_sessions, + enable_persistence, + runtime_ownership, + ToolRegistry::new(), + None, + ) + } + fn test_coordinator_with_config( max_active_sessions: usize, enable_persistence: bool, @@ -12538,6 +13100,45 @@ mod tests { test_coordinator_with_config(100, true) } + fn test_persistent_user_shell_coordinator_with_tool( + tool: Arc, + ) -> (ConversationCoordinator, Arc) { + let ownership_root = std::env::temp_dir().join(format!( + "bitfun-runtime-ownership-test-{}", + uuid::Uuid::new_v4() + )); + let mut registry = ToolRegistry::new(); + registry.register_tool(tool); + let permission_store = Arc::new(ProjectPermissionSqliteStore::new( + ownership_root.join("permissions"), + )); + let permission_request_manager = Arc::new( + PermissionRequestManager::new( + permission_store.clone(), + permission_store.clone(), + Arc::new(FakeRuntimePort::new( + bitfun_runtime_ports::RuntimeServiceCapability::Clock, + )), + ) + .with_grant_store(permission_store), + ); + test_coordinator_with_registry( + 100, + true, + Arc::new(CoreRuntimeOwnership::embedded_with_facts( + ownership_root, + "bitfun".to_string(), + "test", + )), + registry, + Some(permission_request_manager), + ) + } + + fn test_persistent_user_shell_coordinator() -> (ConversationCoordinator, Arc) { + test_persistent_user_shell_coordinator_with_tool(Arc::new(TestExecCommandTool::default())) + } + fn test_coordinator() -> (ConversationCoordinator, Arc) { test_coordinator_with_max_active_sessions(100) } @@ -13301,6 +13902,312 @@ mod tests { assert_state_port::(); } + #[tokio::test] + async fn user_shell_command_rejects_blank_and_nul_input_before_admission() { + let (coordinator, _) = test_coordinator(); + + for command in [" ", "printf 'bad\0input'"] { + let error = AgentUserShellCommandPort::run_user_shell_command( + &coordinator, + AgentUserShellCommandRequest { + session_id: "missing-session".to_string(), + turn_id: "turn-shell".to_string(), + command: command.to_string(), + }, + ) + .await + .expect_err("invalid commands must fail before session lookup"); + + assert_eq!(error.kind, PortErrorKind::InvalidRequest); + } + } + + #[tokio::test] + async fn user_shell_command_persists_a_standard_exec_command_tool_turn() { + let workspace = tempfile::tempdir().expect("workspace"); + let (coordinator, session_manager) = test_persistent_user_shell_coordinator(); + let session = session_manager + .create_session( + "Shell turn".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().into_owned()), + ..Default::default() + }, + ) + .await + .expect("create session"); + + let accepted = AgentUserShellCommandPort::run_user_shell_command( + &coordinator, + AgentUserShellCommandRequest { + session_id: session.session_id.clone(), + turn_id: "turn-shell".to_string(), + command: "git status --short".to_string(), + }, + ) + .await + .expect("admit shell turn"); + coordinator + .wait_for_turn_settlement( + &accepted.session_id, + &accepted.turn_id, + Duration::from_secs(5), + ) + .await + .expect("shell turn settles"); + + let turns = session_manager + .persistence_manager() + .load_session_turns(workspace.path(), &session.session_id) + .await + .expect("load turns"); + let turn = turns.last().expect("shell turn"); + let events = coordinator.event_queue.dequeue_batch(100).await; + assert!(events.iter().any(|envelope| matches!( + &envelope.event, + AgenticEvent::DialogTurnStarted { turn_id, .. } if turn_id == "turn-shell" + ))); + assert!(!events.iter().any(|envelope| matches!( + &envelope.event, + AgenticEvent::DialogTurnFailed { turn_id, .. } if turn_id == "turn-shell" + ))); + assert_eq!(turn.kind, DialogTurnKind::UserDialog); + assert_eq!(turn.user_message.content, "!git status --short"); + assert_eq!( + turn.status, + TurnStatus::Completed, + "shell turn failed: error={:?}, events={events:?}", + turn.error, + ); + let tool = turn + .model_rounds + .first() + .and_then(|round| round.tool_items.first()) + .expect("ExecCommand tool item"); + assert_eq!(tool.tool_name, "ExecCommand"); + assert_eq!(tool.tool_call.input["cmd"], "git status --short"); + assert!(tool.tool_result.is_some()); + + let context = session_manager + .get_context_messages(&session.session_id) + .await + .expect("context messages"); + assert!(context.iter().any(|message| matches!( + &message.content, + MessageContent::Mixed { tool_calls, .. } + if tool_calls.iter().any(|call| call.tool_name == "ExecCommand") + ))); + assert!(context + .iter() + .any(|message| matches!(message.content, MessageContent::ToolResult { .. }))); + } + + #[tokio::test] + async fn user_shell_command_auto_approves_ask_but_preserves_project_denies() { + let workspace = tempfile::tempdir().expect("workspace"); + let permission_path = workspace + .path() + .join(".bitfun") + .join("config") + .join("tool_permissions.json"); + tokio::fs::create_dir_all(permission_path.parent().expect("permission parent")) + .await + .expect("create permission directory"); + tokio::fs::write( + &permission_path, + r#"{"rules":[{"action":"bash","resource":"git reset --hard","effect":"deny"}]}"#, + ) + .await + .expect("write project permission rule"); + + let (coordinator, session_manager) = test_persistent_user_shell_coordinator(); + let session = session_manager + .create_session( + "Denied shell turn".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().into_owned()), + ..Default::default() + }, + ) + .await + .expect("create session"); + let accepted = AgentUserShellCommandPort::run_user_shell_command( + &coordinator, + AgentUserShellCommandRequest { + session_id: session.session_id.clone(), + turn_id: "turn-shell-denied".to_string(), + command: "git reset --hard".to_string(), + }, + ) + .await + .expect("deny is represented as a settled tool result"); + coordinator + .wait_for_turn_settlement( + &accepted.session_id, + &accepted.turn_id, + Duration::from_secs(5), + ) + .await + .expect("denied shell turn settles"); + + let turns = session_manager + .persistence_manager() + .load_session_turns(workspace.path(), &session.session_id) + .await + .expect("load turns"); + let tool_result = turns + .last() + .and_then(|turn| turn.model_rounds.first()) + .and_then(|round| round.tool_items.first()) + .and_then(|tool| tool.tool_result.as_ref()) + .expect("permission denial tool result"); + assert_eq!(tool_result.result["category"], "permission_denied"); + let events = coordinator.event_queue.dequeue_batch(100).await; + assert!(events.iter().any(|envelope| matches!( + &envelope.event, + AgenticEvent::DialogTurnCompleted { + turn_id, + success: Some(false), + finish_reason: Some(reason), + .. + } if turn_id == "turn-shell-denied" && reason == "tool_error" + ))); + } + + #[tokio::test] + async fn user_shell_command_reports_a_nonzero_exit_as_a_tool_error() { + let workspace = tempfile::tempdir().expect("workspace"); + let (coordinator, session_manager) = test_persistent_user_shell_coordinator(); + let session = session_manager + .create_session( + "Failed shell turn".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().into_owned()), + ..Default::default() + }, + ) + .await + .expect("create session"); + let accepted = AgentUserShellCommandPort::run_user_shell_command( + &coordinator, + AgentUserShellCommandRequest { + session_id: session.session_id, + turn_id: "turn-shell-nonzero".to_string(), + command: "exit 7".to_string(), + }, + ) + .await + .expect("admit shell turn"); + coordinator + .wait_for_turn_settlement( + &accepted.session_id, + &accepted.turn_id, + Duration::from_secs(5), + ) + .await + .expect("failed shell turn settles"); + + let events = coordinator.event_queue.dequeue_batch(100).await; + assert!(events.iter().any(|envelope| matches!( + &envelope.event, + AgenticEvent::DialogTurnCompleted { + turn_id, + success: Some(false), + finish_reason: Some(reason), + .. + } if turn_id == "turn-shell-nonzero" && reason == "tool_error" + ))); + } + + #[tokio::test] + async fn user_shell_command_cancelled_during_validation_never_executes() { + let workspace = tempfile::tempdir().expect("workspace"); + let validation_started = Arc::new(Notify::new()); + let release_validation = Arc::new(Notify::new()); + let call_count = Arc::new(AtomicUsize::new(0)); + let (coordinator, session_manager) = + test_persistent_user_shell_coordinator_with_tool(Arc::new(TestExecCommandTool { + validation_started: Some(validation_started.clone()), + release_validation: Some(release_validation.clone()), + call_count: Some(call_count.clone()), + })); + let coordinator = Arc::new(coordinator); + let session = session_manager + .create_session( + "Cancelled shell turn".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().into_owned()), + ..Default::default() + }, + ) + .await + .expect("create session"); + let turn_id = "turn-shell-cancel-preflight".to_string(); + let accepted = AgentUserShellCommandPort::run_user_shell_command( + coordinator.as_ref(), + AgentUserShellCommandRequest { + session_id: session.session_id.clone(), + turn_id: turn_id.clone(), + command: "touch must-not-run".to_string(), + }, + ) + .await + .expect("admit shell turn"); + tokio::time::timeout(Duration::from_secs(1), validation_started.notified()) + .await + .expect("tool validation starts"); + + let coordinator_for_cancel = coordinator.clone(); + let session_id_for_cancel = session.session_id.clone(); + let turn_id_for_cancel = turn_id.clone(); + let cancel_task = tokio::spawn(async move { + bitfun_runtime_ports::AgentTurnCancellationPort::cancel_turn( + coordinator_for_cancel.as_ref(), + bitfun_runtime_ports::AgentTurnCancellationRequest { + session_id: session_id_for_cancel, + turn_id: Some(turn_id_for_cancel), + source: None, + requester_session_id: None, + reason: Some("test cancellation".to_string()), + wait_timeout_ms: Some(1500), + }, + ) + .await + }); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if coordinator + .execution_cancel_token_for_dialog_turn(&turn_id) + .is_some_and(|token| token.is_cancelled()) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("turn cancellation is signalled"); + release_validation.notify_one(); + cancel_task + .await + .expect("cancel task joins") + .expect("cancel request succeeds"); + coordinator + .wait_for_turn_settlement( + &accepted.session_id, + &accepted.turn_id, + Duration::from_secs(5), + ) + .await + .expect("cancelled shell turn settles"); + + assert_eq!(call_count.load(Ordering::SeqCst), 0); + } + #[test] fn hidden_subagent_dialog_turn_id_reuses_existing_or_generates_raw_uuid() { let mut missing = None; diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index c9a2f88a36..b16ac5f279 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -6076,6 +6076,32 @@ impl SessionManager { Ok(turn_id) } + /// Starts a normal user dialog while the caller owns the Session mutation + /// lock. This keeps staged-revert commit and new-turn admission atomic for + /// non-model Runtime operations that still produce standard dialog turns. + pub(crate) async fn start_dialog_turn_locked( + &self, + session_id: &str, + agent_type: String, + user_input: String, + turn_id: Option, + user_message_metadata: Option, + ) -> BitFunResult { + let user_message = Message::user(user_input.clone()) + .with_semantic_kind(MessageSemanticKind::ActualUserInput); + self.start_persisted_turn_locked( + session_id, + DialogTurnKind::UserDialog, + Some(agent_type), + user_input, + turn_id, + vec![user_message], + ProcessingPhase::Starting, + user_message_metadata, + ) + .await + } + pub async fn start_dialog_turn_with_prepended_messages( &self, session_id: &str, diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs index e4252bc8a3..ada90de691 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -1754,7 +1754,30 @@ impl ToolPipeline { BitFunError::tool(error_msg) })?; - let cancellation_token = CancellationToken::new(); + let cancellation_token = task + .options + .parent_cancellation_token + .as_ref() + .map(CancellationToken::child_token) + .unwrap_or_default(); + if cancellation_token.is_cancelled() { + self.state_manager + .update_state( + &tool_id, + ToolExecutionState::Cancelled { + reason: "Tool was cancelled before validation".to_string(), + duration_ms: Some(elapsed_ms_u64(start_time)), + queue_wait_ms: Some(queue_wait_ms), + preflight_ms: Some(elapsed_ms_u64(start_time)), + confirmation_wait_ms: Some(0), + execution_ms: None, + }, + ) + .await; + return Err(BitFunError::Cancelled( + "Tool was cancelled before validation".to_string(), + )); + } let tool_context = self.build_tool_use_context(&task, cancellation_token.clone()); // Keep the registered mux in the execution path. It rechecks the // persisted conflict choice immediately before dispatch and applies @@ -1795,6 +1818,26 @@ impl ToolPipeline { self.cancellation_tokens .insert(tool_id.clone(), cancellation_token.clone()); + if cancellation_token.is_cancelled() { + self.state_manager + .update_state( + &tool_id, + ToolExecutionState::Cancelled { + reason: "Tool was cancelled during validation".to_string(), + duration_ms: Some(elapsed_ms_u64(start_time)), + queue_wait_ms: Some(queue_wait_ms), + preflight_ms: Some(elapsed_ms_u64(start_time)), + confirmation_wait_ms: Some(0), + execution_ms: None, + }, + ) + .await; + self.cancellation_tokens.remove(&tool_id); + return Err(BitFunError::Cancelled( + "Tool was cancelled during validation".to_string(), + )); + } + let has_prepared_plan = self.permission_plans.lock().await.contains_key(&tool_id); let permission_authorization = if has_prepared_plan { self.await_prepared_permission_plan(&tool_id, &cancellation_token) diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs index 707a730213..59b7072fe1 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs @@ -14,6 +14,7 @@ use bitfun_runtime_ports::{ use std::collections::HashMap; use std::sync::Arc; use std::time::SystemTime; +use tokio_util::sync::CancellationToken; pub use tool_runtime::context::PrimaryModelFacts; pub use tool_runtime::pipeline::SubagentBatchExecutionPolicy; @@ -29,6 +30,9 @@ pub struct ToolExecutionOptions { pub permission_policy: ResolvedPermissionPolicy, /// Automatically reply `once` to `ask` requests through the permission manager. pub auto_approve_ask: bool, + /// Optional owner-provided token that latches cancellation before tool + /// validation and permission preflight have registered pipeline state. + pub parent_cancellation_token: Option, } impl Default for ToolExecutionOptions { @@ -40,6 +44,7 @@ impl Default for ToolExecutionOptions { timeout_secs: None, // Default no timeout (infinite waiting) permission_policy: ResolvedPermissionPolicy::default(), auto_approve_ask: false, + parent_cancellation_token: None, } } } diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index a5b11bf1d3..8fd16a13ef 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -17,10 +17,11 @@ use bitfun_runtime_ports::{ AgentLocalCommandTurnPort, AgentSessionClosePort, AgentSessionCreateRequest, AgentSessionManagementPort, AgentSessionRevertRequest, AgentSessionRevertResult, AgentSubmissionPort, AgentSubmissionSource, AgentThreadGoalManagementPort, - AgentTurnCancellationPort, AgentTurnCancellationRequest, AgentWorkspaceReferencePort, - PermissionPolicyPreset, RemoteControlStatePort, RemoteControlStateRequest, - RemoteControlStateSnapshot, RemoteSessionWorkspaceIdentity, RuntimeServiceCapability, - RuntimeServicePort, SessionStoragePathRequest, SessionStorePort, ToolPermissionConfig, + AgentTurnCancellationPort, AgentTurnCancellationRequest, AgentUserShellCommandPort, + AgentWorkspaceReferencePort, PermissionPolicyPreset, RemoteControlStatePort, + RemoteControlStateRequest, RemoteControlStateSnapshot, RemoteSessionWorkspaceIdentity, + RuntimeServiceCapability, RuntimeServicePort, SessionStoragePathRequest, SessionStorePort, + ToolPermissionConfig, }; use bitfun_services_integrations::remote_connect::{ agent_input_attachment_from_remote_image_context, build_remote_chat_messages, @@ -417,6 +418,7 @@ fn core_agent_runtime_builder( session_compaction: Arc, session_restore: Arc, local_command_turn: Arc, + user_shell_command: Arc, transcript_reader: Arc, thread_goal_management: Arc, cancellation: Arc, @@ -433,6 +435,7 @@ fn core_agent_runtime_builder( .with_session_compaction_port(session_compaction) .with_session_restore_port(session_restore) .with_local_command_turn_port(local_command_turn) + .with_user_shell_command_port(user_shell_command) .with_session_transcript_reader(transcript_reader) .with_thread_goal_management_port(thread_goal_management) .with_cancellation_port(cancellation) @@ -1056,6 +1059,7 @@ impl CoreServiceAgentRuntime { let session_model: Arc = coordinator.clone(); let session_restore: Arc = coordinator.clone(); let local_command_turn: Arc = coordinator.clone(); + let user_shell_command: Arc = coordinator.clone(); let transcript_reader: Arc = coordinator.clone(); let thread_goal_management: Arc = coordinator.clone(); @@ -1071,6 +1075,7 @@ impl CoreServiceAgentRuntime { session_compaction, session_restore, local_command_turn, + user_shell_command, transcript_reader, thread_goal_management, cancellation, @@ -1094,6 +1099,7 @@ impl CoreServiceAgentRuntime { let session_model: Arc = coordinator.clone(); let session_restore: Arc = coordinator.clone(); let local_command_turn: Arc = coordinator.clone(); + let user_shell_command: Arc = coordinator.clone(); let transcript_reader: Arc = coordinator.clone(); let thread_goal_management: Arc = coordinator.clone(); @@ -1111,6 +1117,7 @@ impl CoreServiceAgentRuntime { session_compaction, session_restore, local_command_turn, + user_shell_command, transcript_reader, thread_goal_management, cancellation, @@ -1137,6 +1144,7 @@ impl CoreServiceAgentRuntime { let session_model: Arc = coordinator.clone(); let session_restore: Arc = coordinator.clone(); let local_command_turn: Arc = coordinator.clone(); + let user_shell_command: Arc = coordinator.clone(); let transcript_reader: Arc = coordinator.clone(); let thread_goal_management: Arc = coordinator.clone(); @@ -1153,6 +1161,7 @@ impl CoreServiceAgentRuntime { session_compaction, session_restore, local_command_turn, + user_shell_command, transcript_reader, thread_goal_management, cancellation, @@ -1215,6 +1224,7 @@ impl CoreServiceAgentRuntime { let session_model: Arc = coordinator.clone(); let session_restore: Arc = coordinator.clone(); let local_command_turn: Arc = coordinator.clone(); + let user_shell_command: Arc = coordinator.clone(); let transcript_reader: Arc = coordinator.clone(); let thread_goal_management: Arc = coordinator.clone(); @@ -1232,6 +1242,7 @@ impl CoreServiceAgentRuntime { session_compaction, session_restore, local_command_turn, + user_shell_command, transcript_reader, thread_goal_management, cancellation, @@ -1335,6 +1346,7 @@ impl CoreServiceAgentRuntime { let session_model: Arc = coordinator.clone(); let session_restore: Arc = coordinator.clone(); let local_command_turn: Arc = coordinator.clone(); + let user_shell_command: Arc = coordinator.clone(); let transcript_reader: Arc = coordinator.clone(); let thread_goal_management: Arc = coordinator.clone(); @@ -1352,6 +1364,7 @@ impl CoreServiceAgentRuntime { session_compaction, session_restore, local_command_turn, + user_shell_command, transcript_reader, thread_goal_management, cancellation, diff --git a/src/crates/contracts/runtime-ports/src/lib.rs b/src/crates/contracts/runtime-ports/src/lib.rs index bd5ad2cdd4..11576585b0 100644 --- a/src/crates/contracts/runtime-ports/src/lib.rs +++ b/src/crates/contracts/runtime-ports/src/lib.rs @@ -1207,6 +1207,27 @@ pub struct AgentLocalCommandTurnRecordRequest { pub metadata: serde_json::Map, } +/// Starts one user-authored shell command as a normal, model-visible tool turn. +/// +/// The caller provides the exact turn identity so interactive adapters can +/// register cancellation before the side effect is admitted. Implementations +/// must route execution through the normal tool, permission, and audit owners; +/// this is not a generic process-spawn or arbitrary-tool contract. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentUserShellCommandRequest { + pub session_id: String, + pub turn_id: String, + pub command: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentUserShellCommandResult { + pub session_id: String, + pub turn_id: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSessionModelUpdateRequest { @@ -2265,6 +2286,14 @@ pub trait AgentLocalCommandTurnPort: Send + Sync { ) -> PortResult<()>; } +#[async_trait::async_trait] +pub trait AgentUserShellCommandPort: Send + Sync { + async fn run_user_shell_command( + &self, + request: AgentUserShellCommandRequest, + ) -> PortResult; +} + #[async_trait::async_trait] pub trait AgentSessionModelPort: Send + Sync { async fn update_session_model(&self, request: AgentSessionModelUpdateRequest) diff --git a/src/crates/execution/agent-runtime/src/runtime.rs b/src/crates/execution/agent-runtime/src/runtime.rs index 55634e7d93..7218e3d1bd 100644 --- a/src/crates/execution/agent-runtime/src/runtime.rs +++ b/src/crates/execution/agent-runtime/src/runtime.rs @@ -28,6 +28,7 @@ use bitfun_runtime_ports::{ AgentThreadGoalManagementPort, AgentThreadGoalUpdateStatusRequest, AgentTransientSessionDiscardRequest, AgentTurnCancellationPort, AgentTurnCancellationRequest, AgentTurnCancellationResult, AgentTurnSettlementPort, AgentTurnSettlementRequest, + AgentUserShellCommandPort, AgentUserShellCommandRequest, AgentUserShellCommandResult, AgentWorkspaceReference, AgentWorkspaceReferencePort, AgentWorkspaceReferenceSearchRequest, AgentWorkspaceReferenceSearchResult, DialogSubmitOutcome, PermissionAuditRecord, PermissionGrant, PermissionGrantKey, PluginRuntimeBinding, PortError, PortErrorKind, @@ -202,6 +203,7 @@ pub struct AgentRuntime { turn_settlement: Option>, session_restore: Option>, local_command_turn: Option>, + user_shell_command: Option>, session_transcript_reader: Option>, thread_goal_management: Option>, dialog_turn: Option>, @@ -307,6 +309,13 @@ impl std::fmt::Debug for AgentRuntime { .as_ref() .map(|_| ""), ) + .field( + "user_shell_command", + &self + .user_shell_command + .as_ref() + .map(|_| ""), + ) .field( "session_transcript_reader", &self @@ -410,6 +419,7 @@ pub struct AgentRuntimeBuilder { turn_settlement: Option>, session_restore: Option>, local_command_turn: Option>, + user_shell_command: Option>, session_transcript_reader: Option>, thread_goal_management: Option>, dialog_turn: Option>, @@ -509,6 +519,14 @@ impl AgentRuntimeBuilder { self } + pub fn with_user_shell_command_port( + mut self, + port: Arc, + ) -> Self { + self.user_shell_command = Some(port); + self + } + pub fn with_session_transcript_reader( mut self, reader: Arc, @@ -614,6 +632,7 @@ impl AgentRuntimeBuilder { turn_settlement, session_restore, local_command_turn, + user_shell_command, session_transcript_reader, thread_goal_management, dialog_turn, @@ -649,6 +668,7 @@ impl AgentRuntimeBuilder { turn_settlement, session_restore, local_command_turn, + user_shell_command, session_transcript_reader, thread_goal_management, dialog_turn, @@ -1103,6 +1123,35 @@ impl AgentRuntime { .map_err(RuntimeError::from) } + pub async fn run_user_shell_command( + &self, + request: AgentUserShellCommandRequest, + ) -> Result { + let requested_session_id = request.session_id.clone(); + let requested_turn_id = request.turn_id.clone(); + let port = self.user_shell_command.as_ref().ok_or_else(|| { + RuntimeError::Port(PortError::new( + PortErrorKind::NotAvailable, + "agent user shell command port is not registered", + )) + })?; + let result = port + .run_user_shell_command(request) + .await + .map_err(RuntimeError::from)?; + if result.session_id != requested_session_id || result.turn_id != requested_turn_id { + return Err(PortError::new( + PortErrorKind::Backend, + format!( + "agent user shell provider returned identity '{}/{}' for requested identity '{}/{}'", + result.session_id, result.turn_id, requested_session_id, requested_turn_id + ), + ) + .into()); + } + Ok(result) + } + pub async fn update_session_model( &self, request: AgentSessionModelUpdateRequest, @@ -1531,7 +1580,8 @@ mod tests { AgentSessionRevertPort, AgentSessionRevertRequest, AgentSessionRevertResult, AgentSessionSummary, AgentSessionWorkspaceRequest, AgentSubmissionResult, AgentThreadGoalDeliveryKind, AgentThreadGoalDeliveryRequest, AgentThreadGoalManagementPort, - AgentTurnCancellationResult, ClockPort, DialogQueuePriority, DialogSubmissionPolicy, + AgentTurnCancellationResult, AgentUserShellCommandPort, AgentUserShellCommandRequest, + AgentUserShellCommandResult, ClockPort, DialogQueuePriority, DialogSubmissionPolicy, DialogSubmitOutcome, FileSystemPort, PluginDispatchEnvelope, PluginResponseEnvelope, PluginRuntimeAvailability, PluginRuntimeClient, PluginRuntimeUnavailableReason, PortErrorKind, PortResult, RuntimeEventSink, RuntimeEventType, RuntimeServiceCapability, @@ -1554,6 +1604,7 @@ mod tests { archived_sessions: Mutex>, archive_state_updates: Mutex>, local_command_turns: Mutex>, + user_shell_commands: Mutex>, restored_sessions: Mutex>, mode_updates: Mutex>, undo_requests: Mutex>, @@ -1784,6 +1835,23 @@ mod tests { } } + #[async_trait::async_trait] + impl AgentUserShellCommandPort for FakeAgentRuntimePorts { + async fn run_user_shell_command( + &self, + request: AgentUserShellCommandRequest, + ) -> PortResult { + self.user_shell_commands + .lock() + .unwrap() + .push(request.clone()); + Ok(AgentUserShellCommandResult { + session_id: request.session_id, + turn_id: request.turn_id, + }) + } + } + #[async_trait::async_trait] impl AgentSessionRestorePort for FakeAgentRuntimePorts { async fn restore_session( @@ -2502,6 +2570,64 @@ mod tests { assert_eq!(requests[0].metadata["kind"], "usage_report"); } + #[tokio::test] + async fn user_shell_command_requires_registered_port() { + let ports = Arc::new(FakeAgentRuntimePorts::default()); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(ports) + .build() + .expect("runtime"); + + let error = runtime + .run_user_shell_command(AgentUserShellCommandRequest { + session_id: "session_1".to_string(), + turn_id: "turn_1".to_string(), + command: "git status".to_string(), + }) + .await + .unwrap_err(); + + assert!(matches!( + error, + RuntimeError::Port(PortError { + kind: PortErrorKind::NotAvailable, + .. + }) + )); + } + + #[tokio::test] + async fn user_shell_command_delegates_exact_request_and_identity() { + let ports = Arc::new(FakeAgentRuntimePorts::default()); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(ports.clone()) + .with_user_shell_command_port(ports.clone()) + .build() + .expect("runtime"); + let request = AgentUserShellCommandRequest { + session_id: "session_1".to_string(), + turn_id: "turn_1".to_string(), + command: "git status --short".to_string(), + }; + + let result = runtime + .run_user_shell_command(request.clone()) + .await + .expect("run user shell command"); + + assert_eq!( + result, + AgentUserShellCommandResult { + session_id: "session_1".to_string(), + turn_id: "turn_1".to_string(), + } + ); + assert_eq!( + ports.user_shell_commands.lock().unwrap().as_slice(), + [request] + ); + } + #[tokio::test] async fn thread_goal_management_requires_registered_port() { let ports = Arc::new(FakeAgentRuntimePorts::default()); diff --git a/src/crates/execution/agent-runtime/src/sdk.rs b/src/crates/execution/agent-runtime/src/sdk.rs index 383157192f..f160225979 100644 --- a/src/crates/execution/agent-runtime/src/sdk.rs +++ b/src/crates/execution/agent-runtime/src/sdk.rs @@ -77,6 +77,7 @@ pub use bitfun_runtime_ports::{ AgentThreadGoalManagementPort, AgentThreadGoalUpdateStatusRequest, AgentTransientSessionDiscardRequest, AgentTurnCancellationPort, AgentTurnCancellationRequest, AgentTurnCancellationResult, AgentTurnSettlementPort, AgentTurnSettlementRequest, + AgentUserShellCommandPort, AgentUserShellCommandRequest, AgentUserShellCommandResult, AgentWorkspaceReference, AgentWorkspaceReferenceKind, AgentWorkspaceReferencePort, AgentWorkspaceReferenceSearchEntry, AgentWorkspaceReferenceSearchRequest, AgentWorkspaceReferenceSearchResult, AgentWorkspaceReferenceSourceRange, ClockPort, @@ -199,6 +200,14 @@ impl AgentRuntimeBuilder { self } + pub fn with_user_shell_command_port( + mut self, + port: Arc, + ) -> Self { + self.inner = self.inner.with_user_shell_command_port(port); + self + } + pub fn with_session_transcript_reader( mut self, reader: Arc, @@ -480,6 +489,13 @@ impl AgentRuntime { .await } + pub async fn run_user_shell_command( + &self, + request: AgentUserShellCommandRequest, + ) -> Result { + self.inner.run_user_shell_command(request).await + } + pub async fn update_session_model( &self, request: AgentSessionModelUpdateRequest,