Skip to content

Latest commit

 

History

History
949 lines (756 loc) · 76.5 KB

File metadata and controls

949 lines (756 loc) · 76.5 KB

命令面 —— 一份末端命令,多个入口(设计)

目标形状:TuneLab 的每个可被外部驱动的动作只实现一次("末端命令"), 上面接多个入口——内置 agent、CLI、MCP server、CI 里的无头进程——都调同一份。

本文承接 agent-tools.md(现有 26 个工具及其归属判据)与 script-inputs-and-action-surface.md(统一动作面 / 分级授权)。 三者共用一句原则:SSOT 约束的是执行面,不是入口数


0. 为什么现在做,以及为什么不能"再抄两遍"

tl 脚本动作面已经稳了(单一 commit、单一闸门),26 个 agent 工具覆盖了工程编辑、环境感知、 设置/快捷键/路由/扩展设置、探测沙箱。此刻要给外部 agent 开 CLI 和 MCP 两个入口,只有两条路:

  • 各自再实现一遍:三份 handler,必然漂移,且每加一个能力要改三处。
  • 提出一层命令面:末端命令一份,入口是薄适配器。

选后者。代价是一次对用户不可见的重构(现有 26 个工具要搬家),收益是此后每个新能力自动出现在 所有入口上。不做这层,CLI 和 MCP 就是工具面原样抄两遍。

这层不是"给 agent 用的":CI 回归测试、批量转换、开发期手工验证都是它的消费者。


1. 现状盘点(实地核对,不是回忆)

动手前必须知道的既有事实:

事实 位置 对本设计的影响
工具注册表长在 UI 里 AgentSideBarContentProvider.SetProject()new 出 26 个工具 第一刀就是把它提出来。CLI/MCP 入口拿不到侧栏,这一刀不做后面全是空谈
工具结果是给模型读的自然语言 IAgentTool.ExecuteAsync 返回 string,如 "The setting x is already 3. Nothing changed." 命令面要 {data}/{error},人类文本由上层渲染。见 §4
闸门要 UI 回调 ToolAuthorization.AuthorizeAsync(request, confirm, ct)confirm == null 时保守不做 闸门要抽成按入口注入的策略。见 §5.1
脚本类工具依赖编辑器态 mCurrentPartProvider / mQuantizationProvider / mSelectionProvider / mPianoSelectionProvider 外部调进来"当前 part"是什么?见 §5.2
有一个工具会调模型 ListExtensionsTool(SendSideRequestAsync) —— 补能力位摘要时发旁路请求 外部入口没有模型可调,要降级。见 §5.3
有一个工具是对话内交互 AskUserQuestionTool(RequestUserAnswerAsync) 它不是末端命令,是入口能力。见 §5.4
已经有一个命名管道 App.axaml.cs:130NamedPipeServerStream("TuneLab")Program.cs:62 客户端连它——单实例把命令行参数转发给运行中实例 bridge 另开一个管道,不要复用。见 §7
headless 已有可工作的先例 tools/ScreenshotBot/Program.cs 复用 TuneLab.Program.InitCoreServices() + ConfigureAppCommon() + UseHeadless,数据目录经 TUNELAB_DATA_DIR 隔离 headless 不是新地基,是把它正式化。见 §8
沙箱已证明无头合成成立 SandboxHost + PumpableSynchronizationContext:无头造工程、挂真音源、离线合成、读回真实音素,全程不碰音频设备 headless 下"跑完整合成再断言"可行
结果有中央截断 AgentRunner.ClampToolResultSettings.AgentMaxToolResultChars 截断是模型入口的关切,不属命令面;CLI/CI 不该被截断。见 §4.3

2. 命名裁决:裸名 Command 归命令面,撤销栈那族改叫 *UndoCommand

"命令"一词此前在本仓指两样东西,都不是这里说的命令:

符号 含义
数据层 ICommand / Command / CompositeCommandTuneLab.Hosting.Foundation/Document/,命名空间 TuneLab.Foundation 撤销栈里的一次可撤销突变
输入层 KeyCommandTuneLab/Input/KeyCommand.csissue #150 后已拆成 EditorAction + KeyBindingEntry 可绑快捷键的 UI 动作(带 Execute UI 委托)

Operation 也不能用——AutomationRendererOperation.cs 等处有几十个 UI 鼠标交互状态机类叫 *Operation

裁决裸名 Command* 归命令面ICommand / CommandRegistry / CommandResult / CommandArgs / CommandContext / CommandKind),数据层那一族前置改名

这不是"为腾地方而牺牲精确性",而是顺手修正一个既有的命名不精确:那个 ICommand 的全部内容 就是 Undo() / Redo() 两个方法——它讲的一直是撤销栈。而"能被入口调用的末端命令"才是 command 在 CLI/MCP 语境下的主流含义。

接口改叫 IDataCommand——这是归队而非另起名Document/ 目录里的类名本来就是 Data* 一族 (IDataList / IDataMap / IDataObject / IDataProperty / DataDocument / DataObjectList / SortedDataLinkedList),ICommand / Command 恰是少数没带前缀的。限定词取所属层, 于是三个"命令"按层各就各位,与输入层那族同范式:

符号 含义
IDataCommand 数据层 撤销栈条目:一次可撤销突变
EditorAction / KeyBindingEntry 输入层 一条动作,与引用它的一条可绑条目(issue #150 之前合为 KeyCommand
ICommand 宿主入口层 末端命令(本文的命令面,占裸名)

真冲突的只有 ICommand 一个Command / CompositeCommand / UndoOnlyCommand / RedoOnlyCommand 与命令面的 CommandRegistry / CommandResult / CommandArgs 都不重名。 故改名只动两个符号——接口,以及跟着接口配对的默认实现(IDataList/DataList 范式; 留着裸名 Command 会让 new Command(...) 被误读成命令面的东西):

原名 新名 说明
ICommand IDataCommand 归队 Document/Data* 命名
Command DataCommand 默认实现:一对 redo/undo 委托,两支都有动作(注释点明,与两个 *OnlyCommand 对照)
UndoOnlyCommand / RedoOnlyCommand / CompositeCommand 不动 不与命令面重名;Only / Composite 已在承担区分

全族保持 *Command 后缀SoundSource.ModifyCommand 这类外部实现也在其中)——它们都是被 Push 进撤销栈、参与回放的栈条目,后缀反映的就是这个身份。单向那两个当前的用法恰好都是 "插一个方向性通知"(Push(new UndoOnlyCommand(NotifyRangeModified)),34 处),但接口契约是 "只在一个方向执行给定动作",不排除将来出现真正改数据的单向条目——别按当前采样把它们改叫钩子

改名可行且成本极小(已实地核对):

  • TuneLab.Hosting.Foundation 不在冻结面——没有 PublicAPI.*.txt、没挂 PublicApiAnalyzers, 只被 TuneLab / TuneLab.GUI / TuneLab.I18N 三个内部项目引用,没有任何插件引用它 (插件引 TuneLab.SDK + TuneLab.Foundation;legacy 插件有自己独立的 TuneLab.Base.Data.ICommand, 与此无关)。故这次改名不触发 RS0016/RS0017,也不构成插件 ABI 变更
  • 引用面:ICommand 25 处 / 12 文件,其中 11 个文件就在 Document/ 自己目录里; Document/ 之外总共只有 3 行——MidiPart.cs:563MidiPart.cs:569new Command(...))、 SoundSource.cs:149: ICommand)。CompositeCommand 只有 3 处、全内部。

用户面也叫 command:CLI 写 tunelab setting list、MCP 描述里写 command——与代码同词, 不需要在文档和终端之间做词形转换。落地时在术语表加一行,把三者的分工写清楚。


3. 命令面契约

3.1 一条命令

// 一个末端命令:路径 + 参数 schema + 文档 + handler。不含任何入口特有的东西
// (不知道模型、不知道终端、不知道 UI)。
internal interface ICommand
{
    string Path { get; }              // "setting set" —— group + 叶子动词,空格分隔
    CommandKind Kind { get; }     // Read | Edit | Sandbox
    AgentWriteKind? WriteKind { get; }// Edit 时给出,决定闸门维度与卡片文案;Read 恒 null
    string Brief { get; }             // <=60 字,进 MCP 工具描述与 CLI 一行帮助
    string Documentation { get; }     // 完整说明(现有工具的 Description 迁进来)
    string ParametersJsonSchema { get; }

    Task<CommandResult> ExecuteAsync(CommandArgs args, CommandContext ctx, CancellationToken ct);
}

Kind 三值而非二值:Sandbox 单列,因为 run_in_sandbox 既不是只读、也不碰用户数据、 不过闸门——压进 Edit 会让所有入口的安全标注说谎。

3.2 结果形状

// 成功 → Data(结构化事实);失败 → Error。二者互斥。
internal readonly record struct CommandResult(JsonNode? Data, CommandError? Error);
internal readonly record struct CommandError(string Code, string Message, JsonNode? Details);

渲染不在 handler 里。每条命令另有一个 Render(Data) -> string 产出人类/模型可读文本, 这样同一份措辞同时出现在 CLI stdout 和模型上下文里,不可能分裂。

