From f749e048812a7c7bc0977bfbe4ab081d43877904 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 6 Aug 2026 20:52:26 +0800 Subject: [PATCH] docs: record how an app comes to own its command line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Agent Note keeps the three vendored-Loader facts the mechanism turns on — a row's config is resolved and validated when its fiber is created, while it is still waiting; an inject update loses the plugin's static injections; a row cannot be inserted from inside a mounting plugin — with the alternatives they ruled out. --- ...026-08-06-app-owned-command-line.i18n.yaml | 6 +++ .../2026-08-06-app-owned-command-line.md | 45 +++++++++++++++++++ .../2026-08-06-app-owned-command-line.zh.md | 45 +++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md create mode 100644 .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml new file mode 100644 index 0000000000..11165122e5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md +2026-08-06-app-owned-command-line.md: f7db56e298c11f2663f63cc05d71121a03856668 +2026-08-06-app-owned-command-line.zh.md: f17fdc9a78d9baa36852392e11744a585adfe578 diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md new file mode 100644 index 0000000000..f7db56e298 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md @@ -0,0 +1,45 @@ +# Agent Note: Apps own their command line through `ctx.cmdlineArgs` + +Status: implemented + +English | [中文](2026-08-06-app-owned-command-line.zh.md) + +## 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](https://github.com/deepseek-harness/turtle-ui) could contribute rows but had no way to accept a flag: `dsh --profile tui --resume ` 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[]`), `ctx.appExit`, and `ctx.appPatches`. An app consumes them from a **startup row** that injects `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)` with its own commander program; rows the app configures inject that startup service in the bundle patch, so they cannot start before their values are resolved, and `--help` prints, disables those rows, and exits without the app ever starting. + +The shipped apps moved their flags into their bundles: `dsh-web-app` owns the Web family (and enables the `client-hmr` row it now ships disabled, for `--dev`), 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 row id. Out of tree, turtle-ui gained `--resume ` / `--session ` the same way, which is the design's real validation: an installed plugin added a flag with no launcher change. + +Two further consequences fell out of review. An app's decisions are also handed back to the launcher as patches (`ctx.appPatches`), because the launcher re-applies its whole patch stack when a user edits a live patch file: without that layer, an unrelated edit rebuilt every row from its composed options and silently moved a server started on `--port 8080` back to the composed port, dropping `--dev` and the derived `/api` fence authorities with it. And `dsh --profile web` now adds the harness-source prompt section that only the `dsh web` alias used to add — the two paths finally boot identically, which also means a user profile named `web` inherits it. + +## How a waiting row actually receives its values + +Three vendored-Loader facts shaped the mechanism, all found by probe: + +- **A row's config is resolved when the Loader creates its fiber, which happens while the row is still waiting for its startup service.** Writing a new config onto that waiting fiber never reaches the plugin. Each changed row is therefore recycled — disabled, then re-enabled with its new values — which drops the stale fiber and resolves the config again. +- **Updating a row's `inject` loses the plugin's own static injections.** The Loader restarts a replaced row from `runtime.callback`, the unwrapped function, and `Inject.resolve(plugin.inject)` then finds nothing: a row declaring `inject = ['httpServer', 'apiProxy']` comes back unable to read either. Recycling therefore never touches `inject`; the waiting rows are released by providing the service. +- **A row's config is validated at fiber creation too**, so a row whose *required* config the startup supplies (the one-shot runner's `task`) must ship `disabled: true`; making it wait is not enough, because the boot fails before the startup row can run. It only appeared to work because the startup module happened to import first. + +A related constraint: a row cannot be inserted from inside a mounting plugin (`tree.create` returns a prefixed id it then fails to resolve), so a conditional row ships `disabled: true` and startup enables it. Recycling also lets a still-in-flight mount settle first, since disabling alone is not a barrier. + +## Alternatives considered + +- **Releasing the rows by clearing their `inject`** (one atomic update per row): it worked in isolation and failed on the real web tree, because clearing `inject` is exactly what loses the plugin's static injections. The failure is silent until a plugin reads a service it declared. +- **Reading flags from the row's config through `!!js ctx.get('webStartup')`**: config expressions are interpolated when the fiber is created, before the startup service exists, so every waiting row would read `undefined`. +- **The launcher running each bundle's startup function before boot** (no cordis involvement): simplest and strictly earlier than "boot, then help", but it makes app startup a second plugin protocol outside the tree. The maintainer's ruling was a startup *service* other rows depend on, which keeps one protocol. +- **Both apps parsing the same argv** (the one-shot bundle rides over the web bundle): two parsers cannot both own `-h`. A composition has exactly one command-line owner: the layering bundle disables the underlying startup row and names both startup services, so the absorbed rows start on their composed values. +- **`instanceof CommanderError`**: an out-of-tree plugin brings its own commander copy, so the class identity differs and a printed `--help` was 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. +- `--help` cost is a boot: the tree mounts far enough for the startup row to run, then tears down. The rows waiting on that app never start, which is what the maintainer accepted when choosing the service-shaped design. +- A startup service has no statically declared owner: a bundle shipping waiting rows without its startup row fails at settlement with pending entries naming the service, not at load. +- Launcher flags must precede app arguments; a first app argument reading `web` or `plugin` selects those subcommands instead, and the launcher's parser consumes one `--`, so a literal `--` for the app needs `-- --`. +- `--dump-config` never runs a startup row, so it prints the composition before any app argument is resolved and rejects an invocation that carries app arguments. diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md new file mode 100644 index 0000000000..f17fdc9a78 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md @@ -0,0 +1,45 @@ +# Agent Note: 应用通过 `ctx.cmdlineArgs` 持有自己的命令行 + +Status: implemented + +[English](2026-08-06-app-owned-command-line.md) | 中文 + +## 问题 + +profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍然声明着 Web flag 家族(`--host`、`--port`、`--dev`、`--workspace-root`、`--trusted-host`)和一次性任务位置参数,再为自己硬编码的行 id(`webserver`、`api-gateway`、`connection`、`web-runtime`)派生 patch。像 [turtle-ui](https://github.com/deepseek-harness/turtle-ui) 这样的树外应用能贡献行,却无处接受一个 flag:`dsh --profile tui --resume ` 没有地方可供解析,而 `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` 和 `ctx.appPatches`。应用从**启动行**消费它们:启动行注入 `cmdlineArgs`,并以自己的 commander program 调用 `runStartup(ctx, service, program, plan)`;应用所配置的行在组合包 patch 中注入这个启动服务,因此在取值解析完成之前无法启动,而 `--help` 会打印文本、禁用这些行并退出,应用自始至终不会启动。 + +已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族(并为 `--dev` 启用它如今以禁用状态交付的 `client-hmr` 行),`dsh-headless` 持有任务位置参数,缺少任务时按用法错误拒绝。`apps/cli/src/web.ts` 已删除;`runProfile` 不再知道任何行 id。在树外,turtle-ui 以同样的方式获得了 `--resume ` / `--session `,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag,启动器毫无改动。 + +评审中还落出两条后果。应用的决策同时以 patch 的形式交还给启动器(`ctx.appPatches`),因为用户编辑一个活动的 patch 文件时,启动器会重新施加自己的整个 patch 栈:没有这一层,一次无关的编辑就会把每一行都从其组合出的选项重建出来,把一台以 `--port 8080` 启动的服务器悄悄挪回组合出的端口,并连带丢掉 `--dev` 和由此派生的 `/api` 围栏 authority。另外,`dsh --profile web` 现在也会加上过去只有 `dsh web` 别名才会加的 harness 源码提示词章节 —— 两条路径终于以完全相同的方式引导,这也意味着名为 `web` 的用户 profile 会继承它。 + +## 等待中的行实际如何拿到自己的取值 + +vendored Loader 的三个事实塑造了这套机制,三者都是靠探针试出来的: + +- **行的配置在 Loader 创建其 fiber 时就已解析,而这发生在它仍在等待自己启动服务的时候。** 把新配置写到这个等待中的 fiber 上,永远到不了插件。因此每个改动过的行都会被回收重建:先禁用,再带着新取值重新启用,从而丢弃陈旧的 fiber 并重新解析配置。 +- **更新一行的 `inject` 会丢失插件自身的静态注入。** Loader 从 `runtime.callback`(未经包装的函数)重启被替换的行,此时 `Inject.resolve(plugin.inject)` 什么也找不到:声明了 `inject = ['httpServer', 'apiProxy']` 的行回来之后,两个服务都读不到。因此回收重建绝不触碰 `inject`;等待中的行是靠提供服务来放行的。 +- **行的配置同样在 fiber 创建时被校验**,因此一个*必填*配置由启动流程提供的行(一次性运行器的 `task`)必须以 `disabled: true` 交付;只让它等待并不够,因为 boot 会在启动行得以运行之前就失败。它之所以看起来能工作,只是因为启动模块碰巧先被 import。 + +还有一条相关约束:不能从正在挂载的插件内部插入一行(`tree.create` 返回一个带前缀的 id,随后它自己解析不出来),因此条件性的行以 `disabled: true` 交付,由启动流程启用。回收重建还会先让某次仍在进行中的挂载结算完毕,因为单靠禁用并不构成屏障。 + +## 曾考虑的替代方案 + +- **通过清空行的 `inject` 来放行**(每行一次原子更新):孤立测试可行,在真实 web 树上失败,因为清空 `inject` 恰恰会丢失插件的静态注入。在插件真的去读它声明过的服务之前,这个失败是静默的。 +- **通过 `!!js ctx.get('webStartup')` 从行配置中读取 flag**:配置表达式在 fiber 创建时求值,早于启动服务存在,因此每个等待中的行都会读到 `undefined`。 +- **由启动器在 boot 之前运行每个组合包的启动函数**(完全不经过 cordis):最简单,而且严格早于「先 boot 再 help」,但这会让应用启动成为配置树之外的第二套插件协议。维护者的裁定是做成其他行所依赖的启动*服务*,从而只保留一套协议。 +- **两个应用解析同一份 argv**(一次性组合包叠加在 web 组合包之上):两个解析器不可能同时持有 `-h`。一套组合有且只有一个命令行所有者:叠加的组合包禁用下层的启动行,并同时提供这两个启动服务,使被吸收的行按组合后的取值启动。 +- **`instanceof CommanderError`**:树外插件会带来自己的一份 commander 副本,类身份因此不同,已经打印出来的 `--help` 会被重新抛成致命的加载失败。改为按结构识别 commander 的控制流错误。 + +## 后果 + +- 应用的 flag、help 文本和用法错误与它们所配置的行放在一起;给已安装的插件加一个 flag 不需要改动启动器。 +- `--help` 的代价是一次 boot:配置树挂载到足以运行启动行,随后拆除。等待该应用的行从不启动,这正是维护者选择服务形态的设计时所接受的代价。 +- 启动服务没有静态声明的所有者:交付了等待中的行却缺少对应启动行的组合包会在结算时失败,报出指向该服务的待处理条目,而不是在加载时失败。 +- 启动器的 flag 必须写在应用参数之前;如果应用的第一个参数恰好是 `web` 或 `plugin`,选中的将是这两个子命令,而且启动器的解析器会消耗掉一个 `--`,因此要给应用传一个字面量 `--` 需要写成 `-- --`。 +- `--dump-config` 从不运行启动行,因此它在任何应用参数被解析之前打印组合,并拒绝携带应用参数的调用。