From f32aa54aeb0b526c6c04dd1212cce33e8751afa5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:07:11 +0800 Subject: [PATCH] feat(cli)!: make dsh run the headless entrypoint --- ...19-gui-layering-and-rpc-protocol.i18n.yaml | 4 +- ...026-07-19-gui-layering-and-rpc-protocol.md | 6 +- ...-07-19-gui-layering-and-rpc-protocol.zh.md | 6 +- ...026-08-05-profile-plugin-bundles.i18n.yaml | 4 +- .../2026-08-05-profile-plugin-bundles.md | 2 +- .../2026-08-05-profile-plugin-bundles.zh.md | 2 +- ...3-cli-signal-shutdown-escalation.i18n.yaml | 4 +- ...26-08-03-cli-signal-shutdown-escalation.md | 4 +- ...08-03-cli-signal-shutdown-escalation.zh.md | 4 +- ...6-08-08-dsh-run-headless-command.i18n.yaml | 6 ++ .../2026-08-08-dsh-run-headless-command.md | 39 ++++++++++ .../2026-08-08-dsh-run-headless-command.zh.md | 39 ++++++++++ ...3-explicit-config-dsh-entrypoint.i18n.yaml | 2 +- ...08-03-explicit-config-dsh-entrypoint.zh.md | 2 +- README.i18n.yaml | 4 +- README.md | 2 +- README.zh.md | 2 +- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 4 +- apps/cli/README.zh.md | 4 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 10 ++- apps/cli/reference/README.zh.md | 10 ++- apps/cli/src/args.ts | 58 ++++++++++----- apps/cli/src/bin.ts | 11 ++- apps/cli/src/profile-boot.ts | 6 +- apps/cli/tests/args.spec.ts | 18 ++++- apps/cli/tests/built-bin.e2e.ts | 44 +++++++++++- apps/cli/tests/headless-shutdown.e2e.ts | 4 +- docs/config-catalog.md | 2 +- .../tests/fixtures/dsh-run.cordis.yml | 8 +++ .../headless-agent/tests/headless.snapshot.ts | 65 +++++++++++++++-- .../snapshots/dsh-run/session.expected.jsonl | 33 +++++++++ packages/bundle/headless/README.i18n.yaml | 4 +- packages/bundle/headless/README.md | 2 +- packages/bundle/headless/README.zh.md | 2 +- packages/bundle/headless/src/index.ts | 25 ++++--- .../bundle/headless/tests/headless.spec.ts | 72 ++++++++++++++----- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- 41 files changed, 429 insertions(+), 101 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md create mode 100644 .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md create mode 100644 examples/headless-agent/tests/fixtures/dsh-run.cordis.yml create mode 100644 examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index bdb07d5f8a..65cbe478ae 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml @@ -2,5 +2,5 @@ # 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-07-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: 34077302c53081f6ee9171d64dce9af342710d71 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: bc51542ac8159ee7cba234b4ee8b4db47a7f9b58 +2026-07-19-gui-layering-and-rpc-protocol.md: 8e020e4fe9b60100671c0cf0e98e28532d850f94 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: 55fa8084083aa83fbb4a38f8e41d2e5624b6e58e diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index 34077302c5..8e020e4fe9 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -10,7 +10,7 @@ English | [中文](2026-07-19-gui-layering-and-rpc-protocol.zh.md) We need a UI integration layer. Beyond the existing ACP/stdio baseline, more product UI shapes are coming — Web (server), Electron, and others. We call these shapes Clients, uniformly, and want the following capabilities: -- One `dsh` process supporting both `dsh web` (serve) and `dsh -p` (headless) — one process, two modes (a design reservation) +- One `dsh` process supporting both `dsh web` (serve) and `dsh run` (headless) — one process, two modes (a design reservation) - Launching inside Electron with the same Web technology shape as `dsh web` That demands a stable layered responsibility model in the engineering codebase, so future client shapes plug in cleanly. @@ -31,7 +31,7 @@ Directories layer as follows: - **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dshClient` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services. - `apps/` holds the externally exported application shapes, assembled from Client / Host mixtures. - `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`. - - `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = startHost + webserver + the built `dsh-frontend` dist; `dsh -p` = headless in-process calls, zero HTTP. + - `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = startHost + webserver + the built `dsh-frontend` dist; `dsh run` = headless in-process calls, zero HTTP. - A future Electron shape reuses the same web client packages over an IPC fetch carrier. ``` @@ -215,7 +215,7 @@ All four quadrant full forms pass through `onEnvelope`; the base implementation | Subclass | Package | doFetch | Purpose | |---|---|---|---| -| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing — `dsh -p` headless is the protocol's second real consumer | +| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing — `dsh run` headless is the protocol's second real consumer | | `WebApiClient` | dsh-client-connection | `globalThis.fetch` uplink + one same-origin WebSocket downlink per logical stream | the browser shape; physical boundary in the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | unused (protocol-layer override) | serverless UI development (`?fixture`): overrides the `callUnary`/`openMux`/`openHost`/`respond` virtuals and is itself the fake server (frame rpcIds minted by it, semantics self-consistent) | | (future) IPC bridge subclass | apps/electron | IPC serialization round trip | swaps only doFetch; contract and base class unchanged | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index bc51542ac8..55fa808408 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -9,7 +9,7 @@ Status: implemented ## Problem 需要提供 UI 对接层,除已有 ACP/stdio基础版本外,还需要 Web(server) 、 Electron 、等其他产品 UI 形态。我们把这些形态统一称为 Client。希望有如下能力支持: -- 以 `dsh` 进程,同时支持 `dsh web`(启动) 和 `dsh -p`(headless) ,一个进程两种模式(设计预留) +- 以 `dsh` 进程,同时支持 `dsh web`(启动) 和 `dsh run`(headless) ,一个进程两种模式(设计预留) - 以与 `dsh web` 同构的 Web 技术形态,在 Electron 中启动 那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client 形态。 @@ -29,7 +29,7 @@ Status: implemented - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dshClient` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。 - `apps/` 作为对外导出的应用形态入口,可以由 Client / Host 混合组装。 - `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`。 - - `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = startHost + webserver + 构建出的 `dsh-frontend` dist;`dsh -p` = headless 进程内直调,零 HTTP。 + - `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = startHost + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = headless 进程内直调,零 HTTP。 - 将来的 Electron 形态经由 IPC fetch 载体复用同一套 web client 包。 ``` @@ -213,7 +213,7 @@ export type ResponseValue = | 子类 | 所在包 | doFetch | 用途 | |---|---|---|---| -| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧——`dsh -p` headless 即协议第二真实消费者 | +| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧——`dsh run` headless 即协议第二真实消费者 | | `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器形态;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | 不用(协议层覆写) | 无 server 的 UI 开发(`?fixture`):覆写 `callUnary`/`openMux`/`openHost`/`respond` 虚方法,自己就是假 server(帧 rpcId 由它 mint,语义自洽) | | (将来)IPC 桥子类 | apps/electron | IPC 序列化往返 | 仅换 doFetch,契约/基类零改 | diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml index eed6bee5f0..7fa37eeb8e 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml @@ -2,5 +2,5 @@ # 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-05-profile-plugin-bundles.md -2026-08-05-profile-plugin-bundles.md: 11a8ac3d4005371ca9596ba237aaf42a8e770dee -2026-08-05-profile-plugin-bundles.zh.md: 0e9ebf657ccb9d05967d90a935b356acf287a24c +2026-08-05-profile-plugin-bundles.md: b5bf5411d22ab99b598f667886b3c29ba8ee7b06 +2026-08-05-profile-plugin-bundles.zh.md: ae790028b5768c05c57acd27d7f68bdc4d612c11 diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md index 11a8ac3d40..b5bf5411d2 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md @@ -12,7 +12,7 @@ The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.y Everything becomes a **profile**: a directory `$DSH_HOME/profiles/` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer, then `--patch` overlays, then flag patches — one `applyEntryPatches` call, identical for boot, flag derivation, and `--dump-config`. -The shipped compositions became bundles: `@deepseek-ai/dsh-base` (the former base rows as one insert), `@deepseek-ai/dsh-web-app` (the former web overlay plus a runtime glue plugin that owns what used to be launcher code — frontend-dist resolution, the web-surface prompt section, bash runtime variables, the URL line), and `@deepseek-ai/dsh-headless` (a one-shot runner plugin over base + web-app). `dsh web` stays as an alias for `--profile web` carrying the Web flag family; `dsh --profile headless "task"` replaces `-p`; `dsh --config` is removed (its uses migrate to `--patch`). `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` after `add`/`remove` (a bundle-less package warns and stays a plain dependency). +The shipped compositions became bundles: `@deepseek-ai/dsh-base` (the former base rows as one insert), `@deepseek-ai/dsh-web-app` (the former web overlay plus a runtime glue plugin that owns what used to be launcher code — frontend-dist resolution, the web-surface prompt section, bash runtime variables, the URL line), and `@deepseek-ai/dsh-headless` (a one-shot runner plugin over base + web-app). `dsh web` stays as an alias for `--profile web` carrying the Web flag family; `dsh run [--profile ] "task"` owns one-shot execution and defaults to the headless profile, while generic `dsh --profile ` boots without a task; `dsh --config` is removed (its uses migrate to `--patch`). `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` after `add`/`remove` (a bundle-less package warns and stays a plain dependency). Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory — so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them — while bare plugin names in patch rows resolve through the profile directory's Node parent-walk into the maintained flat fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md index 0e9ebf657c..ae790028b5 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md @@ -12,7 +12,7 @@ Status: implemented 一切都变成 **profile**:即目录 `$DSH_HOME/profiles/`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层,然后是 `--patch` overlay,最后是 flag patch——全部收敛为一次 `applyEntryPatches` 调用,启动、flag 派生与 `--dump-config` 使用完全相同的路径。 -已交付的组合改造成了组合包:`@deepseek-ai/dsh-base`(原有基础行合并为一次插入)、`@deepseek-ai/dsh-web-app`(原 web overlay,外加一个接管原启动器代码的运行时粘合插件——前端 dist 解析、web 表层提示词段落、bash 运行时变量、URL 行)、`@deepseek-ai/dsh-headless`(叠加在 base + web-app 之上的一次性 runner 插件)。`dsh web` 保留为携带 Web flag 家族的 `--profile web` 别名;`dsh --profile headless "task"` 取代 `-p`;`dsh --config` 被移除(其用途迁移到 `--patch`)。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并在 `add`/`remove` 后调和 `dsh.profile.bundles`(没有组合包声明的包会给出警告,保持为普通依赖)。 +已交付的组合改造成了组合包:`@deepseek-ai/dsh-base`(原有基础行合并为一次插入)、`@deepseek-ai/dsh-web-app`(原 web overlay,外加一个接管原启动器代码的运行时粘合插件——前端 dist 解析、web 表层提示词段落、bash 运行时变量、URL 行)、`@deepseek-ai/dsh-headless`(叠加在 base + web-app 之上的一次性 runner 插件)。`dsh web` 保留为携带 Web flag 家族的 `--profile web` 别名;`dsh run [--profile ] "task"` 负责一次性执行,默认使用 headless profile,而通用的 `dsh --profile ` 只启动 profile,不携带任务;`dsh --config` 被移除(其用途迁移到 `--patch`)。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并在 `add`/`remove` 后调和 `dsh.profile.bundles`(没有组合包声明的包会给出警告,保持为普通依赖)。 解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析——因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们——而 patch 行中的裸插件名称经 profile 目录的 Node 父目录逐级查找,落到受维护的扁平回退目录 `$DSH_HOME/profiles/node_modules`(安装目录的应用与各组合包所依赖的每个包各一个符号链接,每次启动时修复)。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml index 59e98bc061..4752010ef6 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml @@ -2,5 +2,5 @@ # 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/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md -2026-08-03-cli-signal-shutdown-escalation.md: 2746b5784baad0f3b14258280cd56a621db07c15 -2026-08-03-cli-signal-shutdown-escalation.zh.md: 0bda83327d4cc8fe2edb61f8145a89138610901e +2026-08-03-cli-signal-shutdown-escalation.md: 7c9715c37ee57be9fa0f67af0c19f0bfa84845da +2026-08-03-cli-signal-shutdown-escalation.zh.md: f3485edc9e453c0b774f442bfce6d678d63f2224 diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md index 2746b5784b..7c9715c37e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md @@ -6,9 +6,9 @@ English | [中文](2026-08-03-cli-signal-shutdown-escalation.zh.md) ## Problem -The default telemetry mount added SIGINT/SIGTERM handlers to `dsh web` and `dsh -p` so process exit could drain the Cordis tree instead of dropping queued telemetry. Each handler used a one-way boolean latch and exited only after `ctx.fiber.dispose()` settled. Headless normal completion also awaited that disposal without a bound. +The default telemetry mount added SIGINT/SIGTERM handlers to `dsh web` and the headless command (now `dsh run`) so process exit could drain the Cordis tree instead of dropping queued telemetry. Each handler used a one-way boolean latch and exited only after `ctx.fiber.dispose()` settled. Headless normal completion also awaited that disposal without a bound. -A user then reproduced `dsh -p` hanging immediately after the observation URL and ignoring repeated `Ctrl+C`; `DSH_TELEMETRY_DISABLED=1` removed the hang, while a standalone Node handler in the same Linux sandbox received SIGINT. This isolated the pending disposer to telemetry rather than terminal signal forwarding. OTel's `BatchLogRecordProcessor.shutdown()` awaits `exporter.forceFlush()` before the `exportTimeoutMillis`-bounded completion promise, and the OTLP exporter's `forceFlush()` waits directly on its in-flight HTTP Promise. A proxy/sandbox connection that never obtains a socket can therefore leave provider shutdown pending despite both configured SDK timeouts. +A user then reproduced the headless command hanging immediately after the observation URL and ignoring repeated `Ctrl+C`; `DSH_TELEMETRY_DISABLED=1` removed the hang, while a standalone Node handler in the same Linux sandbox received SIGINT. This isolated the pending disposer to telemetry rather than terminal signal forwarding. OTel's `BatchLogRecordProcessor.shutdown()` awaits `exporter.forceFlush()` before the `exportTimeoutMillis`-bounded completion promise, and the OTLP exporter's `forceFlush()` waits directly on its in-flight HTTP Promise. A proxy/sandbox connection that never obtains a socket can therefore leave provider shutdown pending despite both configured SDK timeouts. The latch then turned that telemetry defect into an unkillable CLI: normal completion was already awaiting the single-shot root disposal; the first SIGINT joined the same pending disposal and set the signal latch; later SIGINTs returned at the latch, so the process had no remaining escape. A signal received before normal completion had the same unbounded wait. Web used the same latch shape. diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md index 0bda83327d..f3485edc9e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md @@ -6,9 +6,9 @@ ## 问题 -默认挂载遥测后,`dsh web` 与 `dsh -p` 新增了 SIGINT/SIGTERM 处理器,使进程退出时可以排空 Cordis 插件树,而不是丢弃排队中的遥测数据。每个处理器都使用单向布尔闩锁(latch),并且只有在 `ctx.fiber.dispose()` 结算后才退出。headless 正常完成时同样会无界等待整棵树执行 dispose(资源释放)。 +默认挂载遥测后,`dsh web` 与 headless 命令(现为 `dsh run`)新增了 SIGINT/SIGTERM 处理器,使进程退出时可以排空 Cordis 插件树,而不是丢弃排队中的遥测数据。每个处理器都使用单向布尔闩锁(latch),并且只有在 `ctx.fiber.dispose()` 结算后才退出。headless 正常完成时同样会无界等待整棵树执行 dispose(资源释放)。 -随后有用户复现,`dsh -p` 在打印观察 URL 后立即卡死,重复按 `Ctrl+C` 也没有反应;设置 `DSH_TELEMETRY_DISABLED=1` 后不再卡死,而同一 Linux 沙箱中的独立 Node 信号处理器能够收到 SIGINT。这将待结算的 disposer 定位到遥测,而非终端信号转发。OTel 的 `BatchLogRecordProcessor.shutdown()` 会先等待 `exporter.forceFlush()`,再进入受 `exportTimeoutMillis` 限制的完成 promise;OTLP 导出器的 `forceFlush()` 则直接等待正在进行的 HTTP Promise。因此,代理/沙箱连接始终无法取得 socket 时,即使已经配置两项 SDK 超时,也会让提供方关闭一直待结算。 +随后有用户复现,headless 命令在打印观察 URL 后立即卡死,重复按 `Ctrl+C` 也没有反应;设置 `DSH_TELEMETRY_DISABLED=1` 后不再卡死,而同一 Linux 沙箱中的独立 Node 信号处理器能够收到 SIGINT。这将待结算的 disposer 定位到遥测,而非终端信号转发。OTel 的 `BatchLogRecordProcessor.shutdown()` 会先等待 `exporter.forceFlush()`,再进入受 `exportTimeoutMillis` 限制的完成 promise;OTLP 导出器的 `forceFlush()` 则直接等待正在进行的 HTTP Promise。因此,代理/沙箱连接始终无法取得 socket 时,即使已经配置两项 SDK 超时,也会让提供方关闭一直待结算。 闩锁随后把这个遥测缺陷变成无法终止的 CLI(命令行界面):正常完成流程已经在等待单次根级 dispose;第一次 SIGINT 会加入同一个待结算的 dispose,并设置信号闩锁;后续 SIGINT 在闩锁处直接返回,因此进程再无退出途径。正常完成之前收到信号时,同样会陷入无界等待。Web 使用的闩锁结构与此相同。 diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml new file mode 100644 index 0000000000..8d5ec1c9f6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.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/feature/2026-08-08-dsh-run-headless-command.md +2026-08-08-dsh-run-headless-command.md: aac2a473760509626d315df8d57eb405eb547abf +2026-08-08-dsh-run-headless-command.zh.md: d71d2a34addf1c64b8cb37c54117be5b9c643370 diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md new file mode 100644 index 0000000000..aac2a47376 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md @@ -0,0 +1,39 @@ +# Agent Note: `dsh run` owns one-shot headless execution + +Status: implemented + +English | [中文](2026-08-08-dsh-run-headless-command.zh.md) + +## Problem + +The product launcher attached optional task text to its generic profile boot: `dsh --profile headless "task"`. That made one argv shape mean either a long-lived profile or a one-shot run according to a row discovered only after composition. The parser's `ProfileInvocation` carried optional task state, help presented a profile implementation detail as the user command, and a custom profile could accept a task only through the same overloaded root. + +The former `dsh -p` spelling was already absent from the parser, so restoring it or detecting it specially would add compatibility machinery to a pre-release interface. A separate application-file proposal also used the `run` verb, leaving two incompatible owners for one top-level command. + +## Decision + +One-shot execution owns an explicit grammar: + +```text +dsh run [--profile ] [--patch ...] +``` + +`--profile` defaults to `headless` and remains available for custom one-shot compositions. `--patch` is repeatable and occupies the existing overlay layer. Commander joins the variadic task arguments with spaces and rejects a missing or blank task before boot. + +`RunInvocation` is a separate `DshInvocation` member. The generic profile invocation no longer carries task text, and its root command accepts no positional arguments. Both dispatch paths call the existing deep `runProfile` module: `profile` omits `task`, while `run` supplies it. There is no shallow `run.ts` forwarding module and no alias, warning, or custom detector for former spellings; they fail through the ordinary Commander grammar. A one-shot profile without `headless-runner` still fails through the existing composed-row check, while booting a profile that contains that row without a task points to `dsh run --profile ""`. + +The `run` verb belongs to one-shot task execution. Launching an application file must choose another command name; two top-level meanings selected by positional shape would recreate the ambiguity this command removes. + +The runner's user-visible contract stays the same: a fresh persisted session, browser observation URL on stderr, final assistant text on stdout, completed/non-completed exit mapping, and bounded signal shutdown. The product-level keyless acceptance exposed that the in-process mux consumer could lag the same-process `agent/status: idle` notification and derive output before reading the final frames. The idle notification now captures the authoritative final session sequence, and the runner waits until the ordered mux reaches that boundary (or the stream ends) before deriving text and exit reason. This enforces the existing idle-to-idle contract without adding a wire field or a timing delay. + +## Alternatives considered + +- **Keep task text on `dsh --profile`.** Rejected because profile boot and one-shot execution remain one grammar whose meaning depends on a late composition check. +- **Preserve `dsh -p` or the positional profile form as aliases.** Rejected under the pre-release stance: compatibility branches would outlive the interface they were meant to retire. +- **Make `--profile headless` mandatory under `run`.** Rejected because the shipped one-shot surface should have the shortest canonical spelling, while optional `--profile` preserves plugin-defined one-shot compositions. +- **Give `dsh run` to application-file launch and choose another headless verb.** Rejected because `run` describes executing a task through the harness; application-file ownership would make the product's primary one-shot command less direct and collide with custom one-shot profiles. +- **Add `apps/cli/src/run.ts`.** Rejected because it would only forward to `runProfile`, splitting command ownership without hiding any complexity. + +## Consequences + +This is an intentional breaking CLI change. Documentation, help, parser tests, built-bin acceptance, PTY shutdown coverage, and the assembled keyless snapshot use `dsh run`. Existing custom one-shot profiles keep working through `--profile`; long-lived profiles and config dumps retain their existing root grammar. The competing application-file command must be renamed and rebased separately rather than sharing or overloading `run`. diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md new file mode 100644 index 0000000000..d71d2a34ad --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md @@ -0,0 +1,39 @@ +# Agent Note: `dsh run` 负责一次性 headless 执行 + +Status: implemented + +[English](2026-08-08-dsh-run-headless-command.md) | 中文 + +## 问题 + +产品启动器过去把可选任务文本挂在通用 profile 启动命令上:`dsh --profile headless "task"`。于是,同一种 argv 形态会表示常驻 profile 或一次性运行,具体含义取决于组合完成后才发现的配置行。解析器的 `ProfileInvocation` 携带可选任务状态,帮助信息把 profile 的实现细节呈现为用户命令,自定义 profile 也只能通过同一个过载的根命令接收任务。 + +解析器中已经没有原来的 `dsh -p` 写法,因此恢复该写法或加入特殊检测,会给预发布接口增加兼容机制。另一个应用文件提案也使用 `run` 动词,使同一个顶层命令同时归属两个互不兼容的功能。 + +## 决策 + +一次性执行采用明确语法: + +```text +dsh run [--profile ] [--patch ...] +``` + +`--profile` 默认为 `headless`,同时保留对自定义一次性组合的支持。`--patch` 可重复使用,并沿用既有 overlay 层的位置。Commander 用空格拼接可变数量的任务参数,并在启动前拒绝缺失或空白任务。 + +`RunInvocation` 是单独的 `DshInvocation` 成员。通用 profile 调用不再携带任务文本,其根命令也不接受位置参数。两条分派路径都调用已有的深层 `runProfile` 模块:`profile` 省略 `task`,`run` 则提供该字段。实现中没有只负责转发的浅层 `run.ts` 模块,也没有面向旧写法的别名、警告或自定义检测器;旧写法会按普通 Commander 语法失败。缺少 `headless-runner` 的一次性 profile 仍会触发既有的组合行检查;如果启动的 profile 包含该行却未提供任务,错误会指向 `dsh run --profile ""`。 + +`run` 动词只负责一次性任务执行。应用文件启动必须选择其他命令名;如果让两个顶层含义由位置参数形态决定,就会重新引入本命令消除的歧义。 + +运行器面向用户的契约保持不变:创建新的持久化会话,在 stderr 打印浏览器观察 URL,在 stdout 打印最终 assistant 文本,将完成/未完成映射为退出状态,并执行有界的信号关闭。产品级无密钥验收用例发现,进程内 mux 消费方可能落后于同进程的 `agent/status: idle` 通知,在读到最终帧之前就生成输出。idle 通知现在会捕获权威的会话最终事件序号,运行器则等待有序 mux 到达该边界(或流结束),再生成文本和退出原因。这一机制在不增加 wire 字段或定时延迟的前提下,落实了既有的 idle-to-idle 契约。 + +## 考虑过的替代方案 + +- **把任务文本保留在 `dsh --profile` 上。** 不予采纳:profile 启动和一次性执行仍共用同一套语法,其含义取决于较晚发生的组合检查。 +- **保留 `dsh -p` 或位置参数 profile 形式作为别名。** 不予采纳:根据预发布立场,这些兼容分支会比本应退役的接口存续更久。 +- **要求在 `run` 下必须指定 `--profile headless`。** 不予采纳:已交付的一次性接口应采用最短的规范写法,同时用可选的 `--profile` 保留插件定义的一次性组合。 +- **把 `dsh run` 交给应用文件启动,并为 headless 选择另一个动词。** 不予采纳:`run` 描述的是通过 harness 执行任务;若归应用文件所有,产品的主要一次性命令会更不直接,并与自定义一次性 profile 冲突。 +- **新增 `apps/cli/src/run.ts`。** 不予采纳:它只会转发到 `runProfile`,拆分命令归属,却没有隐藏任何复杂度。 + +## 后果 + +这是一次有意为之的 CLI(命令行界面)破坏性变更。文档、帮助信息、解析器测试、构建后二进制验收、PTY 关闭覆盖和组装应用的无密钥快照都使用 `dsh run`。现有自定义一次性 profile 可继续通过 `--profile` 工作;常驻 profile 和配置 dump 保留既有的根命令语法。与之竞争的应用文件命令必须单独改名并 rebase,不得共享或重载 `run`。 diff --git a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml index 8ff1af7e8e..ee43f9465c 100644 --- a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.md 2026-08-03-explicit-config-dsh-entrypoint.md: e0d1e954d9cef472ea59345a3d2ef5a67bd03ae8 -2026-08-03-explicit-config-dsh-entrypoint.zh.md: b5b464e3b45a6f3909bbf087f7005ad3f819424a +2026-08-03-explicit-config-dsh-entrypoint.zh.md: 614c2d8600731d85c83d6559bc577350da25e872 diff --git a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md b/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md index b5b464e3b4..614c2d8600 100644 --- a/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md @@ -1,4 +1,4 @@ -# Agent Note:显式配置的 dsh 入口 +# Agent Note: 显式配置的 dsh 入口 Status: implemented diff --git a/README.i18n.yaml b/README.i18n.yaml index 0a7c3e49fe..c4ad1f0fd9 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 README.md -README.md: d8d3e767d5a9805f34f4df57a5b1f8ff7fdaa955 -README.zh.md: 89abf8d817deeed2bf4416035790c8696c8c8e33 +README.md: 64f06c0aec0905fa7deabbec0deea61e1c7a40d4 +README.zh.md: fee03118926028833c828809764ebb5f6375259e diff --git a/README.md b/README.md index d8d3e767d5..64f06c0aec 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ The [CLI contract](apps/cli/README.md#profiles) describes profile layout, layer Run one task, print the final answer, and exit: ```sh -dsh --profile headless "summarize this workspace" +dsh run "summarize this workspace" ``` ### Automation and SDKs diff --git a/README.zh.md b/README.zh.md index 89abf8d817..fee0311892 100644 --- a/README.zh.md +++ b/README.zh.md @@ -56,7 +56,7 @@ profile 布局、层语义与配置输出命令详见 [CLI(命令行界面) 运行一项任务,打印最终答案后退出: ```sh -dsh --profile headless "summarize this workspace" +dsh run "summarize this workspace" ``` ### 自动化与 SDK diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index cbc74b6d0a..801560e7d7 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 apps/cli/README.md -README.md: f50f26ec5de54e094e17221f7cd355483483754e -README.zh.md: 242e64a0c42064b9f0b7621e665e85fe44523fe5 +README.md: 12108fcbff4e649d0bcb3e01e688fa334ab91b14 +README.zh.md: 9518feed1d5d40c3e5ec2d346b929118ccc08810 diff --git a/apps/cli/README.md b/apps/cli/README.md index f50f26ec5d..12108fcbff 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -9,11 +9,11 @@ The `dsh` command is the product launcher for profiles: ordered stacks of plugin | Command | Purpose | |---|---| | `dsh --profile ` | Boot the named profile under `$DSH_HOME/profiles/`. | -| `dsh --profile headless "task"` | Run one fresh persisted session, print the final answer, and exit. | +| `dsh run [--profile ] [--patch ...] "task"` | Run one fresh persisted session, print the final answer, and exit; the profile defaults to `headless`. | | `dsh web` | Alias of `--profile web` with the Web flag family (`--host`, `--port`, `--dev`, ...). | | `dsh plugin --profile ` | Manage a profile's plugins by forwarding to pnpm in the profile directory. | -The invoking directory is the default workspace root. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. +The invoking directory is the default workspace root. `dsh run` requires non-blank task text and the selected profile must mount the `headless-runner` row; `--profile` preserves custom one-shot profiles. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. ## Profiles diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 242e64a0c4..9518feed1d 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -9,11 +9,11 @@ | 命令 | 用途 | |---|---| | `dsh --profile ` | 启动位于 `$DSH_HOME/profiles/` 的指定 profile。 | -| `dsh --profile headless "task"` | 运行一个新的持久化会话,打印最终答案并退出。 | +| `dsh run [--profile ] [--patch ...] "task"` | 运行一个新的持久化会话,打印最终答案并退出;profile 默认为 `headless`。 | | `dsh web` | `--profile web` 的别名,附带 Web flag 系列(`--host`、`--port`、`--dev` 等)。 | | `dsh plugin --profile ` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 | -调用目录是默认 workspace 根目录。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。 +调用目录是默认 workspace 根目录。`dsh run` 要求任务文本非空白,且所选 profile 必须挂载 `headless-runner` 行;`--profile` 保留对自定义一次性 profile 的支持。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。 ## Profile diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index e64141c31d..27e391320a 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 apps/cli/reference/README.md -README.md: c7c7b2aa231d4c9f4b3fbf31663237c8457eb051 -README.zh.md: 5439aa78b74415c8e6264d21f5c52e5cee5b38ee +README.md: 496cecdb64e3254a2a77690f55f760b4cd90b521 +README.zh.md: 4673bf764347307a9b91e2a5474a8439cf67b481 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index c7c7b2aa23..496cecdb64 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This reference defines the profile, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner. +This reference defines the profile, one-shot run, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner. ## Profile boot @@ -12,7 +12,7 @@ Bundle names resolve from the dsh installation first, then from the profile dire The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app; `headless`: base + web-app + headless). Any other missing profile fails loud with a hint to run `dsh plugin --profile add `. -A positional task (`dsh --profile headless "run the tests"`) requires the composition to mount the one-shot runner row (`headless-runner`); the launcher patches the task text into that row, the runner drives one fresh persisted session through the in-process API carrier, prints the final assistant text on stdout, and exits 0 on a completed turn, else 1. The session's Web host runs on an OS-assigned port and is announced on stderr, so the run is observable in a browser. +Profile boot accepts no positional task. A profile that mounts the one-shot runner row (`headless-runner`) therefore fails loud with the canonical `dsh run --profile ""` command instead of reaching the row's raw required-field error. Inspect the composed tree without booting it: @@ -23,6 +23,12 @@ dsh --profile web --patch ./extra.yml --dump-config `--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print provenance comments per layer; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. +## One-shot run + +`dsh run [--profile ] [--patch ...] ` joins the task arguments with spaces, rejects a missing or blank task, and defaults `--profile` to `headless`. Repeatable `--patch` overlays occupy the same layer position as profile-boot overlays. A custom selected profile must mount `headless-runner`; otherwise launch fails before boot with a diagnostic naming that missing row. + +The launcher patches the task text into the runner row, which drives one fresh persisted session through the in-process API carrier, prints the final assistant text on stdout, and exits 0 on a completed turn, else 1. At the idle boundary, the runner waits until its mux consumer has observed the session's final event sequence before deriving that output and exit reason. The session's Web host runs on an OS-assigned port and is announced on stderr, so the run is observable in a browser. + ## Plugin management `dsh plugin --profile ` initializes the profile when missing (shipped template, or `@deepseek-ai/dsh-base` alone for other names), then forwards `` to `pnpm` with the profile directory as working directory — `add`, `remove`, `why`, `update`, and every other pnpm verb work unchanged; pnpm must be on PATH. Relative path specs (`.`, `../plugin`, and their `file:`/`link:` forms) are anchored to the invoking directory first, so `add .` from a plugin checkout installs that checkout, not the profile. After every successful run, `dsh.profile.bundles` is reconciled against the installed state: each dependency resolving to a package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` joins the layer stack (so an `update` that gains the declaration activates it), a bundle-less dependency stays plain with a one-time warning, and a removed dependency leaves the stack. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 5439aa78b7..4673bf7643 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本参考定义 profile、web 别名、插件管理和配置 dump 命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。 +本参考定义 profile、一次性运行、web 别名、插件管理和配置 dump 命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。 ## Profile 启动 @@ -12,7 +12,7 @@ `web` 和 `headless` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app;`headless`:base + web-app + headless)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 -位置参数任务(`dsh --profile headless "run the tests"`)要求组合挂载一次性运行器行(`headless-runner`);启动器把任务文本 patch 进该行,运行器通过进程内 API 载体驱动一个全新的持久化会话,在 stdout 打印最终 assistant 文本,并在轮次完成时以 0 退出,否则以 1 退出。会话的 Web 宿主运行在 OS 分配的端口上并公布到 stderr,因此该次运行可在浏览器中观察。 +Profile 启动不接受位置参数任务。因此,挂载了一次性运行器行(`headless-runner`)的 profile 会显式报错,并提示规范命令 `dsh run --profile ""`,而不会触发该行原始的必填字段错误。 可在不启动的情况下检查组合出的配置树: @@ -23,6 +23,12 @@ dsh --profile web --patch ./extra.yml --dump-config `--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会按层打印来源注释;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。 +## 一次性运行 + +`dsh run [--profile ] [--patch ...] ` 会用空格拼接任务参数,拒绝缺失或空白任务,并让 `--profile` 默认为 `headless`。可重复使用的 `--patch` overlay 与 profile 启动的 overlay 位于同一层。所选的自定义 profile 必须挂载 `headless-runner`;否则启动器会在启动前失败,并在诊断中指明缺少该行。 + +启动器把任务文本 patch 进运行器行,运行器再通过进程内 API 载体驱动一个全新的持久化会话,在 stdout 打印最终 assistant 文本,并在轮次完成时以 0 退出,否则以 1 退出。到达 idle 边界时,运行器会等到 mux 消费方观察到会话的最终事件序号,再生成输出与退出原因。会话的 Web 宿主运行在 OS 分配的端口上并公布到 stderr,因此该次运行可在浏览器中观察。 + ## 插件管理 `dsh plugin --profile ` 在 profile 缺失时先初始化它(有随附模板的用模板,其他名称只装 `@deepseek-ai/dsh-base`),然后以 profile 目录为工作目录,把 `` 转发给 `pnpm`:`add`、`remove`、`why`、`update` 及其他所有 pnpm 子命令都照常可用;pnpm 必须在 PATH 上。相对路径 spec(`.`、`../plugin` 及其 `file:`/`link:` 形式)会先锚定到调用目录,因此在插件 checkout 中执行 `add .` 安装的是该 checkout,而不是 profile。每次成功运行后,`dsh.profile.bundles` 都会与已安装状态对齐:每个解析到 manifest 中声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的包的依赖加入层栈(因此让包获得该声明的 `update` 会将其激活),没有组合包声明的依赖保持为普通依赖并给出一次性警告,已移除的依赖则退出层栈。 diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 310b5b03a2..0b72f76c6a 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -1,10 +1,10 @@ /** * Commander adapter for the `dsh` command-line entry. The default command * boots a named profile (`--profile `), optionally with extra `--patch` - * overlays and a positional task (one-shot mode for profiles mounting the - * headless runner). `web` is a hardcoded alias for `--profile web` that adds - * the Web flag family; `plugin` manages a profile's plugin dependencies by - * forwarding to pnpm. Commander owns help, version, and parse errors. + * overlays. `run` owns one-shot task execution, defaulting to the headless + * profile; `web` is a hardcoded alias for `--profile web` that adds the Web + * flag family; `plugin` manages a profile's plugin dependencies by forwarding + * to pnpm. Commander owns help, version, and parse errors. * @module @deepseek-ai/dsh/args */ @@ -16,8 +16,16 @@ interface ProfileInvocation { profile: string /** Extra patch-list overlays applied after the profile's own layer, in argv order. */ patches: string[] - /** Positional task text joined by spaces; non-empty only for one-shot runs. */ - task?: string +} + +/** Run one task through a profile mounting the headless runner. */ +interface RunInvocation { + mode: 'run' + profile: string + /** Extra patch-list overlays applied after the profile's own layer, in argv order. */ + patches: string[] + /** Non-blank task text joined from the variadic positional arguments. */ + task: string } /** Print a composed profile tree and exit without booting. */ @@ -54,7 +62,7 @@ interface PluginInvocation { } /** The resolved `dsh` invocation. Help, version, and errors exit inside {@link parseDshArgs}. */ -export type DshInvocation = ProfileInvocation | DumpConfigInvocation | WebInvocation | PluginInvocation +export type DshInvocation = ProfileInvocation | RunInvocation | DumpConfigInvocation | WebInvocation | PluginInvocation /** Raw web-subcommand options straight from Commander. */ interface WebOptions { @@ -68,6 +76,12 @@ interface WebOptions { dumpDefaultConfig?: boolean } +/** Raw run-subcommand options straight from Commander. */ +interface RunOptions { + profile?: string + patch?: string[] +} + /** * Repeatable single-value collector: `--patch a.yml --patch b.yml`. Never * variadic — a variadic `--patch` would swallow a following positional task. @@ -90,19 +104,19 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc .addHelpText('after', ` Examples: dsh --profile web boot the web profile (same as: dsh web) - dsh --profile headless "run the tests" answer one task, print the result, and exit + dsh run "run the tests" answer one task, print the result, and exit + dsh run --profile custom "run the tests" run one task through a custom one-shot profile dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay dsh plugin --profile tui add install a plugin into the tui profile dsh web --port 8080 the web alias with its flag family `) .exitOverride() .enablePositionalOptions() - .argument('[task...]', 'one-shot task text for profiles mounting the headless runner') .option('--profile ', 'the profile under $DSH_HOME/profiles to boot') .option('--patch ', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) .option('--dump-config', 'print the composed profile tree and exit') .option('--dump-default-config', 'print the profile tree without its user layer or --patch overlays and exit') - .action((task: string[], options: { + .action((options: { profile?: string patch?: string[] dumpConfig?: boolean @@ -116,7 +130,6 @@ Examples: if (options.dumpConfig === true && options.dumpDefaultConfig === true) { program.error('error: --dump-config and --dump-default-config are mutually exclusive') } - if (task.length > 0) program.error('error: --dump-config/--dump-default-config take no task') const defaultOnly = options.dumpDefaultConfig === true if (defaultOnly && patches.length > 0) { program.error('error: --dump-default-config prints the bundle layers and takes no --patch') @@ -124,12 +137,7 @@ Examples: resolved = { mode: 'dump-config', profile, defaultOnly, patches } return } - resolved = { - mode: 'profile', - profile, - patches, - ...task.length > 0 ? { task: task.join(' ') } : {}, - } + resolved = { mode: 'profile', profile, patches } }) /** Reject parent options that crossed a subcommand boundary. */ @@ -146,6 +154,22 @@ Examples: } } + const run = program.command('run').description('run one task through a profile mounting the headless runner') + run + .option('--profile ', 'one-shot profile under $DSH_HOME/profiles', 'headless') + .option('--patch ', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) + .argument('', 'task text') + .action((task: string[], options: RunOptions) => { + rejectParentOptions('run') + const profile = options.profile ?? 'headless' + if (profile === '') program.error('error: --profile needs a name') + const patches = options.patch ?? [] + if (patches.includes('')) program.error('error: --patch needs a path') + const joined = task.join(' ') + if (joined.trim() === '') program.error('error: run needs a non-blank task') + resolved = { mode: 'run', profile, patches, task: joined } + }) + const web = program.command('web').description('serve the browser UI (alias of --profile web) on the configured host and port') web .option('--patch ', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 4a209b2796..b332a64615 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -33,7 +33,16 @@ switch (invocation.mode) { environment: loadLayeredEnv('dsh'), profile: invocation.profile, patchFiles: invocation.patches, - ...invocation.task !== undefined && { task: invocation.task }, + }) + break + } + case 'run': { + const { runProfile } = await import('./profile-boot.ts') + await runProfile({ + environment: loadLayeredEnv('dsh'), + profile: invocation.profile, + patchFiles: invocation.patches, + task: invocation.task, }) break } diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 742bf29db4..4730ec7073 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -47,7 +47,7 @@ export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.me /** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets. */ const TELEMETRY_ROW_ID = 'telemetry-otel' -/** The one-shot runner row a positional task requires and configures. */ +/** The one-shot runner row a `dsh run` task requires and configures. */ const HEADLESS_ROW_ID = 'headless-runner' /** The empty root entry list every profile tree patches over. */ @@ -160,7 +160,7 @@ export interface RunProfileOptions { patchFiles: readonly string[] /** Launcher hook turning the pre-flag composed rows into flag patches (the web alias's flag family). */ deriveFlagPatches?: (rows: ProfileRows) => PatchOptions[] - /** One-shot task text; requires the composition to mount the headless runner row. */ + /** `dsh run` task text; requires the composition to mount the headless runner row. */ task?: string /** Surface setup registered after Loader installation and before any config-tree entry mounts. */ prepare?: (ctx: Context, rows: ProfileRows) => Promise | void @@ -190,7 +190,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con // error naming no fix. throw new Error( `dsh: profile ${JSON.stringify(options.profile)} mounts the one-shot runner and needs a task: ` - + `dsh --profile ${options.profile} ""`, + + `dsh run --profile ${options.profile} ""`, ) } diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 93bfb62cc6..c9b3dc18f1 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -21,12 +21,16 @@ function exitCode(argv: string[]): number { afterEach(() => { vi.restoreAllMocks() }) describe('parseDshArgs', () => { - it('routes profile boots, one-shot tasks, and the web alias', () => { + it('routes profile boots, one-shot runs, and the web alias', () => { expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [] }) - expect(parse(['--profile', 'headless', 'run', 'the', 'tests'])) - .toEqual({ mode: 'profile', profile: 'headless', patches: [], task: 'run the tests' }) expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--patch', 'b.yml'])) .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'] }) + expect(parse(['run', 'run', 'the', 'tests'])) + .toEqual({ mode: 'run', profile: 'headless', patches: [], task: 'run the tests' }) + expect(parse(['run', '--profile', 'custom', '--patch', 'a.yml', '--patch', 'b.yml', 'run', 'the', 'tests'])) + .toEqual({ mode: 'run', profile: 'custom', patches: ['a.yml', 'b.yml'], task: 'run the tests' }) + expect(parse(['run', '--', '--profile', 'is', 'task', 'text'])) + .toEqual({ mode: 'run', profile: 'headless', patches: [], task: '--profile is task text' }) expect(parse(['web'])).toEqual({ mode: 'web', dev: false, patches: [] }) expect(parse(['web', '--patch', 'web.yml'])).toEqual({ mode: 'web', dev: false, patches: ['web.yml'] }) expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) @@ -65,6 +69,13 @@ describe('parseDshArgs', () => { expect(exitCode(['tui'])).toBe(1) // a bare word is a task without --profile expect(exitCode(['--config', 'c.yml'])).toBe(1) // removed expect(exitCode(['-p', 'task'])).toBe(1) // removed + expect(exitCode(['--profile', 'headless', 'task'])).toBe(1) // tasks belong to `run` + expect(exitCode(['run'])).toBe(1) + expect(exitCode(['run', ''])).toBe(1) + expect(exitCode(['run', '--profile', '', 'task'])).toBe(1) + expect(exitCode(['run', '--patch=', 'task'])).toBe(1) + expect(exitCode(['--profile', 'headless', 'run', 'task'])).toBe(1) + expect(exitCode(['--patch', 'parent.yml', 'run', 'task'])).toBe(1) expect(exitCode(['--profile', ''])).toBe(1) expect(exitCode(['--profile', 'x', '--patch='])).toBe(1) expect(exitCode(['--dump-config'])).toBe(1) @@ -90,6 +101,7 @@ describe('parseDshArgs', () => { it('exits 0 for help and version', () => { expect(exitCode(['--help'])).toBe(0) + expect(exitCode(['run', '--help'])).toBe(0) expect(exitCode(['--version'])).toBe(0) }) }) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 20ed3fb160..a42780581f 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -182,14 +182,56 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const help = await runBuiltBin(['--help']) expect(help.code).toBe(0) expect(help.stdout).toContain('dsh --profile web') + expect(help.stdout).toContain('dsh run "run the tests"') expect(help.stdout).toContain('dsh plugin --profile') expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu) - for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task']]) { + for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['--profile', 'headless', 'task']]) { const result = await runBuiltBin(removed) expect(result.code).toBe(1) } }, 30_000) + it('prints run help without initializing the selected profile', async () => { + const parent = mkdtempSync(join(tmpdir(), 'dsh-run-help-')) + const home = join(parent, 'not-created') + try { + const result = await runBuiltBin(['run', '--help'], { DSH_HOME: home }) + expect(result.code).toBe(0) + expect(result.stderr).toBe('') + expect(result.stdout).toContain('Usage: dsh run [options] ') + expect(existsSync(home)).toBe(false) + } finally { + rmSync(parent, { recursive: true, force: true }) + } + }) + + it('runs the default headless profile through the published run command', async () => { + const apiKey = 'built-dsh-run-key' + const server = await startMockLlmServer({ + sequence: ['success'], + apiKey, + successText: 'published dsh run reached the mock', + }) + const home = mkdtempSync(join(tmpdir(), 'dsh-built-run-')) + try { + const result = await runBuiltBin(['run', 'answer', 'from', 'the', 'published', 'entry'], { + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + DEEPSEEK_API_KEY: apiKey, + DEEPSEEK_BASE_URL: server.baseURL, + }) + expect(result.code, result.stderr).toBe(0) + expect(result.stdout).toBe('published dsh run reached the mock') + expect(result.stderr).toMatch(/^dsh: observing at http:\/\/127\.0\.0\.1:\d+$/u) + expect(server.requests.length).toBeGreaterThan(0) + expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true) + expect(JSON.stringify(server.requests.map(request => request.body))).toContain('answer from the published entry') + } finally { + await server.close() + rmSync(home, { recursive: true, force: true }) + } + }, 30_000) + it('does not load a project environment for --version', async () => { const project = mkdtempSync(join(tmpdir(), 'dsh-version-project-')) writeFileSync(join(project, '.env'), 'PATH=/project-only-path\n') diff --git a/apps/cli/tests/headless-shutdown.e2e.ts b/apps/cli/tests/headless-shutdown.e2e.ts index cfa87b03de..237554864c 100644 --- a/apps/cli/tests/headless-shutdown.e2e.ts +++ b/apps/cli/tests/headless-shutdown.e2e.ts @@ -66,7 +66,7 @@ async function runHeadlessPtySmoke(): Promise { try { const home = join(cwd, '.dsh') // Pre-initialize the headless profile with the never-dispose row in its - // user patch layer (the same file `dsh --profile headless` hot-reloads). + // user patch layer (the same file a long-lived profile boot hot-reloads). const profileDir = join(home, 'profiles', 'headless') await mkdir(profileDir, { recursive: true }) await writeFile(join(profileDir, 'package.json'), JSON.stringify({ @@ -83,7 +83,7 @@ async function runHeadlessPtySmoke(): Promise { ].join('\n')) const launch = resolveExampleLaunch({ srcBin: dshBinScript, - configArgs: ['--profile', 'headless', 'never complete'], + configArgs: ['run', 'never complete'], tsconfigPath, env: { DSH_HOME: home, diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 42c2f52565..eda24bb29f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -509,7 +509,7 @@ export interface Config { } ``` -Source: [`packages/bundle/headless/src/index.ts:33`](../packages/bundle/headless/src/index.ts) +Source: [`packages/bundle/headless/src/index.ts:32`](../packages/bundle/headless/src/index.ts) ## `@deepseek-ai/dsh-hooks-claude` diff --git a/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml b/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml new file mode 100644 index 0000000000..e67630c029 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml @@ -0,0 +1,8 @@ +- id: api-gateway + config: + provider: cli-mock + model: cli-mock + +- insert: + - id: cli-mock-llm + name: !!js process.env.DSH_RUN_MOCK_PLUGIN_URL diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index f9cb46111a..9145c52f5a 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -2,7 +2,7 @@ import { readFile, readdir, writeFile } from 'node:fs/promises' import { createServer } from 'node:http' import type { IncomingMessage, ServerResponse } from 'node:http' import { delimiter, dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { normalizeSessionLog, normalizeStdout, @@ -14,6 +14,10 @@ import { type NormalizeContext, } from '@deepseek-ai/dsh-acp-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import { + decompressZstdFrame, + scanZstdFrames, +} from '@deepseek-ai/dsh-session-persistence-jsonl/src/zstd.ts' import { describe, expect, it } from 'vitest' const snapshotsDir = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') @@ -44,9 +48,15 @@ const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', im const startupFailureConfigPath = fileURLToPath(new URL('./fixtures/startup-activation-error/cordis.yml', import.meta.url)) const startupFailureExpected = join(snapshotsDir, 'startup-activation-error', 'stderr.expected.txt') const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) +const dshBinScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url)) const deepseekDefaultsConfigPath = fileURLToPath(new URL('./fixtures/deepseek-defaults.cordis.yml', import.meta.url)) +const dshRunOverlayPath = fileURLToPath(new URL('./fixtures/dsh-run.cordis.yml', import.meta.url)) +const dshRunSessionExpected = join(snapshotsDir, 'dsh-run', 'session.expected.jsonl') +const cliMockLlmPluginUrl = pathToFileURL( + fileURLToPath(new URL('./fixtures/cli-mock-llm.ts', import.meta.url)), +).href const refreshing = process.env.DSH_SNAPSHOT === 'refresh' interface JsonObject { @@ -167,16 +177,61 @@ async function scenarioPrompt(dir: string, label: string): Promise { return prompt } -async function persistedLogs(cwd: string): Promise { - const root = join(cwd, '.sessions') - const files = (await readdir(root, { recursive: true })).filter(file => file.endsWith('.jsonl')) +async function readPersistedLog(file: string): Promise { + const content = await readFile(file) + if (!file.endsWith('.zstd')) return content.toString('utf8') + const scan = scanZstdFrames(content) + if (scan.tornStart !== undefined) throw new Error(`persisted snapshot log has a torn Zstandard frame: ${file}`) + const decoded: Buffer[] = [] + for (const frame of scan.frames) { + decoded.push(await decompressZstdFrame(content.subarray(frame.start, frame.end))) + } + return Buffer.concat(decoded).toString('utf8') +} + +async function persistedLogs(cwd: string, root: string = join(cwd, '.sessions')): Promise { + const files = (await readdir(root, { recursive: true })) + .filter(file => file.endsWith('.jsonl') || file.endsWith('.jsonl.zstd')) return Promise.all(files.map(async (file) => { - const content = await readFile(join(root, file), 'utf8') + const content = await readPersistedLog(join(root, file)) return { content, header: parseJsonl(content)[0] ?? {} } })) } describe('headless stream-json snapshots', () => { + it('runs one task through the product dsh run command', async () => { + const task = 'Prove the product dsh run path with one real tool round trip.' + const result = await runLoaderSmoke({ + label: 'product dsh run snapshot', + tempDirPrefix: 'headless-snapshot-dsh-run-', + binScript: dshBinScript, + configPath: dshRunOverlayPath, + binArgs: ['run', '--patch', dshRunOverlayPath, task], + tsconfigPath, + env: { + DSH_RUN_MOCK_PLUGIN_URL: cliMockLlmPluginUrl, + DSH_PERMISSION_MODE: 'danger-full-access', + DSH_TELEMETRY_DISABLED: '1', + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + inspect: async (cwd) => { + const logs = await persistedLogs(cwd, join(cwd, '.dsh', 'sessions')) + expect(logs).toHaveLength(1) + const actual = logs[0] + if (actual === undefined) throw new Error('dsh run did not persist its session') + const context = contextFromLogs([actual.content]) + const session = scrubRequestHeaders(normalizeSessionLog(actual.content, context)) + if (refreshing) await writeFile(dshRunSessionExpected, session) + expect(session).toBe(await readFile(dshRunSessionExpected, 'utf8')) + expect(session).toContain(task) + expect(session).toContain('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP') + }, + }) + + expect(result.stdout).toBe('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP\n') + expect(result.stderr).toMatch(/^dsh: observing at http:\/\/127\.0\.0\.1:\d+\n$/u) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('prints the original Loader activation error through the assembled one-shot app', async () => { const result = await runLoaderSmoke({ label: 'headless startup activation error snapshot', diff --git a/examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl b/examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl new file mode 100644 index 0000000000..1312eb6511 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl @@ -0,0 +1,33 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"permission/preset","seq":0,"time":0,"data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","seq":1,"time":0,"data":{"mode":"danger-full-access"}} +{"type":"approval/policy","seq":2,"time":0,"data":{"policy":"never"}} +{"type":"agent/inbox/spliced","seq":3,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Prove the product dsh run path with one real tool round trip."}],"source":{"kind":"user","rpcId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Prove the product dsh run path with one real tool round trip."}],"source":{"kind":"user","rpcId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":9,"time":0,"data":{"title":"Prove the product dsh run","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":11,"time":0,"data":{"provider":"cli-mock","model":"cli-mock"}} +{"type":"session/title-llm-request","seq":12,"time":0,"data":{"titleProvider":"session-title-first-message-llm","messageSeqs":[7],"route":{"provider":"cli-mock","model":"cli-mock"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"Prove the product dsh run path with one real tool round trip.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"cli-smoke-call","name":"bash","argumentsDelta":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":11,"outputTokens":3,"cacheReadTokens":2}}}} +{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}],"source":{"kind":"model","provider":"cli-mock","model":"cli-mock"},"id":"{{sessionId}}"},"usage":{"inputTokens":11,"outputTokens":3,"cacheReadTokens":2}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} +{"type":"tool/call","seq":19,"time":0,"data":{"turn":1,"step":1,"callId":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}} +{"type":"tool/result","seq":20,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"cli-smoke-call"},"content":[{"type":"tool-result","toolCallId":"cli-smoke-call","content":[{"type":"text","text":"CLI_TOOL_ROUND_TRIP"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} +{"type":"step/end","seq":21,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":22,"time":0,"data":{"turn":1,"step":2}} +{"type":"request/header","seq":23,"time":0,"data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"off"},"system":"{{system}}","tools":"{{tools}}"},"reason":"change"}} +{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"CLI tool round trip complete: CLI_TOOL_ROUND_TRIP"}}} +{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CLI tool round trip complete: CLI_TOOL_ROUND_TRIP"}}}} +{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":7,"outputTokens":5,"reasoningTokens":1}}}} +{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":29,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CLI tool round trip complete: CLI_TOOL_ROUND_TRIP"}],"source":{"kind":"model","provider":"cli-mock","model":"cli-mock"},"id":"{{sessionId}}"},"usage":{"inputTokens":7,"outputTokens":5,"reasoningTokens":1}},"sourceEventSeqs":[24,25,26,27,28],"surfaceOp":"append"} +{"type":"step/end","seq":30,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":31,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 08e4a5a5b5..f1a9d53be8 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/bundle/headless/README.md -README.md: d08fb08e2aca3c4e5ccd733b37fc415d492974ca -README.zh.md: 99a64ef04c4fd8fb0c6a979d3f09f1bd98b434a0 +README.md: 661b377817482d22f58f22b573075722646729a2 +README.zh.md: a6b91a8e60fdcc06ba23e07dcb2f4208ea1020f7 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index d08fb08e2a..661b377817 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md): it moves the webserver to an OS-assigned port (parallel runs never collide), silences the URL line, and inserts this package's `headless-runner` plugin (config `{task}`). The runner drives one task turn through the in-process API carrier (`InProcessApiClient` over `toFetchHandler(ctx.apiProxy)`, so the full wire chain — serialization, zod, SSE framing — really runs), aggregates the turn's final assistant text, writes it to stdout, and requests exit (completed → 0, else 1) through the launcher-provided `ctx.headlessIo` seam. The Web composition stays mounted, so the running session is observable in a browser at the stderr-announced URL. The launcher patches the task text in (`dsh --profile headless "task"`), and fails loud when a task is given to a profile without this row. +The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md): it moves the webserver to an OS-assigned port (parallel runs never collide), silences the URL line, and inserts this package's `headless-runner` plugin (config `{task}`). The runner drives one task turn through the in-process API carrier (`InProcessApiClient` over `toFetchHandler(ctx.apiProxy)`, so the full wire chain — serialization, zod, SSE framing — really runs), waits at idle until that mux has consumed the session's final event sequence, aggregates the turn's final assistant text, writes it to stdout, and requests exit (completed → 0, else 1) through the launcher-provided `ctx.headlessIo` seam. The Web composition stays mounted, so the running session is observable in a browser at the stderr-announced URL. The launcher patches the task text in (`dsh run "task"`), and fails loud when the selected profile lacks this row. ## Model Experience diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index 99a64ef04c..a6b91a8e60 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md) 之上:把 webserver 移到 OS 分配的端口(并行运行绝不冲突),关闭 URL 行输出,并插入本包的 `headless-runner` 插件(配置为 `{task}`)。runner 通过进程内 API 载体(架在 `toFetchHandler(ctx.apiProxy)` 之上的 `InProcessApiClient`,因此序列化、zod、SSE(Server-Sent Events)帧封装这整条 wire 链路都会真实运行)驱动一个任务轮次,聚合该轮次最终的 assistant 文本,写到 stdout,再经启动器提供的 `ctx.headlessIo` seam 请求退出(完成 → 0,否则 1)。Web 组合保持挂载,因此运行中的会话可在浏览器中通过 stderr 公告的 URL 观察。启动器把任务文本 patch 进来(`dsh --profile headless "task"`);如果向没有这一行的 profile 传入任务,则大声失败。 +dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md) 之上:把 webserver 移到 OS 分配的端口(并行运行绝不冲突),关闭 URL 行输出,并插入本包的 `headless-runner` 插件(配置为 `{task}`)。runner 通过进程内 API 载体(架在 `toFetchHandler(ctx.apiProxy)` 之上的 `InProcessApiClient`,因此序列化、zod、SSE(Server-Sent Events)帧封装这整条 wire 链路都会真实运行)驱动一个任务轮次,在 idle 时等待该 mux 消费完会话的最终事件序号,再聚合该轮次最终的 assistant 文本,写到 stdout,并经启动器提供的 `ctx.headlessIo` seam 请求退出(完成 → 0,否则 1)。Web 组合保持挂载,因此运行中的会话可在浏览器中通过 stderr 公告的 URL 观察。启动器把任务文本 patch 进来(`dsh run "task"`);若所选 profile 缺少该行,则显式报错。 ## 模型体验 diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 572f11b487..8db505c3ac 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -6,8 +6,7 @@ * (InProcessApiClient over toFetchHandler(ctx.apiProxy), so the full wire * chain — serialization, zod, SSE framing — really runs), prints the final * assistant text at agent quiescence, and exits (completed → 0, else 1). The - * task text arrives as launcher-patched config - * (`dsh --profile headless "task"`). + * task text arrives as launcher-patched config (`dsh run "task"`). * @module @deepseek-ai/dsh-headless */ @@ -86,26 +85,31 @@ async function unwrap(response: RpcResponse, io: HeadlessIo): Promise { * `agent/status` subscription; the stream itself carries no status frame. * @param frames - the mux stream opened before the prompt. * @param sessionId - the headless session. - * @param idle - resolves when the agent reaches quiescence. + * @param idle - resolves to the final session-event sequence when the agent reaches quiescence. * @param io - process-facing effects for stream diagnostics. * @returns the aggregated outcome. */ async function consumeUntilIdle( frames: AsyncIterable>, sessionId: SessionId, - idle: Promise, + idle: Promise, io: HeadlessIo, ): Promise { let started = false let text = '' let reason: string = 'error' - void (async () => { + let observedSeq = -1 + let resolveProgress: (() => void) | undefined + const streamDone = (async () => { try { for await (const frame of frames) { const payload = frame.payload if (payload.type === 'stream/error') return if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue const event = payload.event + observedSeq = event.seq + resolveProgress?.() + resolveProgress = undefined if (event.type === 'turn/start') { started = true continue @@ -121,7 +125,12 @@ async function consumeUntilIdle( io.stderr.write(`dsh: event stream failed: ${String(error)}\n`) } })() - await idle + const streamEnded = streamDone.then(() => 'ended' as const) + const idleSeq = await idle + while (observedSeq < idleSeq) { + const progress = new Promise<'progress'>((resolve) => { resolveProgress = () => { resolve('progress') } }) + if (await Promise.race([progress, streamEnded]) === 'ended') break + } return { text, reason } } @@ -154,9 +163,9 @@ export function apply(ctx: Context, config: Config): void { // port of this runner must replace it with a wire-visible idle signal. const abort = new AbortController() const frames = api.events.mux({}, abort.signal) - const idle = new Promise((resolve) => { + const idle = new Promise((resolve) => { ctx.on('agent/status', ({ agent, status }) => { - if (agent.id === created.sessionId && status === 'idle') resolve() + if (agent.id === created.sessionId && status === 'idle') resolve(agent.session.seq - 1) }) }) const done = consumeUntilIdle(frames, created.sessionId, idle, io) diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index 6ff96619ff..9408ee62bf 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -21,26 +21,45 @@ function stamped(event: ScriptedEvent): ScriptedEvent { interface RpcShapedRequest { rpcId: string } +interface ScriptedApiOptions { + promptFails?: boolean + framesAfterPrompt?: boolean + onPrompt?: () => void +} + /** Build a fake apiProxy (echoing rpcIds like the real gateway) whose mux stream replays `events` for the created session. */ -function scriptedApi(events: ScriptedEvent[], options: { promptFails?: boolean } = {}): unknown { +function scriptedApi(events: ScriptedEvent[], options: ScriptedApiOptions = {}): unknown { + let releaseFrames = (): void => {} + const framesReady = options.framesAfterPrompt === true + ? new Promise((resolve) => { releaseFrames = resolve }) + : Promise.resolve() + const prepared = events.map((event) => { + if (event.type === 'stream/error') return { streamError: true } as const + const { sessionId = 'S1', ...rest } = event + return { streamError: false, sessionId, event: stamped(rest) } as const + }) return { sessions: { create: (request: RpcShapedRequest) => Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { sessionId: 'S1' } } }), - prompt: (request: RpcShapedRequest) => Promise.resolve(options.promptFails === true - // A code from the closed wire union: the carrier schema rejects invented codes. - ? { rpcId: request.rpcId, result: { ok: false, error: { code: 'agent-busy', message: 'agent is busy', details: { reason: 'test' } } } } - : { rpcId: request.rpcId, result: { ok: true, value: { accepted: true } } }), + prompt: (request: RpcShapedRequest) => { + releaseFrames() + options.onPrompt?.() + return Promise.resolve(options.promptFails === true + // A code from the closed wire union: the carrier schema rejects invented codes. + ? { rpcId: request.rpcId, result: { ok: false, error: { code: 'agent-busy', message: 'agent is busy', details: { reason: 'test' } } } } + : { rpcId: request.rpcId, result: { ok: true, value: { accepted: true } } }) + }, }, events: { mux: async function* () { - for (const event of events) { - if (event.type === 'stream/error') { + await framesReady + for (const item of prepared) { + if (item.streamError) { yield { rpcId: 'e', payload: { type: 'stream/error', error: { code: 'cancelled', message: 'stream broke', details: {} } } } continue } - const { sessionId = 'S1', ...rest } = event - yield { rpcId: 'e', payload: { type: 'session/event', sessionId, event: stamped(rest) } } + yield { rpcId: 'e', payload: { type: 'session/event', sessionId: item.sessionId, event: item.event } } } }, }, @@ -51,7 +70,10 @@ function scriptedApi(events: ScriptedEvent[], options: { promptFails?: boolean } * Mount the runner against a scripted API, emit the idle transition after the * scripted frames drain, and wait for its exit request. */ -async function run(events: ScriptedEvent[], options: { promptFails?: boolean } = {}): Promise<{ code: number; out: string; err: string }> { +async function run( + events: ScriptedEvent[], + options: { promptFails?: boolean; framesAfterPrompt?: boolean; idleInPrompt?: boolean } = {}, +): Promise<{ code: number; out: string; err: string }> { const ctx = new Context() let out = '' let err = '' @@ -63,16 +85,25 @@ async function run(events: ScriptedEvent[], options: { promptFails?: boolean } = } ctx.provide('headlessIo', io) }) - ctx.provide('apiProxy', scriptedApi(events, options) as never) + const emitIdle = (): void => { + ctx.emit('agent/status', { agent: { id: 'S1', session: { seq: nextSeq + 1 } } as Agent, status: 'idle' }) + } + ctx.provide('apiProxy', scriptedApi(events, { + ...options.promptFails === undefined ? {} : { promptFails: options.promptFails }, + ...options.framesAfterPrompt === undefined ? {} : { framesAfterPrompt: options.framesAfterPrompt }, + ...options.idleInPrompt === true ? { onPrompt: emitIdle } : {}, + }) as never) ctx.provide('httpServer', { port: 12345 } as never) apply(ctx, { task: 'do the thing' }) // Quiescence is out of band: give the scripted stream a beat to drain, then // flip the agent idle exactly as the loop would. Foreign agents and // non-idle transitions must not settle the run. - await new Promise(resolve => setTimeout(resolve, 10)) - ctx.emit('agent/status', { agent: { id: 'OTHER' } as Agent, status: 'idle' }) - ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'running' }) - ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'idle' }) + if (options.idleInPrompt !== true) { + await new Promise(resolve => setTimeout(resolve, 10)) + ctx.emit('agent/status', { agent: { id: 'OTHER' } as Agent, status: 'idle' }) + ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'running' }) + emitIdle() + } const code = await exited await ctx.fiber.dispose() return { code, out, err } @@ -106,6 +137,15 @@ describe('headless runner', () => { expect(err).toContain('observing at http://127.0.0.1:12345') }) + it('consumes through the idle sequence when queued frames arrive after the status transition', async () => { + const { code, out } = await run( + [messageTurn, text(1, 'race-free answer'), end(1, 'completed')], + { framesAfterPrompt: true, idleInPrompt: true }, + ) + expect(code).toBe(0) + expect(out).toBe('race-free answer\n') + }) + it('exits 1 when the final turn ends for any other reason', async () => { const { code } = await run([messageTurn, end(1, 'aborted')]) expect(code).toBe(1) @@ -168,7 +208,7 @@ describe('headless runner', () => { ctx.provide('httpServer', { port: 1 } as never) apply(ctx, { task: 't' }) await new Promise(resolve => setTimeout(resolve, 10)) - ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'idle' }) + ctx.emit('agent/status', { agent: { id: 'S1', session: { seq: nextSeq + 1 } } as Agent, status: 'idle' }) expect(await exited).toBe(1) expect(err).toContain('event stream failed') await ctx.fiber.dispose() diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 961b48dd0b..627f624235 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 packages/host/apiproxy/README.md -README.md: 5506cbef7b778a870e1e28c3f9fdf1713f89d65f -README.zh.md: de31f653944097e9b47a966f56c418dc9fa9b1b9 +README.md: a3c9f214690144ec0f39a8690e4fd346f5e315e2 +README.zh.md: aeaf1b29e5a71674c9feedb30b67f9ce11c47340 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 5506cbef7b..a3c9f21469 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -52,7 +52,7 @@ The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-pag ## Carrier layer (`/client` + root) -`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless. +`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh run` headless. ## Model Experience diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index de31f65394..aeaf1b29e5 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -52,7 +52,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr ## 载体层(`/client` + 根路径) -`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh -p` headless 模式使用。 +`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh run` headless 模式使用。 ## 模型体验