唯一例外是 error message:错误的上下文太杂,模板化会丢信息,handler 直接组合 Message 字符串。 现有工具里那些精心调过的引导语(例如 set_setting 那句"no setting with key … Call list_settings to see the exact keys")原样保留——它们本来就是给模型看的、实测调过的。 Message 本身不带 "Error: " 前缀:前缀由入口加(agent 入口加,CLI 打 stderr 时不该冗余)。 共用的查找/校验助手同样只给裸 message,故仍留在 agent 侧的那半条命令自己补前缀。

什么算失败:判据是"这次调用还有没有产物",不是"有没有坏事发生"。调用方的用法错误(未知 id、 缺参、写法歧义)→ Error;环境的坏消息(某个引擎加载不上、手册没随包、结果超量被截断)→ 仍走 成功路径,把它作为事实标进 Data、由 Render 如实说出——否则 agent 侧会凭空多出一个 "Error: " 前缀,等于改了行为。同一件坏事在两种模式下会落到两边:extension settings 列清单时 某个扩展声明设置抛错,那是清单里的一格局部事实(其余条目照常列出)→ 成功 + 逐条标注;而单问 那一个扩展时同样的抛错让整条命令没有产物 → Error(CLI 也该为此非零退出)。

3.3 上下文

// 入口注入的一切"环境"。handler 只从这里取,不碰静态单例、不碰 Dispatcher。
internal sealed class CommandContext
{
    public IProject? Project { get; init; }
    public IEditorStateAccess? EditorState { get; init; }    // 当前 part / 量化 / 选区,见 §5.2
    public IAuthorizationPolicy Authorization { get; init; } // 见 §5.1
    public ISideModelAccess? SideModel { get; init; }         // 可选,见 §5.3
    public IUserPrompt? UserPrompt { get; init; }             // 可选,见 §5.4
    public Func<string?> Language { get; init; }
}

3.4 注册表

CommandRegistry宿主级静态注册表,不依赖 UI、不依赖工程。 命令实例无状态(工程与环境全从 CommandContext 走),因此不需要在工程切换时重建—— 这一点和现状不同:现在 SetProject() 每次换工程都要 new 26 个工具。

单栈红利:注册表就是唯一真源。CLI 的命令树、MCP 的工具列表、CLI 的 --help 全由它自省生成, 三者结构上不可能漂移,不需要额外的一致性测试。


4. 结果形状怎么迁(最容易低估的工作量)

现有 handler 直接产人类文本。全部改成 {data} + renderer 是 ~26 个 handler 的返回值重写。 不建议一次推翻,切法:

4.1 read 命令彻底结构化

它们是 CI 断言和 --json 最需要的,也最容易切(本来就在拼列表)。 list_settings / list_keybindings / list_sound_sources / list_effects / list_extension_*Data 是数组,字段就是现在文本里那些列。

一处例外:文档类命令(docs *)的 Data 以文本为主。 它们的产物本来就是文本——把 markdown 章节拆成 JSON 不增加任何信息,反而要在 Render 里复制一份格式化逻辑、与 ManualLibrary.BuildToc() 漂移。故 Data{ text, 元数据 }(语言、是否 fallback 版、mode、命中与否),只有手册检索的命中 列表结构化——它的源头 ManualLibrary.Search 本就是三元组。判据是"这个产物的事实形态是什么", 不是"read 命令一律拆成字段"。

4.2 write 命令的 Data 只回"改了什么"

不需要为 set_setting 设计丰满的结构,{key, oldValue, newValue} 这一小组就够 CI 断言。 人类文本沿用现有措辞。

4.3 截断留在模型入口

ClampToolResult 是"防淹没模型上下文",不是命令面的事。命令面返回完整 Data只有 agent 入口在渲染后 clamp。CLI 和 CI 拿到的永远是全量——否则 --json 管进 jq 会拿到截断的坏 JSON。


5. 四类入口依赖,各自的处置

这四类才是真正决定 scope 的东西。handler 搬家是机械劳动,这四类是设计。

5.1 授权闸门 → 按入口注入策略

现在是 ToolAuthorization 静态类直读 Settings.AgentAuthorization + 一个可空的 UI confirm 回调, confirm == null 时保守不做。抽成:

internal interface IAuthorizationPolicy
{
    Task<(bool Proceed, string Message)> AuthorizeAsync(AgentAuthorizationRequest request, CancellationToken ct);
}
入口 实现
侧栏 agent 现状:读 Settings.AgentAuthorization,Confirm 档弹内联卡片
CLI(attach) --yes 全放开 / 默认走 stdin 交互确认(把 ActionPhrase() 打给用户)/ --dry-run 等价 ReadOnlyAdvice
MCP 靠工具 annotation 让客户端自己问(readOnlyHint / destructiveHint);服务端侧不再问一遍,因为批准 UI 在客户端——服务端再问一遍是双重询问且没有 UI 可用
headless / CI 必须显式--yes 或按维度配;没给就拒绝所有 Edit 命令并说明原因(不能静默放开)

天花板:经桥进来的一律被 Settings.AgentAuthorization 压顶AuthorizationModes.Stricter)。 上表后两行说的是"这个入口自己想要什么档位",而实际档位 = 声明 ∧ 用户设定。理由:外部进程不该比用户 给自家侧栏 agent 的权限更大——否则用户把面板设成只读建议时,自家 agent 一个字都改不了,任何本机进程 声明 auto 却能随手改工程。压顶之后"桥开着"最坏的后果止于用户设的那一档:默认 Confirm 下,一个无声 连上来的进程只会拿到"需要确认、而这里没法问",写全部落空。

副作用要在文案上说清:--yes 的语义从"照做"变成"在用户允许的范围内尽量做"。宿主在 hello 里回它 当前的天花板,CLI 据此在 --yes 遇到更严的档位时先说一句实话(不说的话,CI 里只看到"没做",会去 怀疑命令本身)。headless 不受天花板约束:那是调用方自己的进程、自己打开的工程,碰不到用户此刻 开着的会话(它能做的事,直接改文件也能做)。

与暂缓项的关系:这件事和"授权按能力分维度"(工程编辑 / 应用配置 / 磁盘文件)是同一件事的两根轴。 本期只做入口维度(每入口一个策略),能力维度仍按暂缓处理——但 IAuthorizationPolicy 的形状要 容得下它,将来加维度不改调用点(9 个调用点本来就只传 Kind)。

5.2 编辑器态 → 显式优先,attach 时可继承

current part / quantization / selection / pianoSelection 是"侧栏 agent 坐在编辑器里"才有的隐式上下文。

internal interface IEditorStateAccess
{
    IMidiPart? CurrentPart { get; }
    IQuantization? Quantization { get; }
    ScriptSelection? Selection { get; }
    ScriptPianoSelection? PianoSelection { get; }
}
入口 实现
侧栏 agent 现状:实时访问器(用户切 part 即变)
CLI attach 默认继承运行中实例的真实编辑器态 + 允许命令参数显式覆盖(如 --part 3)。理由:用户开着界面让外部 agent 干活时,"当前 part"的自然含义就是他正在看的那个
headless 没有编辑器态。依赖它的命令必须显式传参,缺参就报错说明——不能默认成 track 0 part 0,那是在猜

5.3 调模型的那个工具 → 降级而非禁用

list_extensions 会为缺摘要的能力位发一次旁路模型请求(短文档直接用作者原话,长文档才调模型, 按内容哈希缓存)。外部入口没有模型:

  • SideModel == null缓存命中就用,未命中就给作者原话/留空并如实标注"摘要未生成"。
  • 不因此禁用命令——枚举扩展本身是 CLI/CI 最常用的 read 之一。

5.4 问用户 → 不是命令,是入口能力

ask_user_question 在本轮之内等用户答复。它不改任何状态,纯为 agent 自身决策服务。

它不进命令树。改成入口能力:IUserPrompt? 在 context 里, 入口声明支不支持,命令面按能力决定要不要向该入口暴露它

入口 支持
侧栏 agent 支持(现状的内联卡片)
CLI attach 可支持(stdin 提问)
MCP 协议有 elicitation,但客户端支持面不齐——先不做,MCP 侧不暴露
headless 不支持,暴露即错误

5.5 编辑器动作 → 判据下沉,状态随回报走

工程数据那一轴由脚本 API 覆盖,应用与编辑器自身的状态(切工具、开合侧栏、播放、缩放、吸附档位…) 从前外部完全够不着。补法见 issue #150,三条要点:

  • 穷尽面是另一份注册表ActionRegistryTuneLab/Input/ActionRegistry.cs)里是"用户够得着的每一件事"; 快捷键表 Keymap引用其中值得占一个手势的那批。两个集合的判据不同——快捷键是筛选,动作面是穷尽 ——拿筛选过的集合当穷尽面的真源,结果只会是"要么设置页被几百条没人想绑的东西灌满,要么命令面永远 停在那二十几条"。
  • 可用性判据下沉到动作上EditorAction.Unavailable 返回"为什么现在跑不动",ActionRegistry.Execute 先问它再跑。从前这些守卫散在各个 Execute 闭包里(没工程就 return、没选中就 return),键盘路径下 "按了没反应"用户看得见,而外部触发看不见——那就会回报"已执行"而什么都没发生。
  • 状态读与触发同源action run 的回报直接带上执行后的编辑器状态(editor status 的同一份数据): 动作里有一半是即发即忘的(transport.play 是切换,不是"开始播"),没有配套的状态读,这个动作面是瞎的。

