目标形状:TuneLab 的每个可被外部驱动的动作只实现一次("末端命令"), 上面接多个入口——内置 agent、CLI、MCP server、CI 里的无头进程——都调同一份。
本文承接 agent-tools.md(现有 26 个工具及其归属判据)与 script-inputs-and-action-surface.md(统一动作面 / 分级授权)。 三者共用一句原则:SSOT 约束的是执行面,不是入口数。
tl 脚本动作面已经稳了(单一 commit、单一闸门),26 个 agent 工具覆盖了工程编辑、环境感知、
设置/快捷键/路由/扩展设置、探测沙箱。此刻要给外部 agent 开 CLI 和 MCP 两个入口,只有两条路:
- 各自再实现一遍:三份 handler,必然漂移,且每加一个能力要改三处。
- 提出一层命令面:末端命令一份,入口是薄适配器。
选后者。代价是一次对用户不可见的重构(现有 26 个工具要搬家),收益是此后每个新能力自动出现在 所有入口上。不做这层,CLI 和 MCP 就是工具面原样抄两遍。
这层不是"给 agent 用的":CI 回归测试、批量转换、开发期手工验证都是它的消费者。
动手前必须知道的既有事实:
| 事实 | 位置 | 对本设计的影响 |
|---|---|---|
| 工具注册表长在 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:130 开 NamedPipeServerStream("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.ClampToolResult(Settings.AgentMaxToolResultChars) |
截断是模型入口的关切,不属命令面;CLI/CI 不该被截断。见 §4.3 |
"命令"一词此前在本仓指两样东西,都不是这里说的命令:
| 层 | 符号 | 含义 |
|---|---|---|
| 数据层 | ICommand / Command / CompositeCommand(TuneLab.Hosting.Foundation/Document/,命名空间 TuneLab.Foundation) |
撤销栈里的一次可撤销突变 |
| 输入层 | KeyCommand(TuneLab/Input/KeyCommand.cs;issue #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 变更。- 引用面:
ICommand25 处 / 12 文件,其中 11 个文件就在Document/自己目录里;Document/之外总共只有 3 行——MidiPart.cs:563、MidiPart.cs:569(new Command(...))、SoundSource.cs:149(: ICommand)。CompositeCommand只有 3 处、全内部。
用户面也叫 command:CLI 写 tunelab setting list、MCP 描述里写 command——与代码同词,
不需要在文档和终端之间做词形转换。落地时在术语表加一行,把三者的分工写清楚。
// 一个末端命令:路径 + 参数 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 会让所有入口的安全标注说谎。
// 成功 → 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 也该为此非零退出)。
// 入口注入的一切"环境"。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; }
}CommandRegistry 是宿主级静态注册表,不依赖 UI、不依赖工程。
命令实例无状态(工程与环境全从 CommandContext 走),因此不需要在工程切换时重建——
这一点和现状不同:现在 SetProject() 每次换工程都要 new 26 个工具。
单栈红利:注册表就是唯一真源。CLI 的命令树、MCP 的工具列表、CLI 的 --help 全由它自省生成,
三者结构上不可能漂移,不需要额外的一致性测试。
现有 handler 直接产人类文本。全部改成 {data} + renderer 是 ~26 个 handler 的返回值重写。
不建议一次推翻,切法:
它们是 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 命令一律拆成字段"。
不需要为 set_setting 设计丰满的结构,{key, oldValue, newValue} 这一小组就够 CI 断言。
人类文本沿用现有措辞。
ClampToolResult 是"防淹没模型上下文",不是命令面的事。命令面返回完整 Data,
只有 agent 入口在渲染后 clamp。CLI 和 CI 拿到的永远是全量——否则 --json 管进 jq 会拿到截断的坏 JSON。
这四类才是真正决定 scope 的东西。handler 搬家是机械劳动,这四类是设计。
现在是 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)。
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,那是在猜 |
list_extensions 会为缺摘要的能力位发一次旁路模型请求(短文档直接用作者原话,长文档才调模型,
按内容哈希缓存)。外部入口没有模型:
SideModel == null时缓存命中就用,未命中就给作者原话/留空并如实标注"摘要未生成"。- 不因此禁用命令——枚举扩展本身是 CLI/CI 最常用的 read 之一。
ask_user_question 在本轮之内等用户答复。它不改任何状态,纯为 agent 自身决策服务。
它不进命令树。改成入口能力:IUserPrompt? 在 context 里,
入口声明支不支持,命令面按能力决定要不要向该入口暴露它:
| 入口 | 支持 |
|---|---|
| 侧栏 agent | 支持(现状的内联卡片) |
| CLI attach | 可支持(stdin 提问) |
| MCP | 协议有 elicitation,但客户端支持面不齐——先不做,MCP 侧不暴露 |
| headless | 不支持,暴露即错误 |
工程数据那一轴由脚本 API 覆盖,应用与编辑器自身的状态(切工具、开合侧栏、播放、缩放、吸附档位…) 从前外部完全够不着。补法见 issue #150,三条要点:
- 穷尽面是另一份注册表。
ActionRegistry(TuneLab/Input/ActionRegistry.cs)里是"用户够得着的每一件事"; 快捷键表Keymap只引用其中值得占一个手势的那批。两个集合的判据不同——快捷键是筛选,动作面是穷尽 ——拿筛选过的集合当穷尽面的真源,结果只会是"要么设置页被几百条没人想绑的东西灌满,要么命令面永远 停在那二十几条"。 - 可用性判据下沉到动作上。
EditorAction.Unavailable返回"为什么现在跑不动",ActionRegistry.Execute先问它再跑。从前这些守卫散在各个Execute闭包里(没工程就 return、没选中就 return),键盘路径下 "按了没反应"用户看得见,而外部触发看不见——那就会回报"已执行"而什么都没发生。 - 状态读与触发同源。
action run的回报直接带上执行后的编辑器状态(editor status的同一份数据): 动作里有一半是即发即忘的(transport.play是切换,不是"开始播"),没有配套的状态读,这个动作面是瞎的。
action run 作为命令恒声明 CommandKind.Edit(它可能写,这是唯一诚实的声明),具体这一次要不要过闸门
由被触发动作的 ActionKind 说:AppState 不过(每次播放都弹确认卡片这个面就没法用了)、ProjectEdit
与 Destructive 各占一档 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 钉住——新加一个按钮而没认领就会红,那不是障碍,
是提问「这个入口,外部该不该够得着」。
run_action 收一个可选的 argument,被触发动作用 EditorAction.Parameter(ActionParameter)声明它接什么。
今天有四条: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_actions里bindable恒 false。可选参数的动作不在此列,见 §5.7。
已经逐值收了的闭集不回收成带参的一条(quantization.* 那 18 档、sidebar.show* 那 6 条)。四条理由:
成员是静态闭集,没有"逐成员开 id 会爆"这个问题;那些 id 已经发布,而 id 是 Keybindings.json 的键与
菜单绑定的锚点;侧栏那 6 条可绑,改成带参就把它们从"可绑"降级成"不可绑"(净损失);而带参的一条与
逐值的十八条在自省上并无高下——list_actions 两种都报得出。判据始终是那一条:成员会不会随工程变。
为什么这不是把视口捞回来的口子。「滚到某个 tick」要的是一个连续量、不是闭集里的一个成员。
选择器参数收的是「界面上本来就摆着一排、成员随工程变」的那类入口,不是任何缺参数的东西的通用入口。
视口定位后来确实做了,但没有走这条路:它是一条命令(editor reveal,§6.1)——那是这段话的结论
成立的样子,不是它的反例。
四条移调动作(note.transposeUp / transposeDown / octaveUp / octaveDown)各带一个可选参数
parameters(sync / 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 恰有其一,可选 = 两条都要有。
9 个 group,noun-first:project script docs extension source effect setting keybinding sandbox
(搬家之后又加了三个:app、editor、action,见下)
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 run
的 allowPrompt 为什么存在,以及此刻挪过去的视野为什么用户可能没看见。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—— 图灵完备逃生口,参数是一段 JSsandbox run—— 探测器,写入不碰用户数据、不过闸门docs *—— 文档查阅,渐进式披露的出口
| 现工具 | 命令路径 | 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 | 闸门;SettingChange;AgentWritable=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),因此这张表不是"工具消失了", 而是"工具变成了命令的一种投影"。
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 共用同一份定义)、
BridgeCredentials(Configs/CommandBridge.json:管道名 + 每次开桥现生成的 token + pid)、
BridgeSession(握手 / commands/list / command/execute / 反向的 authorization/confirm)、
CommandBridge(设置开关的订阅 + 接受循环)。开关是 SettingsRegistry.CommandBridgeEnabled,
默认关、AgentWritable=false。
两处实现上的要点,改这块代码前先知道:
- 读循环不能等命令跑完。Edit 命令会反过来问客户端"这次做不做",而那个回答正是从同一条连接上读回来的
——在读循环里
await命令,就是在等一条自己不去读的消息(写这条时真踩了一次,测试里表现为超时)。 故命令在独立任务上跑,写口上锁。 - 答得含糊一律当拒绝。授权这件事上,缺字段/写错的回答不能算同意。
headless = 不开窗口、不建音频设备,但插件加载了、工程在内存、命令能跑、合成能跑。
复用已有的两处机制:
- 启动:
Program.InitCoreServices()—— 与真实启动同一份初始化(配置/翻译/路由/启停/键位/插件上下文), 因此"headless 下的行为"和用户看到的一致。 - 合成:
PumpableSynchronizationContext+ 驱动循环 —— 沙箱已证明非 UI 线程上泵 SyncContext、 真引擎 Init/CreateSession/合成、读回真实音素全部成立。
headless 特有的三件事:
- 没有编辑器态(§5.2)——依赖它的命令必须显式传参。
- 没人点卡片(§5.1)——授权必须显式给。策略为 null 时一条 Edit 都不做(且说清是"没配授权");
CLI 走的是另一条同样诚实的路:声明 confirm 但
canAsk=false,于是命令回报"需要确认但这里没法问"(§8.2)。 - 数据目录应当可隔离——CI 里跑要用
TUNELAB_DATA_DIR指向临时沙盒, 否则会读写开发机/CI 机上真实的用户数据目录(设置、插件、脚本库全在那儿)。 ScreenshotBot 已经这么做了,直接沿用。
唯一真正的新工程量:headless 不加载 UI 层任何东西,但有些 handler 需要 Dispatcher.UIThread
(ScriptWriteExecutor 就是),headless 下要给它一个可泵的等价物。其余都是既有机制的拼装。
一条专用线程装上可泵的 SynchronizationContext,在它上面依次:InitCoreServices() →
AudioUtils.Init(codec)(解码器不是播放设备,音频 part 要靠它读 wav/mp3)→ LegacyCompatLoader.Wire()
→ ExtensionManager.LoadExtensions() → 音源引擎急切 Init(须早于挂工程,否则 part 一 Activate 就回落到
空会话且无回建路径)→ 建 ProjectDocument 并挂工程 → 组出 CommandContext(EditorState / SideModel
为 null,MainThread = PumpDispatcher)→ 装上 HostCommandContext.Provider → 跑调用方给的 body,
其 await 续体由驱动循环泵回来。收尾换一个空工程触发 Detach/Dispose,短暂续泵让在飞的销毁落地。
反转了原计划里的"起 Avalonia headless 平台":那条路(ScreenshotBot 走的)会把 Skia / 字体 / 平台
一整套拉起来,只为一个我们并不需要的窗口系统。实地核对的结果是不必:UI/ 与 GUI/ 之外全仓库只有
UiThreadDispatcher 碰 Dispatcher.UIThread(那正是 §5 抽掉的那一样),扩展管理器与音源管理器零 Avalonia
依赖,AudioEngine 里数据层真正用到的(SampleRate、AudioGraph.AddTrack)都是静态量、不 Init() 也成立。
故只装真正需要的那几样。留下的风险是某个第三方插件在 Init 里碰 Avalonia——真撞上时升级只动这一个文件。
工程从哪来:--project <file> 给了就照"打开"那条路装载(DeserializeNative + native 元数据),
没给就新建空工程。刻意不补轨道颜色(编辑器打开时会补一个呈现层默认色)——headless 不呈现,
补了反而会把呈现层的默认写进随后导出的文件里。
不给插件的 Destroy() 设时限:收尾跑的是第三方代码,而合理的收尾本来就可能很慢(刷缓存、
等子进程干净退出)。宿主无从分辨"慢"与"卡",切断反而会坏在插件自己的数据上,而那种损坏是静默的
——比挂住更糟。故一直等。能做也该做的是让卡住这件事可见:VoicesManager / InstrumentsManager
的 Destroy 逐引擎把"正在拆谁"写进日志(并逐个接住抛错,一个引擎收不了尾不该带走其余引擎),
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。不补这一步,同一台机器同一个数据目录下,
命令行看到的扩展面会与应用不同——那种差异排查起来极难。
tunelab --headless <group> <verb> …单条;tunelab [--headless] --commands <file|->一串。- 批量是同一个进程、同一个工程:后一条看得见前一条的编辑。一条一进程做不到这件事 (进程一退工程就没了),而"开工程 → 跑脚本改 → 导出 → 断言"正是 CI 要的形状。
- 首条失败即止并非零退出:让后面的命令在一个已经不对的状态上接着跑,只会把"哪一步坏了"埋掉。
- 回声(
$ project status)打 stderr、结果打 stdout,故--json时 stdout 仍是一串干净的 JSON。 - 一行的切词:空白分隔、双引号成组、组内
""表示一个字面双引号。不用反斜杠转义——这些行里 最常出现的就是 Windows 路径。 - 数据目录仍是
PathManager.TuneLabFolder;CI 用TUNELAB_DATA_DIR隔离。
顺带修掉的两处②期的话不算数(都属于"入口说了假话",不是新功能):
canAsk(execute 的新参数,布尔,缺省 true)。声明confirm说的是"要问",canAsk说的才是 "问得着"——非交互的 shell 里两者不同。缺了它,命令会把"根本没法问"说成"用户拒绝了",而当时并没有 任何用户被问过。补上后走的是早就写好的cannot_ask那支("Confirmation is required … but no UI is available to ask")。CLI 另在 stderr 上先说一次,免得 CI 里的人以为写生效了。[a] always现在真的不再问。裁决切档"由策略实现方自己完成",而桥那侧的策略只活一次调用—— 记住这件事的只能是客户端:AuthorizationState一旦被抬到 auto,此后每次 execute 都声明 auto。 单条命令时这件事看不出来(进程随即退出),批量时才露馅。
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 曾经静默失效(拿默认值照跑、回报仍说跑成功),
只看退出码的自动化会“通过”而什么都没测到。
IAgentTool 不再被手写实现,而是从注册表合成:一条命令 → 一个工具,
Name 由路径转下划线(setting set → set_setting,与现有名字对齐,模型侧零感知),
Description = Documentation,ExecuteAsync = 调命令 + Render + ClampToolResult。
让内置 agent 成为第一个且唯一的消费路径,是验证这层没做错的唯一可靠办法: 如果合成出来的工具面和现在不等价,实测立刻会发现。
tunelab <group> <verb> [args],默认打人类文本,--json打Data原样。- 退出码:
0成功 /1handler 失败 /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 逐个校验并转型,认不出的参数名报用法错而不是
静默忽略;--json 打 Data、默认打渲染文本;--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_settings → tunelab setting list,调用式的参数表留着但
加一个空格(tunelab setting set (key, value)),读成附注而不是函数调用。--json 的 Data 一个字
不动——那是机器契约。不去改那 180 多处文案,是因为一处文案同时说两套叫法只会更差;机械替换让每个
入口各自看到一致的名字,新加的命令自动跟上(CliCommandLineTests 有一条封条盯着"有没有漏")。
程序集名不能叫 tunelab:它会与被引用的 TuneLab.dll 在同一输出目录里同名(Windows 不区分
大小写)而互相覆盖。命令名 tunelab 是安装期的事——装包时给 TuneLab.Cli.exe 落一个 tunelab
入口即可。
发得出去才算有(这三层缺一层,上面所有东西对用户就都不存在):
- 随包发:
CIUtils/pack-installer.ps1把TuneLab.Cli发进与 app 同一份 stage(只多出TuneLab.Cli.exe那几个文件,Avalonia/Skia/TuneLab.dll 全共享);build-artifacts.yml同样把它 拷进便携产物。 - 敲得着:靠绝对路径。一期刻意不做 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 再删目录,否则留下一条 谁也看不出来源的死路径。 - 说得清:设置窗「通用」页末尾一颗"复制接入说明"按钮,把外部 agent 要知道的东西一次给全
(
ExternalAgentOnboarding)。文案在点下去那一刻生成,因为里面有三样只有此刻才知道的事实: 命令行的真实绝对路径(文件不在就明说这份安装没有命令行,绝不吐无效路径)、tunelab到底能不能直接敲 (便携解压的那份没有 PATH 入口)、命令桥此刻开没开(没开就在最前面多一段"只有我能去开它",且位置与 字样取用户界面上看到的译文)。口吻是用户在对他自己的 agent 说话——那段话是用户当自己的话贴出去的。 刻意不复述"连不上怎么办":那句BridgeClient已经会说,且能分清"桥没开 / 宿主没跑 / 桥不应答" 三种情形;文案是主动快照、命令行的消息是被动兜底,抄一遍只会有一天与它对不上。 结尾要求 agent 只把门记进自己的记忆(有这道门、门在哪、四条铁律),并明确不要记命令清单、 参数与版本号——那些随版本漂移,记住了就会自信地调一条不存在的命令。
- 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 如实:
*_read标readOnlyHint,*_edit标destructiveHint,sandbox两者都不标。 - 为什么不一命令一工具:25 条命令各带完整 JSON Schema,会挤占 agent 的工具选择上下文, 且随能力增长线性膨胀。按 group 合成让工具数随组增长而不是随叶子增长。
- 为什么不折成单个 invoke_command:那样
tools/list只剩一个不透明的洞, agent 不预先知道就发现不了能力;且安全标注退化成"一律按最坏情况标",客户端的批准 UI 失去意义。
已落地(tunelab mcp,TuneLab.Cli/Mcp/)。照上面的形状做,另有六处是动手时才定的:
- 住在命令行里,不是又一个二进制。
tunelab mcp接管 stdin/stdout 作协议通道。理由是它要的东西 (BridgeClient、runner、参数校验、文本换名)都已经在那儿,而命令行本身随发布产物一起分发 (build-artifacts把它 publish 进同一个目录)。客户端那侧的配置就是{ "command": "<...>/TuneLab.Cli.exe", "args": ["mcp"] }。仍然满足"独立进程、不由宿主 spawn":tools/list由CommandRegistry就地自省,列能力不需要 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/list里:arguments是个自由 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、只读文档照答、要宿主的命令如实说
"请先开桥"、未实现的方法回协议错误而不是工具结果)。
| 期 | 内容 | 可验证的产出 |
|---|---|---|
| ⓪ | 前置改名(§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 mcp(TuneLab.Cli/Mcp/),工具面由注册表合成(今天 21 个),宿主没开也列得出能力(见 §9.3 的落地小节) |
①是大头且用户不可见;②开始有实感。不建议把①②合并推进——①的验证靠"内置 agent 行为不变", 掺进新入口会分不清是搬家搬坏了还是新入口的问题。
通用"执行任意、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 与 prompts。
resources是给客户端"主动附进上下文"的静态可寻址内容,而我们手上 够格当资源的只有文档,它们本来就是检索式的(手册有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 的 取消令牌 + 并发派发,届时一起做。 - 并发多连接 / 多窗口寻址。等有需求。
- ①期期间内置 agent 会短暂不稳。缓解:分批搬(先 read 后 edit),每批跑对应的既有测试文档。
- 结果形状改造会误伤调过的措辞。缓解:renderer 里逐字沿用现有文本,只把结构化
Data加上去; error message 不动。 - headless 下
Dispatcher.UIThread的替代是唯一真正的新代码,也是最可能出并发 bug 的地方。 缓解:复用沙箱已实测的泵,不另造。 - 凭据文件 + 管道开关是新的攻击面。缓解:默认关、
AgentWritable=false、 凭据文件靠文件系统权限、不开网络端口。