Apps own their command line through `ctx.cmdlineArgs`
应用通过 `ctx.cmdlineArgs` 持有自己的命令行
After profiles, compositions were installable but their command lines were not. `apps/cli` still declared the Web flag family (`--host`, `--port`, `--dev`, `--workspace-root`, `--trusted-host`) and the one-shot task positional, then derived patches for row ids it hardcoded (`webserver`, `api-gateway`, `connection`, `web-runtime`). An out-of-tree app such as [turtle-ui](https://github.com/deepseek-harness/turtle-ui) c
English
Problem
After profiles, compositions were installable but their command lines were not. apps/cli still declared the Web flag family (--host, --port, --dev, --workspace-root, --trusted-host) and the one-shot task positional, then derived patches for row ids it hardcoded (webserver, api-gateway, connection, web-runtime). An out-of-tree app such as turtle-ui could contribute rows but had no way to accept a flag: dsh --profile tui --resume <session> had nowhere to be parsed, and dsh --profile web --help printed the launcher's help rather than the web app's.
Decision
The launcher parses only what it owns — --profile, --patch, the config dumps — and hands everything after its own flags to the booted tree verbatim. The split is positional: the first token the launcher does not recognize starts the app's arguments (commander's passThroughOptions + allowUnknownOption + helpOption(false)). A bare dsh -h, which has no app to hand the flag to, still prints the launcher's own help.
The new @deepseek-ai/dsh-cmdline package owns the handoff. A launcher calls provideCmdline(ctx, host) before any entry mounts, providing ctx.cmdlineArgs (whose whole interface is get(): readonly string[]) and ctx.appExit. Any ordinary app plugin may inject cmdlineArgs, call parseCmdline(ctx, program) with its own commander program, and provide the resolved value as an app-owned service from the program's action. Its Loader row carries no launcher marker or special kind, and the launcher does not inspect the composition for an owner. Multiple plugins may read the same immutable snapshot; a profile with no reader ignores its app arguments. Rows configured from a provider inject its service and read direct lazy config expressions (port: !!js ctx.webStartup.port ?? 3080), so a flag beats the value written beside it and nothing is written back into any row.
The boot mounts the composition once. Cordis holds each row until its injections are active; Loader then interpolates that row's !!js against the injection-ready plugin context immediately before activation. Include keeps nested row expressions raw until their target row reaches this point. --help leaves the provider's service absent, so dependent rows never activate, and a live patch reload interpolates again against the service that remains active, so a served port cannot be silently reset.
The shipped apps moved their flags into their bundles: dsh-web-app owns the Web family, and dsh-headless owns the task positional and rejects a missing task as a usage error. apps/cli/src/web.ts is gone; runProfile no longer knows any flag-target row id. Out of tree, turtle-ui gained --resume <session> / --session <id> the same way, which is the design's real validation: an installed plugin added a flag with no launcher change.
Two further consequences. Loader mounts sibling rows concurrently, so one row can activate while another still mounts or while the whole boot is rolling back; the Web bundle therefore publishes its URL only after its own Loader tree settles. The Web bundle's runtime plugin owns the harness-source prompt section too, so dsh web and dsh --profile web boot identically without Web-specific launcher setup.
Why Loader owns the ordering
Three framework facts shape the mechanism:
- A profile's rows arrive inside the root include's
patchesoption. Include declares theEntryGroup.keytree-carrier marker (as Group does), so Loader keeps its config — entry and patch lists, including Include's ownpath— literal instead of recursively evaluating nested!!jsnodes in the Include context; each expression resolves in its target row's fiber. - Cordis activates a fiber only after all declared injections are active. Immediately before each activation, Cordis runs the
internal/configwaterfall against the fiber's own context; Loader's listener interpolates the raw config after Cordis snapshots its injected services. - Provider replacement and HMR must preserve the same contract. Fiber reactivation re-runs the waterfall, HMR carries the raw config to the replacement fiber, and a pending row accepts option changes without prematurely evaluating expressions against absent services.
This leaves dependency ordering in Cordis activation and Loader interpolation, which own it. Rows keep their inject and config, Loader mounts the composition once, and the launcher only provides argv and process-lifecycle services.
Alternatives considered
- Writing the resolved values into each row (a config update per row, plus a patch layer handed back to the launcher so a reload could not undo it): it worked, but it meant patches travelling from an app to the launcher and back, two mechanisms for one fact, and a recycle whose correctness depended on Loader restart internals. The maintainer rejected the round trip; the service the rows read replaced all of it.
- Releasing rows by clearing their
inject: it worked in isolation and failed on the real web tree, because clearinginjectis exactly what loses the plugin's static injections. The failure is silent until a plugin reads a service it declared. - Launcher-managed two-pass mounting: it can make a provider active before readers are applied, but duplicates the composition, makes ordering a launcher concern, and conceals the Loader defect that nested expressions were evaluated in the include context rather than the target row's injected context.
- The launcher running each bundle's command function before boot (no Cordis involvement): strictly earlier than "boot, then help", but it makes app startup a second plugin protocol outside the tree. An ordinary
cmdlineArgs-injected provider keeps one protocol and remains dumpable and patchable. - A launcher-enforced command-line owner: rejecting zero or multiple readers would arbitrate overlaps such as
-h, butget()is an immutable read and normal composition may need several app-owned services. Plugins therefore share the snapshot and own any parser interaction through ordinary composition. instanceof CommanderError: an out-of-tree plugin brings its own commander copy, so the class identity differs and a printed--helpwas rethrown as a fatal load failure. Commander's control-flow errors are detected structurally instead.
Consequences
- An app's flags, help text, and usage errors live with the rows they configure; adding a flag to an installed plugin needs no launcher change.
- The launcher recognizes no app row at all: the telemetry row remains its only composition probe (for the environment switch), SIGTERM exits 0 on every surface, every boot watches its user patch layers, and the one-shot runner exits through
ctx.appExitlike any other app. --helpleaves every row that depends on the provider's service pending and requests bounded exit; unrelated rows may activate concurrently before teardown.- An app-owned service has no statically declared provider: a bundle shipping consumer rows without that provider fails at settlement with pending entries naming the service, not at load.
- A user patch that replaces a row's whole
configdrops its expressions, and with them the flag's precedence for that row. - Launcher flags must precede app arguments; a first app argument equal to
weborpluginselects that subcommand instead,-V/--versionremains launcher-owned before that boundary, and the launcher's parser consumes one--, so a literal--for the app needs-- --. --dump-confignever runs app command-line providers, so it prints the composition before any app argument is resolved and rejects an invocation that carries app arguments.
中文
问题
profile 落地之后,组合可以安装,命令行却不能。apps/cli 仍然声明着 Web flag 家族(--host、--port、--dev、--workspace-root、--trusted-host)和一次性任务位置参数,再为自己硬编码的行 id(webserver、api-gateway、connection、web-runtime)派生 patch。像 turtle-ui 这样的树外应用能贡献行,却无处接受一个 flag:dsh --profile tui --resume <session> 没有地方可供解析,而 dsh --profile web --help 打印的是启动器的 help,而不是 web 应用的 help。
决策
启动器只解析属于自己的部分(--profile、--patch、配置 dump),并把自己 flag 之后的一切原样交给引导起来的配置树。切分按位置进行:启动器不认识的第一个 token 就是应用参数的起点(依靠 commander 的 passThroughOptions + allowUnknownOption + helpOption(false))。裸的 dsh -h 没有可交付的应用,仍然打印启动器自己的 help。
新包 @deepseek-ai/dsh-cmdline 持有这次交接。启动器在任何条目挂载之前调用 provideCmdline(ctx, host),提供 ctx.cmdlineArgs(其全部接口就是 get(): readonly string[])与 ctx.appExit。任何普通应用插件都可以注入 cmdlineArgs,用自己的 commander program 调用 parseCmdline(ctx, program),再在 program 自己的 action 中把解析出的取值作为应用自有服务提供出去。它的 Loader 行不携带启动器标记或特殊类型,启动器也不会检查组合中的所有者。多个插件可以读取同一份不可变快照;没有读取方的 profile 会忽略自己的应用参数。由提供方配置的行注入其服务,并在惰性配置表达式中直接读取它(port: !!js ctx.webStartup.port ?? 3080),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。
boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 !!js。Include 会保留嵌套的行表达式,直到目标行到达这一时点。--help 会让提供方服务保持缺失,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。
已交付的各应用把自己的 flag 搬进了组合包:dsh-web-app 持有 Web 家族,dsh-headless 持有任务位置参数,缺少任务时按用法错误拒绝。apps/cli/src/web.ts 已删除;runProfile 不再知道任何 flag 目标行 id。在树外,turtle-ui 以同样的方式获得了 --resume <session> / --session <id>,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag,启动器毫无改动。
还有两条后果。Loader 会并发挂载兄弟行,因此一行可能已经激活,而另一行仍在挂载,或整次 boot 正在回滚;所以 Web 组合包只会在自身的 Loader 配置树结算后公布 URL。另外,Web 组合包的运行时插件也持有 harness 源码提示词段,因此 dsh web 与 dsh --profile web 无需 Web 专用启动器设置即可按完全相同的方式启动。
为什么由 Loader 持有顺序
三条框架事实塑造了这套机制:
- profile 的各行位于根 include 的
patches选项内部。 Include 声明了EntryGroup.key树载体标记(与 Group 相同),因此 Loader 让它的配置——条目与 patch 列表,包括 Include 自己的path——保持字面值,而不是在 Include 上下文中递归求值嵌套的!!js节点;每个表达式都在其目标行的 fiber 中解析。 - Cordis 只在所有声明的注入都已激活后才激活 fiber。 每次激活前一刻,Cordis 会基于 fiber 自身上下文运行
internal/configwaterfall;Cordis 快照注入服务之后,Loader 的监听器再插值原始配置。 - 提供方替换与 HMR 必须保持相同契约。 fiber 重新激活时会重跑 waterfall,HMR 会把原始配置带给替换 fiber,而待处理行可以接受选项变更,不会针对缺失服务提前求值表达式。
这样,依赖顺序仍由负责它的 Cordis 激活与 Loader 插值流程处理。各行保留自己的 inject 和配置,Loader 只挂载一次组合,启动器只提供 argv 与进程生命周期服务。
曾考虑的替代方案
- 把解析出的取值写进每一行(逐行一次配置更新,外加交还给启动器的一层 patch,使重载无法撤销它):它能工作,但这意味着 patch 在应用与启动器之间来回传递、同一件事有两套机制,以及一套其正确性依赖 Loader 重启内部细节的回收重建。维护者否决了这次往返;供各行读取的服务取代了这一切。
- 通过清空行的
inject来放行:孤立测试可行,在真实 web 树上失败,因为清空inject恰恰会丢失插件的静态注入。在插件真的去读它声明过的服务之前,这个失败是静默的。 - 由启动器管理两趟挂载:它可以让提供方先于读取行激活,但会重复组合、把顺序变成启动器职责,还掩盖了 Loader 的缺陷——嵌套表达式在 include 上下文而不是目标行的注入上下文中求值。
- 由启动器在 boot 之前运行每个组合包的命令函数(完全不经过 Cordis):严格早于「先 boot 再 help」,但这会让应用启动成为配置树之外的第二套插件协议。使用注入
cmdlineArgs的普通提供方只保留一套协议,并且仍可 dump、可 patch。 - 由启动器强制指定命令行所有者:拒绝零个或多个读取方可以裁决
-h等重叠项,但get()是不可变读取,普通组合也可能需要多个应用自有服务。因此插件共享该快照,并通过普通组合持有各自解析器的交互。 instanceof CommanderError:树外插件会带来自己的一份 commander 副本,类身份因此不同,已经打印出来的--help会被重新抛成致命的加载失败。改为按结构识别 commander 的控制流错误。
后果
- 应用的 flag、help 文本和用法错误与它们所配置的行放在一起;给已安装的插件加一个 flag 不需要改动启动器。
- 启动器完全不识别任何应用行:telemetry 行仍是它唯一的组合探测(用于环境开关),SIGTERM 在所有 surface 上以 0 退出,每次启动都监视用户 patch 层,一次性 runner 像任何应用一样经
ctx.appExit退出。 --help会让所有依赖提供方服务的行保持待处理并请求有边界的退出;无关行可能在拆除前并发激活。- 应用自有服务没有静态声明的提供方:交付了消费行却缺少对应提供方的组合包会在结算时失败,报出指向该服务的待处理条目,而不是在加载时失败。
- 用户 patch 若整体替换某行的
config,会连同其中的表达式一起丢掉,该行上 flag 的优先级也随之消失。 - 启动器的 flag 必须写在应用参数之前;如果应用的第一个参数恰好等于
web或plugin,会选择对应的子命令;-V/--version在该边界之前仍归启动器持有;而且启动器的解析器会消耗掉一个--,因此要给应用传一个字面量--需要写成-- --。 --dump-config从不运行应用命令行提供方,因此它在任何应用参数被解析之前打印组合,并拒绝携带应用参数的调用。