action run 作为命令恒声明 CommandKind.Edit(它可能写,这是唯一诚实的声明),具体这一次要不要过闸门 由被触发动作的 ActionKindAppState 不过(每次播放都弹确认卡片这个面就没法用了)、ProjectEditDestructive 各占一档 WriteKind,措辞把"撤销栈救得回"与"救不回"分开说。

按面补注册表时的四条口径(二期)。动作面是「用户在界面上够得着的事」的镜像,故一条一条按入口来, 不按功能想象来:

  • 终态优先于切换。界面上的 toggle 按钮在动作面上分解成一对终态动作(侧栏页签 → sidebar.show* / sidebar.hide,播放 → transport.start / transport.pause),外部驱动因此不必"先读一次现在是什么状态 才敢按",两次 show 同一个面与一次的结果相同。反过来,当那个 toggle 本身就是用户心里的一件事 (Space 的播放/暂停、Ctrl+P 的参数面板、F11 的全屏),就保留切换语义——它与按钮、与快捷键是同一件事。
  • 闭集逐值收,动态集给选择器参数。成员固定且有限的(5 支笔、6 个侧栏面、18 档量化)逐值开一条终态 动作,id 由事实算出而不是抄一张并行的表(quantization.1_12 = 三连基数 3 × 细分 4)。成员随工程变的 (参数面板的合成参数轨、参数栏钉选项)逐成员开 id——那些 id 明天就不存在了,"一经发布不改" 无从保证。它们的形状是一个动词 + 一个闭集里的成员:判据是"加一个成员是加一个,不是加一条动作"。 见下面的 §5.6。
  • 可绑是另一层筛选。18 档量化终态不进 keymap,只有「更细 / 更粗」这一对占手势;侧栏、波形带、 设置窗可绑但不预占默认键。判据仍是"值不值得占一个手势",与"够不够得着"分开(见 EditorAction)。
  • 只镜像已有的能力。动作面不是新功能的入口:AudioEngine 里没有循环播放,那"循环区间"就不是覆盖率 的洞、而是功能缺失;视口缩放/滚动与音频导出是刻意不收(§11)。

覆盖率清单见 docs/action-coverage.md:界面上每个入口逐条认领(指向一条动作/命令, 或说清它为什么不该在那儿),并由 ActionCoverageTests 钉住——新加一个按钮而没认领就会红,那不是障碍, 是提问「这个入口,外部该不该够得着」。

5.6 选择器参数:动作面上唯一的带参形状

run_action 收一个可选的 argument,被触发动作用 EditorAction.ParameterActionParameter)声明它接什么。 今天有四条:parameter.showSynthesizedTrack / hideSynthesizedTrack(作用在一条合成参数轨上)与 parameter.pinProperty / unpinProperty(作用在一个 note / phoneme 属性上)。四条口径:

  • 值域必须能自省。动态成员集意味着调用方不可能预先知道合法值,故 Values 是闭包、list_actions此刻的合法值连同每个值的当下状态(shown / hidden / pinned…)一起报出去(同 setting list 报 allowed values 的理由)。落在集合外的值不跑并回列合法值——猜错在这个面上的表现是静默什么都没发生。 值太多时截断并如实说是前几个(12 条,同 setting 的口径)。
  • 值域即判据,不另开逐值的可用性委托EditorAction.Unavailable 仍只回答"整条动作现在跑不动" (没开 part、声明面是空的),而"这个值此刻合不合法"就是值域本身。故既有那批无参动作的签名一个字都没动 (加性)。真出现"值在集合里、但这一个值还是不能跑"的动作时再加一个接值的判据委托。
  • 闸门要点名成员。授权卡片上"跑一下隐藏合成参数轨"没说清隐藏的是哪一条,故 WriteKind.EditorAction / EditorActionDestructive 的措辞带上那个值的 label(同 ExtensionActivationChange 要点名包)。 今天这四条都是 AppState、不过闸门,但机制在那儿——将来带参的 ProjectEdit 动作不必回头再补。
  • 带必填参数的动作不可绑。一条绑定只有手势、没有参数,按下去无从知道作用在哪个成员上;v1 刻意不做 "绑定携带参数"(keybinding-system.md §10 把它留给 v2),故这批动作只从命令面与界面入口触发, list_actionsbindable 恒 false。可选参数的动作不在此列,见 §5.7。

已经逐值收了的闭集不回收成带参的一条quantization.* 那 18 档、sidebar.show* 那 6 条)。四条理由: 成员是静态闭集,没有"逐成员开 id 会爆"这个问题;那些 id 已经发布,而 id 是 Keybindings.json 的键与 菜单绑定的锚点;侧栏那 6 条可绑,改成带参就把它们从"可绑"降级成"不可绑"(净损失);而带参的一条与 逐值的十八条在自省上并无高下——list_actions 两种都报得出。判据始终是那一条:成员会不会随工程变

为什么这不是把视口捞回来的口子。「滚到某个 tick」要的是一个连续量、不是闭集里的一个成员。 选择器参数收的是「界面上本来就摆着一排、成员随工程变」的那类入口,不是任何缺参数的东西的通用入口。 视口定位后来确实做了,但没有走这条路:它是一条命令(editor reveal,§6.1)——那是这段话的结论 成立的样子,不是它的反例。


5.7 可选修饰符参数:同一件事,副作用管到哪

四条移调动作(note.transposeUp / transposeDown / octaveUp / octaveDown)各带一个可选参数 parameterssync / keep):移调要不要把音符底下的音高线与自动化曲线一起搬走。它与 §5.6 的选择器 参数是两种东西,判据分得开:

  • 选择器参数选的是作用对象(哪条合成参数轨、哪个属性),一个动词配一个成员,必填——不给值就无从 知道要作用在哪儿。
  • 修饰符参数说的是同一件事的副作用范围可选——不给值动作照样跑,走那条缺省行为。

为什么不按 §5.5 逐值开动作:修饰符是上去的,不是加一个成员——四条移调 × 两种副作用 = 八条; 而且这批动作都带手势,键盘按下去无从携带参数,缺省行为只能有一条。逐值开八条反而让"按 Shift+↑ 等于 哪一条"变成新的含糊问题。四条口径:

  • 缺省行为必须说出来ActionParameter.DefaultBehavior 必填(注册时断言),list_actions 报成 default 字段、文本里是"without a value:"那一行,且点名是哪个设置;每个值的 State 另报此刻哪个是 缺省(current default),故调用方不必先读一次设置才知道不给值会发生什么。
  • 要确定性就显式给值。外部驱动(CLI / MCP / agent)没有"用户勾了什么"的上下文,同一条命令在两台机器上 行为不同是不能接受的;给了值就与设置无关。缺省那条路径是留给手势与界面菜单的——那里"按我在设置里 选的来"正是用户的预期。
  • 可选参数的动作可以绑手势Keymap.Register 只拒必填参数的动作,故这四条 bindable 仍是 true (手势路径不带参数、走 EditorAction.Execute)——与 §5.6 那批正相反。
  • 加性。既有动作与那 4 条选择器参数动作的声明一字未改;ActionRegistry.Register 的执行路径校验按 "参数可选与否"分两支:必填 = Execute 与 Parameter.Execute 恰有其一,可选 = 两条都要有。

6. 命令树与迁移清单

6.1 group 划分

9 个 group,noun-first:project script docs extension source effect setting keybinding sandbox (搬家之后又加了三个:appeditoraction,见下)

app group(搬家之后新加)app info 报的是命令跑在哪个装置里——版本与构建号、用户数据与 日志在哪、有没有编辑器、出了问题去哪说。与 project 分开是因为工程是用户的文档,这里说的是那一份安装。 它存在的理由是排障与反馈:外部 agent 查出问题后不该自己去提 issue(结论错了撤不回来),而要把证据交给 用户由他决定——而"你这是哪一版"此前没有任何命令答得出。回报里同时给出那份证据清单(版本+构建号 / 日志相关行 / list_extensions 输出 / 最小复现脚本),故三个入口看到的是同一份要求。 刻意不含扩展清单、设置、工程信息(那是另外三条命令的事),也不含 legacy 兼容层状态 (extension list 已逐包给出 Skipped 与原因)。

editor group(issue #150)editor status编辑器此刻的界面状态——在播吗、播到哪、拿着哪支笔、 参数面板开着没、键盘焦点在哪个编辑面、钢琴窗里开着哪个 part。与另外两条刻意不重叠:app info 说的是 "命令跑在哪一份安装里",project status 说的是用户的文档,这里说的是会话(不随工程保存,撤销栈里 也没有它)。它存在的理由是动作面里有一半是即发即忘的,见 §5.5。

editor status 还报此刻有没有东西挡在界面前面(模态框 / 系统文件选择器),这一条排在回报的最前面。 理由是一个容易被漏掉的事实:模态框期间 Avalonia 跑的是嵌套消息循环,Dispatcher 照常泵,于是命令桥 照常应答——外部因此可以在用户屏幕被一个框锁着的时候跑一串命令、条条回报成功,而用户一个字都看不见、 也点不了。那是最难被发现的一种「假装成功」(每一条单独看都没错)。它同时解释了另外两件事:action runallowPrompt 为什么存在,以及此刻挪过去的视野为什么用户可能没看见。action run 的回报与 editor status 共用同一份状态文本,故触发完一条动作也会看到这句——那正是它最有用的时刻。

两路来源,因为可见性不同(见 BlockingUi):自家的模态窗不必登记,Avalonia 自己数得出 (Window.IsDialog 恰好就是"被 ShowDialog 弹出来的");系统文件选择器根本不是 Avalonia 的窗口, 谁也数不到,只能由调用处声明一段作用域。故有一条宿主纪律:弹系统选择器一律走 OpenFilePickerTracked / SaveFilePickerTracked,别直接调 StorageProvider——直接调的那一处, 在它挡着界面的整段时间里状态会报"没东西挡着"。

只报,不代答:让外部去点那个框上的按钮是另一件事,判据也不同(界面上的按钮属于某条已经被触发的 动作,不是独立的一件事;何况按钮文案是本地化的,跨语言认不住),故没做。真要做,先做的应该是 「agent 自己捅出来的那个框,让它自己收尾」——那不是替用户做决定,与 allowPrompt 是同一条线的延伸。

editor reveal(收尾时补的,§11 那条「视口不收」的部分翻案)是它的写侧对应物:把用户的视野挪到 你说的那个地方去——你找到了他要的那段,或者你指着某处说这里不对。定位可以是轨、轨里的某个 part、 或一段 tick;编排区与钢琴窗的时间轴一起走(同 transport.gotoStart,只挪一条的话用户在另一个窗里 看到的还是原处)。

翻案的边界在 §11:翻的是定位,不是缩放手势。手势没有参数、也不指向任何东西,而 reveal 恒要一个 「哪里」——这正是 §11 自己留下的那半句话。缩放只作为后果出现:装不下才缩小,且永不替用户放大 (把缩放猛拉到一个音符上,反而让人找不着北)。

为什么是命令而不是带参动作:§5.6 自己写着「滚到某个 tick 要的是一个连续量、不是闭集里的一个成员」; 何况带必填参数的动作不可绑手势,把 ActionParameter 扩成能接一个数,走完一圈还是只能从命令面调。

不过闸门(同 ActionKind.AppState):它只改用户此刻看到什么,不碰工程数据、不动播放头、撤销栈里 也没有它。唯一有后果的是 open——把钢琴窗切到目标 part 改的是用户的编辑目标(Ctrl+A 选什么、 下一个按键落在哪),故它默认关着。默认路径上不留死角:目标 part 没开时照样滚编排区,并在回报里明说 「钢琴窗现在开着的是 X;要切过去传 open:true」,一个来回就能自纠。回报给的是挪完之后实际看得见的 范围,故整段没装下时它读不成「都给你看到了」。

action group(issue #150)action list / action run —— 用户在编辑器窗口里能做的事,外部也够得着。 一条 action run --id不是每个动作一条命令:动作面要穷尽(二期会有几百条),逐动作开命令会立刻 把工具面撑爆,与脚本层收口工具爆炸是同一个道理。

覆盖率清单补上的两族(issue #150 三期之后)

  • project open —— 打开一个工程文件,换掉用户此刻开着的那份。它只能是命令:动作面无参, file.open 只能弹个选择器让人自己挑,而"打开这个路径"要一个参数。三道闸的顺序有意——没有编辑器 (headless 的工程在启动时就定了)、路径不可用、有未保存改动就直接拒绝(早于授权:卡片上写的是 "打开哪个文件",用户在那儿点允许并没有同意丢掉自己的活儿)。
  • extension install / uninstall / cancel-uninstall —— 此前只有 extension enable(启停、不动文件)。 两条宿主硬约束决定了它们的形状:装可以立刻生效(解压 + 加载 + 音源引擎急切 Init),而卸与重装 必须等重启(运行中的进程锁着那些 dll),故卸载只是标记、装同名包直接拒绝——重启整个应用是用户的决定, 命令面不替他做。

preset group(覆盖率清单的最后一个桶)preset list / apply / save / delete / rename —— Part 面板顶上那一行预设的外部面。一条 part preset 是声音的快照(声源 + part 属性 + 各自动化轨的 默认值),刻意不含任何时间轴内容,故"应用一条预设"改的是这个 part 怎么唱,不是它唱什么。 它只能是命令(同 project open 的判据):动作面无参,而这一族每件事都要参数——哪条预设、 用在哪个 part、存成什么名字。应用确实写工程数据(那本该归脚本面),但预设的内容在用户的配置目录里, 脚本面读不到,故这条通道无可替代——与 script run-saved 同一个理由。part 按 1-based 轨号 + part 号定位, 与 project status / editor status 报的号同一套。

两处知情差异(不是漏做,是这个入口没法照抄界面):preset rename 撞上已有的名字直接拒绝, 而界面会弹确认框问要不要替换——这里没人可问,而替换掉用户的另一条预设不是能猜着做的事; preset save 只在替换已有的那条时过闸门(新建一条不拦,同 save_script)。

effect 的实例 / 链 preset 实现暂缓(issue #141)不构成挡住 part preset 的理由:三种同住一个 Presets\ 文件夹、后缀名唯一声明种类,届时给这几条加一个 kind 参数即可(加性),不必回头改形状。

project save / project save-as(收尾时补的一族):把用户此刻开着的那份文档存回磁盘。

它们只能是命令,判据与 project open 同一条:动作面无参,file.save 只能存回它自己那个路径, 而"存到这个路径"要一个参数。两条命令都不弹任何框——菜单那条 file.save 在工程从未保存过时会转去弹 文件选择器,而命令面没人应答那个框,故这里在动手之前就据 HasSaveTarget 拒绝并指向 save_project_as

project export 的分野是语义,不是路径。export 写一份副本:文件落地,用户的工程照旧指向 老文件、照旧带着未保存标记。save 这两条是真的保存:走用户按 Ctrl+S 的同一条下游——改写那个文件、 把工程的保存路径挪过去、清掉未保存标记,撤销栈救不回其中任何一件。

之所以不为命令面另造一份"自己写盘的副本实现"(那看上去更简单、也不必碰 Editor),是因为副本会造出 两份真相:文件是新的,而用户在标题栏上看到的仍是"未保存",调用方却以为存过了——那只是把"假装成功" 换了个形式。副本这个语义已经有人承担了,就是 export。

顺带修掉一处旧疾:Editor.SaveToFile 从前把序列化失败与写盘失败都咽进日志。对着屏幕的人还能从标题栏的 星号发现没存下,而命令面照搬就会回报"已保存"而文件根本没写成,故它改成返回 string?(null=存下了, 否则是为什么没存下);菜单那条路忽略返回值,行为不变。

headless 里两条都如实拒绝并指向 export_project:那里没有"用户开着的文档"这个东西 (工程在启动时就由 --project 定了,见 IProjectFileAccess),而 headless 跑完想留下结果, 要的本来就是 export 那个语义。

project export-audio(收尾时补的一条,§11 那条"音频导出不收"的翻案):把工程渲染成一个音频文件。

翻案的理由在 §11,这里只说形状。它与 project export 不是一族:那条写的是工程文件(.tlpx/.mid…), 这条写的是声音(.wav/.mp3/.flac/.ogg)。格式由扩展名定,采样率/位深/码率取工程自己的导出设置 (用户在导出侧栏里设的那一份,脚本面可读可写)——路径是这次调用说了算的东西,其余的是工程的属性。

真正的难点是"等",不是"渲染"AudioEngine.ExportMaster 拉的是 AudioGraph 此刻的数据、不等任何人; 界面上的导出之所以行得通,是因为用户看着状态带自己等到全绿才按下去——那个"等"是人做的,不在代码里。 照搬到命令面就会在合成还没跑完时静默产出静音段,而回报仍说"导出成功"。故这条命令自己把合成驱动到落定 (SynthesisCompletion),再看链尾上到底是什么:

  • 超时(默认 5 分钟)→ 什么都不写,如实报还剩多少。半截音频比没有音频更坏:它看起来是成品。
  • 有失败段 → 默认拒绝并点名坏在哪(那些范围会被导成静音);要 allowIncomplete 才导,且回报里 仍然逐条列出。
  • 有降级段 → 只警告:那是 effect 某级失败后放的未处理音频,能听,只是不是应有的结果。两者后果不同, 故分开说——混成一句话用户就分不清要不要重做。

为什么要自己派活:编辑器里派活的是 Editor 那个 50ms 定时器,headless 进程里没有它,不自己派就永远 等不到。故 SynthesisCompletion.DriveOnce 做的是与 SynthesisNext 同样的事,只是窗口取全轴(要的是 "全都合成完"而不是"播放头附近优先");编辑器里与那个定时器并存无害,两者都在数据线程上串行。

一期只导混音到一个文件。多轨、选区、分文件是要不要做的问题,不是判据问题——界面上那三个入口 (导出混音 / 单轨导出 / 导出侧栏的按钮)在 action-coverage.md 里已一并改判到这条命令,并注明缺口。

project synthesize / project synthesis-status(后来补的两条):把合成驱动到落定并等它,以及 只看此刻的状态。

补的是命令面自己的一个空洞:脚本面能把合成产物固化成用户数据(part.lockPitch / lockAutomation / note.lockPhonemes),却没有任何办法让合成发生——那三处的文档都在说 "false means there was no synthesis output — usually: not synthesized yet",而在 headless 里那恒为真。 在这两条之前,跑批脚本唯一能顶起合成的办法是 export-audio 导一个根本不要的音频文件(它顺带把合成 驱动到落定),荒谬且昂贵:为了拿音素时间白渲染一遍全曲。

三件事值得记下判据:

  • synthesize 只在 headless 可用,这是命令面上「编辑器 → 不可用」的第一例(project save / open 是反方向:缺了编辑器做不到)。理由不是"危险",是写不出一张诚实的授权卡片export-audio 在有窗口的进程里能成立,靠的是前面挡着模态框,而锁住界面几分钟的正当性来自"换来一个文件";这一条 换不来任何东西。不挡框则更糟——用户边编辑边合成,派活反复作废,很可能永远不 Done,最后回报 timeout,而真实原因是用户在动工程,那个结果无从解释。何况有编辑器的进程里合成本来就有主人 (那个 50ms 定时器),外部再插一手,"谁负责把它跑完"就没人说得清。状态查询没有这个问题 (纯读、零代价),故那一条两边都给。
  • Kind = Read。它让引擎跑起来、于是内存里多出合成产物——那与"按一下播放"同级,不是一次编辑: 不改工程数据、不写盘、不进撤销历史。代价(占住这台机器)写在文档第一句,不靠 kind 去表达。
  • 范围过滤把一个变量劈成了两个DriveOnce 原本用一个 busy 同时当派活预算与完成判据;接了 时间窗 × 轨/part 过滤之后这两件事分家:预算必须数全工程的在飞数(EffectTaskGate.Limit 是全局 闸门,只数范围内的话范围外正在跑的活儿看不见,这一轮就会越过上限、资源失控),而 Done 只能看 范围内(否则范围外有活儿在跑就永远判不出完成,等到超时为止,而那个超时无从解释)。过滤持有的 是对象引用而非 1-based 编号:编号只活在参数面上,一进来就解析成对象,于是没有哪一层还需要 重新数一遍"第几个"。

"落定"不等于"合成好了":范围内一个 part 都没有产出时 Done 同样为真。那是跑批里最容易被当成成功的 一种失败(音源在这个进程里不可用、或那些 part 根本没有音符),故回报里一并给出"多少个 part 现在有产出" ——这条命令的全部目的就是让产出存在,那它就该回答产出在不在,而不只是"我等完了"。

故意不塞进普通 group 的三样(它们和原子命令不是一个物种):

  • script run / script run-saved —— 图灵完备逃生口,参数是一段 JS
  • sandbox run —— 探测器,写入不碰用户数据、不过闸门
  • docs * —— 文档查阅,渐进式披露的出口

6.2 逐条迁移

现工具 命令路径 Kind 依赖处置
get_project_overview project status Read 纯读 IProject,零障碍
export_project project export Edit 闸门(恒,路径任意);ProjectExport(+Overwrite)
run_script script run Edit 闸门 + 编辑器态;走 ScriptWriteExecutor 不变
run_saved_script script run-saved Edit 同上 + 按名读库 + 入参解析
list_scripts script list Read 注入 project/编辑器态(为 eval getScriptInfo
read_script script read Read 零障碍
save_script script save Edit 闸门(仅覆盖已存);ScriptOverwrite
delete_script script delete Edit 闸门(恒);ScriptDelete
get_script_inputs script inputs Read 编辑器态(eval getInputConfig
get_script_api docs script-api Read 纯静态文本,零障碍
get_manual docs manual Read 纯静态,读 ManualLibrary
list_extensions extension list Read 调模型降级(§5.3)
get_extension_introduction extension introduction Read 零障碍
list_extension_routing extension routing Read 零障碍
set_extension_routing extension set-routing Edit 闸门;RoutingChange;重启生效
set_extension_enabled extension enable Edit 闸门;ExtensionActivationChange;重启生效
list_extension_settings extension settings Read 密钥只报有无
set_extension_setting extension set-setting Edit 闸门;ExtensionSettingChange;密钥恒拒
list_sound_sources source list Read 三层钻取不变
list_effects effect list Read 分层不变
list_settings setting list Read SettingsRegistry
set_setting setting set Edit 闸门;SettingChangeAgentWritable=false 三项仍拒
list_keybindings keybinding list Read Keymap
set_keybinding keybinding set Edit 闸门;KeybindingChange;冲突在落地那刻重查
run_in_sandbox sandbox run Sandbox 不过闸门;headless 下天然可用
ask_user_question 不进命令树 入口能力(§5.4)

内置 agent 的工具面由注册表合成(见 §9),因此这张表不是"工具消失了", 而是"工具变成了命令的一种投影"。


7. 传输层

attach 模式的连法:宿主起一个 NamedPipeServerStream,外部进程连它, 跑 4 字节长度前缀的 JSON-RPC 2.0

几个已定的点:

  • 另开管道,不复用 "TuneLab"。那个管道的语义是"把命令行参数转发给运行中实例"(单实例逻辑), 载荷是逐行文本;混进 JSON-RPC 会让两个协议共用一个名字。新名字另取。
  • 不做 HTTP/端口。管道天然限本机、天然带 Windows ACL,不需要选端口、不需要处理端口占用, 也不会被同机其它进程扫到。跨平台上 .NET 的 named pipe 落到 Unix domain socket,语义一致。
  • 凭据文件:宿主把管道名 + 一次性 token 写进用户数据目录下一个文件(PathManager 加一项), 外部进程读它连回来。这样"谁能连"由文件系统权限决定,不需要自己发明认证。
  • 设置里有开关,默认关。开启即写凭据文件,关闭即删。这条进 SettingsRegistry (它是"换台机器还成不成立"意义上的真设置),并且必须 AgentWritable=false—— 不能让 agent 自己打开自己的远程通道。
  • 一次一个连接够用(外部 agent 是串行的)。并发连接的隔离留到有需求再说。
  • 管道两头都加 PipeOptions.CurrentUserOnly:宿主侧把 ACL 限到本用户(Unix 上落到 0700 的 socket 文件),客户端侧则在连之前认一下"对面是不是同一个用户的进程"——同机多用户下,别人放一个同名管道等着, 我们就会把命令(以及那次授权确认)发给它。凭据文件的权限仍是门槛,这一条只是让连都连不上比 "连上了但 token 不对"更早发生。

已落地TuneLab/Bridge/):BridgeProtocol(帧 + JSON-RPC 常量,宿主与 CLI 共用同一份定义)、 BridgeCredentialsConfigs/CommandBridge.json:管道名 + 每次开桥现生成的 token + pid)、 BridgeSession(握手 / commands/list / command/execute / 反向的 authorization/confirm)、 CommandBridge(设置开关的订阅 + 接受循环)。开关是 SettingsRegistry.CommandBridgeEnabled, 默认关、AgentWritable=false

两处实现上的要点,改这块代码前先知道:

  • 读循环不能等命令跑完。Edit 命令会反过来问客户端"这次做不做",而那个回答正是从同一条连接上读回来的 ——在读循环里 await 命令,就是在等一条自己不去读的消息(写这条时真踩了一次,测试里表现为超时)。 故命令在独立任务上跑,写口上锁。
  • 答得含糊一律当拒绝。授权这件事上,缺字段/写错的回答不能算同意。

8. headless

headless = 不开窗口、不建音频设备,但插件加载了、工程在内存、命令能跑、合成能跑

复用已有的两处机制:

  • 启动:Program.InitCoreServices() —— 与真实启动同一份初始化(配置/翻译/路由/启停/键位/插件上下文), 因此"headless 下的行为"和用户看到的一致。
  • 合成:PumpableSynchronizationContext + 驱动循环 —— 沙箱已证明非 UI 线程上泵 SyncContext、 真引擎 Init/CreateSession/合成、读回真实音素全部成立。

headless 特有的三件事:

  1. 没有编辑器态(§5.2)——依赖它的命令必须显式传参。
  2. 没人点卡片(§5.1)——授权必须显式给。策略为 null 时一条 Edit 都不做(且说清是"没配授权"); CLI 走的是另一条同样诚实的路:声明 confirm 但 canAsk=false,于是命令回报"需要确认但这里没法问"(§8.2)。
  3. 数据目录应当可隔离——CI 里跑要用 TUNELAB_DATA_DIR 指向临时沙盒, 否则会读写开发机/CI 机上真实的用户数据目录(设置、插件、脚本库全在那儿)。 ScreenshotBot 已经这么做了,直接沿用。

唯一真正的新工程量:headless 不加载 UI 层任何东西,但有些 handler 需要 Dispatcher.UIThreadScriptWriteExecutor 就是),headless 下要给它一个可泵的等价物。其余都是既有机制的拼装。

8.1 已落地(TuneLab/Headless/HeadlessHost.cs

一条专用线程装上可泵的 SynchronizationContext,在它上面依次:InitCoreServices()AudioUtils.Init(codec)解码器不是播放设备,音频 part 要靠它读 wav/mp3)→ LegacyCompatLoader.Wire()ExtensionManager.LoadExtensions() → 音源引擎急切 Init(须早于挂工程,否则 part 一 Activate 就回落到 空会话且无回建路径)→ 建 ProjectDocument 并挂工程 → 组出 CommandContextEditorState / SideModel 为 null,MainThread = PumpDispatcher)→ 装上 HostCommandContext.Provider → 跑调用方给的 body, 其 await 续体由驱动循环泵回来。收尾换一个空工程触发 Detach/Dispose,短暂续泵让在飞的销毁落地。

反转了原计划里的"起 Avalonia headless 平台":那条路(ScreenshotBot 走的)会把 Skia / 字体 / 平台 一整套拉起来,只为一个我们并不需要的窗口系统。实地核对的结果是不必:UI/GUI/ 之外全仓库只有 UiThreadDispatcherDispatcher.UIThread(那正是 §5 抽掉的那一样),扩展管理器与音源管理器零 Avalonia 依赖,AudioEngine 里数据层真正用到的(SampleRateAudioGraph.AddTrack)都是静态量、不 Init() 也成立。 故只装真正需要的那几样。留下的风险是某个第三方插件在 Init 里碰 Avalonia——真撞上时升级只动这一个文件。

工程从哪来--project <file> 给了就照"打开"那条路装载(DeserializeNative + native 元数据), 没给就新建空工程。刻意不补轨道颜色(编辑器打开时会补一个呈现层默认色)——headless 不呈现, 补了反而会把呈现层的默认写进随后导出的文件里。

不给插件的 Destroy() 设时限:收尾跑的是第三方代码,而合理的收尾本来就可能很慢(刷缓存、 等子进程干净退出)。宿主无从分辨"慢"与"卡",切断反而会坏在插件自己的数据上,而那种损坏是静默的 ——比挂住更糟。故一直等。能做也该做的是让卡住这件事可见VoicesManager / InstrumentsManagerDestroy 逐引擎把"正在拆谁"写进日志(并逐个接住抛错,一个引擎收不了尾不该带走其余引擎), headless 这边等久了在 stderr 上点一句(只是提示,什么都不放弃)。开发期真撞到过一次:某引擎的 Destroy() 阻塞等一个永不完成的 Init(),进程就停在那儿——根因是我们自己漏了一步启动初始化 (见下条),而当时唯一能把矛头指对地方的就是那行日志。

启动必须整条走 InitCoreServices(),不许只挑自己用得着的那几样。它现在也负责把进程环境 摆正(清掉 NoDefaultCurrentDirectoryInExePath——开发环境的终端会注入它,置位后 cmd 不再从工作目录 解析可执行文件,经 cmd 相对路径拉起辅助进程的插件会静默起不来)。无头宿主最初只抄了 Main 里 "看着有关"的几行、漏了这一条,后果是:某插件的辅助进程没起来 → 它的 Init 永远等不到回答 → 它的 Destroy() 阻塞等 Init → 整个进程挂死,而日志上什么异常都没有。同一个宿主的启动步骤只该有 一处定义,别在各入口各抄一份。

keybinding * 在 headless 下无事可做,且必须【这么说】:命令目录(id / 默认手势 / 作用域)是 编辑器在构建时连同 Execute 委托一起注册的,没有编辑器就没有目录。两条命令按 ctx.EditorState == null 如实回答"这个进程没有编辑器",而不是"还没注册"(暗示等等就有)或"没有这个 id"(暗示名字写错了)—— 那两句会让调用方白等,或对着一个必然找不到的 id 反复试。空 id 不在此列:那是调用方的笔误, 有没有编辑器都一样。(25 条命令全量跑一遍,这是唯一暴露出来的降级。)

ExtensionManager.LaunchPendingUninstalls() 刻意不跑:那是给用户装卸扩展收尾的, 无人值守的进程不该替他执行。

日志不回声到控制台FileLogger 加了 echoToConsole):这个进程的 stdout 属于命令结果, --json 的输出必须能直接喂给解析器。

legacy 兼容层要单独搬一次:主程序把 TuneLab.Hosting.Compat.Legacy.dll 散拷进自己的输出目录 (不是 Content),故不会随 ProjectReference 流到 TuneLab.Cli。不补这一步,同一台机器同一个数据目录下, 命令行看到的扩展面会与应用不同——那种差异排查起来极难。

8.2 CLI 的 headless 与批量

  • tunelab --headless <group> <verb> … 单条;tunelab [--headless] --commands <file|-> 一串。
  • 批量是同一个进程、同一个工程:后一条看得见前一条的编辑。一条一进程做不到这件事 (进程一退工程就没了),而"开工程 → 跑脚本改 → 导出 → 断言"正是 CI 要的形状。
  • 首条失败即止并非零退出:让后面的命令在一个已经不对的状态上接着跑,只会把"哪一步坏了"埋掉。
  • 回声($ project status)打 stderr、结果打 stdout,故 --json 时 stdout 仍是一串干净的 JSON。
  • 一行的切词:空白分隔、双引号成组、组内 "" 表示一个字面双引号。不用反斜杠转义——这些行里 最常出现的就是 Windows 路径。
  • 数据目录仍是 PathManager.TuneLabFolder;CI 用 TUNELAB_DATA_DIR 隔离。

顺带修掉的两处②期的话不算数(都属于"入口说了假话",不是新功能):

  1. canAsk(execute 的新参数,布尔,缺省 true)。声明 confirm 说的是"要问",canAsk 说的才是 "问得着"——非交互的 shell 里两者不同。缺了它,命令会把"根本没法问"说成"用户拒绝了",而当时并没有 任何用户被问过。补上后走的是早就写好的 cannot_ask 那支("Confirmation is required … but no UI is available to ask")。CLI 另在 stderr 上先说一次,免得 CI 里的人以为写生效了。
  2. [a] always 现在真的不再问。裁决切档"由策略实现方自己完成",而桥那侧的策略只活一次调用—— 记住这件事的只能是客户端:AuthorizationState 一旦被抬到 auto,此后每次 execute 都声明 auto。 单条命令时这件事看不出来(进程随即退出),批量时才露馅。

8.3 CI 用例:tests/headless/smoke.ps1

pwsh tests/headless/smoke.ps1。临时 TUNELAB_DATA_DIR 沙盒 + stdin 一律重定向到空文件 (那正是 CI 里的样子,也让它在开发机的交互终端里跑出同样的结果、不会停在确认提示上)。 八组断言:离线 help / 无授权时读照跑写不落地且说清是问不着而非被拒 / 给了 --yes 整串跑通且后一条 看得见前一条 / 装载刚导出的工程 / --json 的 stdout 干净可解析 / 命令失败退 1 / 用法错退 2 / 工程打不开时一条命令都不跑。

(暂不新增跑它的 workflow——本仓目前没有任何跑测试的 CI,那是另一个决定。)

同一套配方也用来测具体特性:tests/headless/path-picker.ps1 把脚本夹具直接写进沙盒的 Scripts/、把 .tlx 解到沙盒的 Extensions/,再经 script inputs / script run-saved / extension settings 断言一个新 config 类型(PathPickerConfig)的措辞与取值。它是现成的模板:断言落在“脚本 main 收到的那个值”上而不是退出码——写坏的 inputs 曾经静默失效(拿默认值照跑、回报仍说跑成功), 只看退出码的自动化会“通过”而什么都没测到。


9. 三个入口适配器

9.1 内置 agent(第一个消费者)

IAgentTool 不再被手写实现,而是从注册表合成:一条命令 → 一个工具, Name 由路径转下划线(setting setset_setting,与现有名字对齐,模型侧零感知), Description = DocumentationExecuteAsync = 调命令 + Render + ClampToolResult

让内置 agent 成为第一个且唯一的消费路径,是验证这层没做错的唯一可靠办法: 如果合成出来的工具面和现在不等价,实测立刻会发现。

9.2 CLI

  • tunelab <group> <verb> [args],默认打人类文本,--jsonData 原样。
  • 退出码:0 成功 / 1 handler 失败 / 2 用法错 / 3 连不上宿主。
  • --help 必须离线可用(不连宿主也能列出命令树)。这要求命令树的元数据能在不启动 宿主全套的情况下自省——注册表是纯声明,满足这条。
  • --headless 切到 §8 那条路。
  • docs 组不连宿主也能答ICommand.NeedsHost == false 声明"答案只取决于程序自带的东西" (编译进来的 API 参考常量、可执行文件旁的 Resources/Manual),CLI 于是就地跑(LocalRunner)—— 外部 agent 第一次接触 TuneLab 时,读 API 参考与查手册零启动、零前提,TuneLab 开着没开着都行。 批量清单里一条都不需要宿主时同样一个进程都不起。就地答只允许 Read(封条在 CommandRegistryTests): 会写的命令要过宿主的闸门与授权,绕过去是不能接受的。
  • --search <regex>:把全部命令的帮助语料(Brief + 完整说明)过一遍正则,命中的给命令名加一段 上下文。同样离线(注册表是纯声明)——"有没有一条命令能干这件事"是外部 agent 最先要问的问题, 不该以启动为前提。
  • object / array 参数收的是 JSON 文本,解析不了即用法错(退出码 2),不许降级成"当没给"——那会让 脚本拿默认值照跑而回报仍是"跑成功了",无人值守的跑批于是"通过"了却什么都没测到。而把 JSON 塞进 Windows 命令行有两个互不相干的坑,故诊断分两支说话:收到的实参里一个双引号都没有时,几乎 必然是外层 shell 剥掉了它们(PowerShell 的 Start-Process -ArgumentList 拼出的是一行命令行, CommandLineToArgvW 会吃裸引号)——那时报"JSON 语法错、位置 1"是把人往错方向指,故这一支单独说、 并把收到的实参回显出来当证据;引号活着但路径里的反斜杠没双写才是另一支。 两支的措辞刻意不重叠:反斜杠那句提示只在真有反斜杠时附上——无条件附在每条坏 JSON 后面的话, 它就成了永真的噪音,测试再也钉不住它(smoke.ps1 第 7 组就这么假绿过一阵)。

已落地TuneLab.Cli/,程序集 TuneLab.Cli):命令树 / 单条命令的 --help 全部离线(直接读 CommandRegistry,不连宿主);参数按该命令的 schema 逐个校验并转型,认不出的参数名报用法错而不是 静默忽略;--jsonData、默认打渲染文本;--yes / --dry-run / 默认 stdin 交互确认; 退出码 0/1/2/3 如约。连不上时区分"桥没开"与"宿主已退出但凭据文件还在"——两者的下一步不同。 --headless(§8.1)与 --commands(§8.2)也已落地:同一份解析、同一份授权语义,只是命令送去的地方不同。

入口叫法:打印前把 agent 工具名换成自己的。命令面的文本第一读者是模型,故引用别的动作时写的是 agent 工具面上的名字("Call list_settings to see the exact keys"、"Change one with set_setting(key, value)") ——那些名字在命令行里根本不存在,照原样打出去等于让人去调一条 --help 里查不到的命令。 注册表给出对照表(CommandRegistry.PathsByAgentToolName),CLI 在每个打印点机械替换 (TuneLab.Cli.CommandText.ForCli):list_settingstunelab setting list,调用式的参数表留着但 加一个空格(tunelab setting set (key, value)),读成附注而不是函数调用。--jsonData 一个字 不动——那是机器契约。不去改那 180 多处文案,是因为一处文案同时说两套叫法只会更差;机械替换让每个 入口各自看到一致的名字,新加的命令自动跟上(CliCommandLineTests 有一条封条盯着"有没有漏")。

程序集名不能叫 tunelab:它会与被引用的 TuneLab.dll 在同一输出目录里同名(Windows 不区分 大小写)而互相覆盖。命令名 tunelab 是安装期的事——装包时给 TuneLab.Cli.exe 落一个 tunelab 入口即可。

发得出去才算有(这三层缺一层,上面所有东西对用户就都不存在):

  1. 随包发CIUtils/pack-installer.ps1TuneLab.Cli 发进与 app 同一份 stage(只多出 TuneLab.Cli.exe 那几个文件,Avalonia/Skia/TuneLab.dll 全共享);build-artifacts.yml 同样把它 拷进便携产物。
  2. 敲得着:靠绝对路径。一期刻意不做 PATH——tunelab 在不在 PATH 上不是"能不能跑通"的 问题,只是"要不要多打一串路径":实测一个只拿到接入说明的陌生 agent 全程用绝对路径跑完七条命令、 零失败(它自己就把示例里的 tunelab 替成了那个路径)。而改 PATH 是动用户的环境,代价一侧却是 实打实的:向导上要给选项、静默自更新时无人可问、卸载要摘干净、读写注册表要绕开变量展开,且必须 真装一次/卸一次才敢发。收益只是省几个字,故推到后续;接入说明改为把"示例里的 tunelab 就是这个 绝对路径"明写出来(ExternalAgentOnboarding)——实测那个 agent 自己推对了,但那是它多想一步, 不该让它想。 将来真要做,两条别丢:不能把安装目录本身挂上 PATH(那里躺着 Skia/NAudio 一堆原生 dll,而 PATH 参与 Windows 的 dll 搜索——挂上去会改变别的进程加载 dll 的结果,是能把不相干程序弄坏且极难归因的 副作用;正解是单独一个只放转发脚本的目录);读写 PATH 必须不展开变量DoNotExpandEnvironmentNames + 保持 REG_EXPAND_SZ:经 Environment.SetEnvironmentVariable 写回会把用户 PATH 里的 %USERPROFILE% 烧成字面路径)。卸载要先摘 PATH 再删目录,否则留下一条 谁也看不出来源的死路径。
  3. 说得清:设置窗「通用」页末尾一颗"复制接入说明"按钮,把外部 agent 要知道的东西一次给全 (ExternalAgentOnboarding)。文案在点下去那一刻生成,因为里面有三样只有此刻才知道的事实: 命令行的真实绝对路径(文件不在就明说这份安装没有命令行,绝不吐无效路径)、tunelab 到底能不能直接敲 (便携解压的那份没有 PATH 入口)、命令桥此刻开没开(没开就在最前面多一段"只有我能去开它",且位置与 字样取用户界面上看到的译文)。口吻是用户在对他自己的 agent 说话——那段话是用户当自己的话贴出去的。 刻意不复述"连不上怎么办":那句 BridgeClient 已经会说,且能分清"桥没开 / 宿主没跑 / 桥不应答" 三种情形;文案是主动快照、命令行的消息是被动兜底,抄一遍只会有一天与它对不上。 结尾要求 agent 只把记进自己的记忆(有这道门、门在哪、四条铁律),并明确不要记命令清单、 参数与版本号——那些随版本漂移,记住了就会自信地调一条不存在的命令。

9.3 MCP server

  • stdio 独立进程,不由宿主 spawn。理由:宿主没开时,server 仍然活着并能在对话里 告诉 agent"请先启动 TuneLab"。反过来(宿主 spawn、绑端口)的失败模式是工具从 agent 的工具列表里静默消失,agent 连"怎么修"都说不出来——那是死胡同。
  • 工具面按 group × {read, edit} 合成,不是一命令一工具: setting_read / setting_edit / extension_read / … 约 15 个, inputSchema{subcommand: <该组该类的叶子枚举>, arguments: <object>}。 描述里带每个 subcommand 的 Brief,让 tools/list 自描述。 纯读的 group(source / effect / docs)只出 read 工具。
  • annotation 如实:*_readreadOnlyHint*_editdestructiveHintsandbox 两者都不标。
  • 为什么不一命令一工具:25 条命令各带完整 JSON Schema,会挤占 agent 的工具选择上下文, 且随能力增长线性膨胀。按 group 合成让工具数随增长而不是随叶子增长。
  • 为什么不折成单个 invoke_command:那样 tools/list 只剩一个不透明的洞, agent 不预先知道就发现不了能力;且安全标注退化成"一律按最坏情况标",客户端的批准 UI 失去意义。

已落地tunelab mcpTuneLab.Cli/Mcp/)。照上面的形状做,另有六处是动手时才定的:

  • 住在命令行里,不是又一个二进制tunelab mcp 接管 stdin/stdout 作协议通道。理由是它要的东西 (BridgeClient、runner、参数校验、文本换名)都已经在那儿,而命令行本身随发布产物一起分发 (build-artifacts 把它 publish 进同一个目录)。客户端那侧的配置就是 { "command": "<...>/TuneLab.Cli.exe", "args": ["mcp"] }。仍然满足"独立进程、不由宿主 spawn": tools/listCommandRegistry 就地自省,列能力不需要 TuneLab 开着;真要执行才连桥,连不上时 回的是"请先启动 TuneLab / 去设置里开桥"这句话本身(作为工具结果,不是从工具列表里消失)。
  • 一个 group × 一个类别 = 一个工具(今天 21 个:13 个 group 摊开成 20 个,加一个 tunelab_help)。后缀是 read / edit / run ——sandbox 那条既非只读也不碰用户数据,压进任何一边都会让安全标注说谎(见 CommandKind),故它 自成一个后缀 sandbox_run
  • tools/list 带 Brief,全文按需。33 条命令的 Documentation 合起来 24K 字符,全塞进去等于每次会话 先吃掉几千 token——正是"按 group 合成"想省下的那部分。故摘要进列表、全文走 tunelab_help (同 get_script_api / get_manual 的渐进式披露)。参数说明是例外,它留在 tools/listarguments 是个自由 object,agent 不知道字段名就根本没法调用——那是"调用所必需",与"什么时候该用它" 不是一回事。
  • 换名照 CLI 的办法McpText):命令面的文本里引用别的命令时用的是 agent 工具面的名字 (list_settings),那些名字在这里不存在,故机械换成本入口的叫法 (setting_read with subcommand "list")。表在注册表、换名在入口。
  • 授权:每次执行声明 auto 且 canAsk=false。这不是提权——天花板是用户在 TuneLab 里设的档位 (BridgeAuthorizationPolicy),声明 auto 只是说"这个入口自己不再加码";批准 UI 在 MCP 客户端那边, annotation 已经如实标了哪些工具会改东西。用户设成 confirm 时,改动被如实拒绝 ("要确认,但这里没有 UI 可问")而不是被偷偷做掉,也不会被说成"用户拒绝了"——实测验过这一条。
  • 连接懒连、断了重连一次。server 的寿命是整个对话,而 TuneLab 可能中途才启动、也可能退出又开起来: 开机就连会把"宿主没开"变成启动失败,一次性连上不管则会在宿主重启后永远失败下去。只在连接层面 出问题时重试,命令自己失败不重跑(那既不会变对,还可能把一次写做成两次)。

封条:tests/TuneLab.Tests/McpToolsTests.cs(工具名快照、每条命令恰好够得着一次、annotation 如实、 参数在列表里而全文不在、换名彻底)+ tests/headless/smoke.ps1 第 14 段(真起一个 server 进程, 刻意不开 TuneLab:initialize 协商、tools/list、tunelab_help、只读文档照答、要宿主的命令如实说 "请先开桥"、未实现的方法回协议错误而不是工具结果)。


10. 分期

内容 可验证的产出
前置改名(§2):数据层 ICommand / Command / CompositeCommand*UndoCommand,腾出裸名 编译通过、既有测试全绿(纯改名,Document/ 外只动 3 行)
CommandRegistry + 契约 + 26 个 handler 搬家(结果形状按 §4 切)+ 内置 agent 改成合成消费者 内置 agent 实测与现在等价(回归现有测试文档)
已完成:25 条命令全部进注册表,TuneLab/Agent/Tools/ 只剩 AskUserQuestionTool(按 §5.4 是入口能力,不进命令树)。四类入口依赖的抽象都已落地:IAuthorizationPolicy(§5.1)、IEditorStateAccess(§5.2)、ISideModelAccess(§5.3)
管道 bridge + 凭据文件 + 设置开关 + CLI(全部 read 命令 + 少数 edit) 开发者能从终端驱动运行中的 TuneLab
已完成:桥与 CLI 都在(见 §7 / §9.2 的落地小节)。命令不分 read/edit 地全部可用——闸门按连接声明的档位走,故 edit 不需要另开名单
headless + CI 用例 CI 里无人值守跑一串命令并断言
已完成HeadlessHost + CLI 的 --headless / --project / --commands + tests/headless/smoke.ps1(见 §8.1–8.3)。反转了"起 Avalonia headless 平台"的原计划,理由记在 §8.1
MCP server 壳 外部客户端连上,用已有订阅额度驱动
已完成tunelab mcpTuneLab.Cli/Mcp/),工具面由注册表合成(今天 21 个),宿主没开也列得出能力(见 §9.3 的落地小节)

①是大头且用户不可见;②开始有实感。不建议把①②合并推进——①的验证靠"内置 agent 行为不变", 掺进新入口会分不清是搬家搬坏了还是新入口的问题。


11. 明确不做

  • 通用"执行任意 KeyCommand"命令播放/试听 —— 已反转(issue #150)。原判据是 "那些 Execute 是 UI 委托、依赖焦点/选中态、会弹模态框,结果不可预期",反转不是因为顾虑消失了, 而是三条顾虑各自有了对应的闸(§5.5):后果分档决定过不过授权(不是"一口气全放回来")、 Unavailable 判据把"依赖焦点/选中态"变成一句可回报的原因(不是空跑一趟)、Prompts 判据把会弹模态的 动作默认拒掉(要显式声明有人在场)。命令面的基调是全面:用户能做的事外部都该够得着,够不着的 地方要有说得出的理由。
  • 另存为 / 打开:不禁止,但动作面上的那两条弹的是要人应答的模态,故 action run 默认拒绝、 要 allowPrompt 显式说"用户就在机器前"。带路径的「打开」后来成了一条命令(project open,见 §6.1)—— 那是同一件事的另一种形状,不是绕过这一条:它不弹框,代价是未保存时直接拒绝。 框真弹出来之后 editor status 会报「有个框挡着」(见 §6.1)——那是「用户其实不在机器前」唯一能被外部发现的地方。 导出音频仍是人在环决定 —— 已翻案(收尾):见下面单列的那条。
  • 导出音频 —— 已翻案(收尾),成了 project export-audio。原判据只有一句:渲染期界面锁住 好几分钟,「要不要现在把这台机器占住」是人在环的决定。但那句话在 headless 里根本不成立——那儿没有 界面可锁;而连着窗口跑时,「人在环」恰恰是授权闸门该管的事:把代价写进卡片(那一档的措辞明写 「要跑完整合成与混音、可能几分钟、期间窗口锁住」)让用户自己决定,比替他决定「你不能这么做」要诚实。 翻案之后才露出真正的难点,而它与界面锁不锁无关:界面上的导出不等合成(直接拉 AudioGraph 此刻的 数据,用户是看着状态带自己等的),命令面照搬会静默产出静音。故那条命令自己把合成驱动到落定, 超时就什么都不写,有失败段默认拒绝——形状见 §6.1。
  • 视口(缩放 / 滚动):模拟滚轮缩放仍然不收(issue #150)。它是以鼠标位置为轴心的连续手势 (操作态,见 keybinding-system.md §0),而外部没有鼠标;且不改变任何结果,只改变用户此刻在看什么。 选择器参数落地之后这一条不变(§5.6):那收的是「界面上本来就摆着一排、成员随工程变」的入口 (一个闭集里的成员),而「滚到某个 tick」要的是一个连续量。 但这一条自己留下的那半句已经兑现(收尾):它当时写着「真正有用的那件事是让用户看到我改了哪里, 它要一个 tick 或一个对象作参数」——那就是 editor reveal(§6.1)。翻的不是缩放手势,是定位; 两者的分界正是这一条自己划的:手势没有参数、也不指向任何东西,而 reveal 恒要一个「哪里」。 缩放在 reveal 里只作为后果出现:目标装不下才缩小,且永不替用户放大。
  • 播放循环与区间:不是覆盖率的洞——TuneLab 今天没有循环播放这个功能(AudioEngine 里没有)。 动作面只镜像界面已有的能力;要循环,先得在界面上有循环。
  • 授权的能力维度拆分(工程编辑/应用配置/磁盘文件)。本期只做入口维度,但形状要容得下它。
  • MCP elicitation(客户端支持面不齐)。
  • MCP 的 resources 与 promptsresources 是给客户端"主动附进上下文"的静态可寻址内容,而我们手上 够格当资源的只有文档,它们本来就是检索式的(手册有 query / section,十几章):做成 resource 要么整份塞进上下文(正是渐进式披露想避免的),要么另造一套 URI 空间表达"第几章",凭空多一层。 真正诱人的是把当前工程当 resource 让客户端自动跟随,但那要 resources/updated 推送,而命令桥是 纯请求-响应——要接就得先在桥上加一条变更通道,成本远大于收益。prompts 是"用户可选的提示模板", 我们没有这类资产:内置 agent 的系统提示是宿主内部的,现造几条模板就是在入口层发明产品形态, 与"只镜像已有的能力"这条基调相反。 故 initialize 只声明 tools 能力,客户端问 resources/list 拿到的是 -32601(而不是空列表—— 空列表读起来像"有这个能力但没有内容")。
  • MCP 的取消 / 进度 / 并发:知道,暂不做,三条各有代价但都不咬人。①客户端的 notifications/cancelled 收下即忽略——一条跑很久的 script run 中止不了(CLI 那侧有 Ctrl+C,这里没有 等价物);②没有 notifications/progress,长命令期间客户端看不到动静;③请求串行处理,一个慢命令 会把后面的(含 ping)挡在后面,客户端若据此判超时就会断连。要接的话三条是一件事——per-request 的 取消令牌 + 并发派发,届时一起做。
  • 并发多连接 / 多窗口寻址。等有需求。

12. 风险

  1. ①期期间内置 agent 会短暂不稳。缓解:分批搬(先 read 后 edit),每批跑对应的既有测试文档。
  2. 结果形状改造会误伤调过的措辞。缓解:renderer 里逐字沿用现有文本,只把结构化 Data 加上去; error message 不动。
  3. headless 下 Dispatcher.UIThread 的替代是唯一真正的新代码,也是最可能出并发 bug 的地方。 缓解:复用沙箱已实测的泵,不另造。
  4. 凭据文件 + 管道开关是新的攻击面。缓解:默认关、AgentWritable=false、 凭据文件靠文件系统权限、不开网络端口。