diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml index baf1c2f9b5..088891c738 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.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-06-20-generic-long-running-tool-runtime.md -2026-06-20-generic-long-running-tool-runtime.md: c34d0708ee88b94fcf9b9294fe002a9c3e081cfd -2026-06-20-generic-long-running-tool-runtime.zh.md: a95812692f16d5a5ea257c2439623346368313d5 +2026-06-20-generic-long-running-tool-runtime.md: 1edc3422c253e06178a5c8ebf68dfd4ef1289e31 +2026-06-20-generic-long-running-tool-runtime.zh.md: 1f5349b2aeb02e8db21150012300a6d0a0493ae1 diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md index c34d0708ee..1edc3422c2 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md @@ -25,6 +25,8 @@ Long-running tools are producers. `dsh-tool-bash` adapts a `BashProcess` into in The literal types live on the [tasks subsystem page](../../../../docs/subsystems/tasks.md). A producer calls `ctx.tasks.start()` with a kind, label, optional owning `Agent`, optional positive `outputLimitBytes`, and a `run()` function. The runtime completes all failable preflight work before calling `run()` and invokes it once. After `run()` returns hooks, registration commits without another failable step; a producer cannot start work that lacks a collectable task id. +The process-local provider also owns bounded admission, whose rationale is recorded in the [bounded background task admission decision](../bug-fix/2026-08-11-bounded-background-task-admission.md). Its positive-safe-integer `maxConcurrentTasksPerOwner` config defaults to `10`; `start()` derives each exact `Agent` object's active count from `running` and `stopping` records, while every unowned task shares one service bucket. Capacity rejection occurs before `run()` and id allocation, and producer `done` settlement is the only event that releases a stopping task's place. The provider does not queue, preempt, or retain a second mutable count. + `outputLimitBytes` is producer-owned presentation policy, not a registry buffer. The registry validates and projects it unchanged into `TaskSnapshot`; generic control APIs apply the cap to complete model-facing output after adding their own status or notice metadata. Omitting it preserves the existing controller behavior, so the runtime does not impose a hidden default on unrelated producer families. A model-facing producer exposes that committed id in its canonical success value, normally `{ kind: 'background', taskId }`; Native rendering may keep human-readable prose. A pre-aborted background call fails rather than returning a no-op because no task exists to satisfy the promised handle. Once registration publishes the id, cancellation belongs to the task's own controller and the task runtime: later cancellation of the producing tool call must not kill the published task. `task_kill`, owner disposal, and service teardown request cancellation; foreground execution remains coupled to the call's `exec.signal`. @@ -55,7 +57,7 @@ For contract-compliant producers, `AgentHandle.dispose()` resolves only after ow `TaskService` provides: -- `start(spec)` for preflighted, atomic registration. +- `start(spec)` for preflighted, provider-admitted, atomic registration. - `get(id, caller?)` and `list(caller?)` for non-consuming snapshots. - `read(id, caller?)` for a consuming stream delta or an idempotent final result. - `kill(id, caller?, reason?)` for cancellation. @@ -125,10 +127,12 @@ Authorization, not unguessability, is the access boundary, and ids do not derive ## Testing -Unit coverage pins preflight atomicity, per-kind ids, output-limit validation and projection, complete UTF-8 result bounds, stream and final reads, wait timeout and abort races, cancellation, first-wins settlement, listener containment, notice suppression, owner isolation, stale owner instances, owner cleanup, service teardown, and the no-controller fence. Producer tests cover bash process mapping, subagent startup cancellation, terminal mapping, and disposal. Snapshot coverage pins the control-tool schemas and prompt guidance. +Unit coverage pins preflight atomicity, per-kind ids, per-exact-owner and unowned-bucket admission, `stopping` occupancy, terminal release, output-limit validation and projection, complete UTF-8 result bounds, stream and final reads, wait timeout and abort races, cancellation, first-wins settlement, listener containment, notice suppression, owner isolation, stale owner instances, owner cleanup, service teardown, and the no-controller fence. Producer tests cover bash process mapping, subagent startup cancellation, terminal mapping, and disposal. Snapshot coverage pins the control-tool schemas, prompt guidance, and an assembled ACP path where the configured limit rejects a second real background Bash task with a `task_kill` recovery action. ## Consequences Bash commands and subagents share one id vocabulary, listing, notice format, prompt habit, and set of control tools. New long-running producers implement execution hooks instead of another registry and tool family. The [tool cookbook](../../../../docs/cookbook/adding-a-tool.md) points producers to this contract. +One exact owner cannot grow process-local Task-backed work without bound, and another owner does not consume its allowance. A cancellation request keeps capacity occupied until the producer actually releases its resource, so replacing slow-stopping work cannot exceed the configured live-resource budget. + Owned background bash now stops with its agent instead of surviving it. Background processes have no executor timeout; callers must kill irrelevant work or rely on owner/service disposal. Stream reads support one consuming reader, and a producer that returns from `cancel` without settling `done` can still stall teardown. Durable jobs, independent observation cursors, and foreground promotion remain separate designs. diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md index a95812692f..1f5349b2ae 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md @@ -25,7 +25,9 @@ Status: implemented 字面类型见[任务子系统页面](../../../../docs/subsystems/tasks.md)。生产方调用 `ctx.tasks.start()`,传入 kind、label、可选的所属 `Agent`、可选的正数 `outputLimitBytes` 与一个 `run()` 函数。运行时会在调用 `run()` 前完成所有可能失败的预检工作,并且只调用一次。`run()` 返回钩子后,注册过程不会再执行可能失败的步骤而直接提交;生产方无法启动没有可收集 task id 的工作。 -`outputLimitBytes` 是生产方拥有的呈现策略,而非注册表缓冲区。注册表校验该值,并将其原样投影到 `TaskSnapshot`;通用控制 API 添加自身的状态或通知元数据后,再将该上限应用于完整的面向模型输出。省略该值时保持现有控制器行为,因此运行时不会向无关的生产方类别施加隐式默认值。 +进程内 Service provider 还拥有有界准入,其理由记录在[有界后台任务准入决策](../bug-fix/2026-08-11-bounded-background-task-admission.md)中。它的 `maxConcurrentTasksPerOwner` 配置必须是正的安全整数,默认值为 `10`;`start()` 从 `running` 与 `stopping` 记录派生每个确切 `Agent` 对象的活动数量,而全部无 owner 任务共享一个服务级桶。容量拒绝发生在 `run()` 与 id 分配之前,处于 stopping 的任务只有在生产方 `done` 结算时才释放名额。Service provider 不排队或抢占任务,也不保留第二份可变计数。 + +`outputLimitBytes` 是生产方拥有的呈现策略,而非注册表缓冲区。注册表校验该值,并将其原样投影到 `TaskSnapshot`;通用任务控制器添加自身的状态或通知元数据后,再将该上限应用于完整的面向模型输出。省略该值时保持现有控制器行为,因此运行时不会向无关的生产方类别施加隐式默认值。 面向模型的生产方会在规范成功值中暴露已提交的 id,通常为 `{ kind: 'background', taskId }`;Native 渲染仍可保留便于人类阅读的行文。预先被中止的后台调用会失败,而不是返回空操作,因为不存在可履行所承诺句柄的任务。一旦注册过程发布 id,取消就归任务自身的控制器与任务运行时所有:随后取消生产工具调用不得终止已发布的任务。`task_kill`、所有者资源释放和服务拆除会请求取消;前台执行仍与调用的 `exec.signal` 耦合。 @@ -55,7 +57,7 @@ task id 在运行时全局可见且可预测,因此注册表会授权每次访 `TaskService` 提供: -- `start(spec)`:经过预检的原子注册。 +- `start(spec)`:经过预检与 Service provider 准入的原子注册。 - `get(id, caller?)` 和 `list(caller?)`:非消费式快照。 - `read(id, caller?)`:消费式流增量或幂等的最终结果。 - `kill(id, caller?, reason?)`:取消。 @@ -125,10 +127,12 @@ bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `Bas ## 测试 -单元覆盖固定预检原子性、按 kind 分配的 id、输出上限的校验与投影、完整结果的 UTF-8 字节上限、流式与最终读取、等待超时与中止竞态、取消、首次结果优先的结算、监听器隔离、通知压制、所有者隔离、陈旧的所有者实例、所有者清理、服务资源销毁和无控制器防线。生产方测试覆盖 bash 进程映射、subagent 启动取消、终止映射与释放。快照覆盖固定控制工具 schema 与提示词指导。 +单元覆盖固定预检原子性、按 kind 分配的 id、按确切 owner 与无 owner 桶执行的准入、`stopping` 占位、终态释放、输出上限的校验与投影、完整结果的 UTF-8 字节上限、流式与最终读取、等待超时与中止竞态、取消、首次结果优先的结算、监听器隔离、通知压制、所有者隔离、陈旧的所有者实例、所有者清理、服务资源销毁和无控制器防线。生产方测试覆盖 bash 进程映射、subagent 启动取消、终止映射与释放。快照覆盖固定控制工具 schema、提示词指导,以及一条组合完整的 ACP 路径:配置上限会拒绝第二个真实后台 Bash 任务,并给出 `task_kill` 恢复动作。 ## 后果 bash 命令与 subagent 共享一套 id 词汇、列表、通知格式、提示词习惯和控制工具。新的长时间运行生产方只需实现执行钩子,而不必再实现一套注册表与工具族。[工具实操手册](../../../../docs/cookbook/adding-a-tool.md)将生产方指向本约定。 +单个确切 owner 无法再无限增加进程内由 Task 承载的工作,另一个 owner 也不会消耗它的额度。取消请求会继续占用容量,直到生产方真正释放资源,因此用新工作替换缓慢停止的任务不会突破已配置的实时资源预算。 + 有所属后台 bash 会随其 agent 一起停止,不再比 agent 存活更久。后台进程没有执行器超时;调用方必须终止无关工作,或依赖所有者/服务释放。流式读取只支持一个消费方;生产方的 `cancel` 返回后如果未使 `done` 完成,仍可能阻塞资源销毁。持久任务、独立观察游标和前台提升仍属于单独设计。 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 9633d965b3..022b4ceae5 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 01081f1e0b8027420fedbc599f99c67e67a4639b -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 18ce0f0b56ddcfcdf920d5af983ec84c907b054c +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 4fe9ea3c4073e249a8c961634be9d329b35a3f4e +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 58b70e6e8968bdfd434e1374a591fc57fd876bce diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 01081f1e0b..4fe9ea3c40 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -40,15 +40,15 @@ The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-ru ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source, so the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory; macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source; CI rebuilds that addon inside the matching manylinux 2.28 container before packaging, and the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory. macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. -CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly only — `workflow_dispatch`, or the `build-exe` label on a pull request; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached; macOS ad-hoc signing is handled by pkg. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects GLIBC requirements and runs in a manylinux 2.28 container. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts only `python-vX.Y.Z` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. +CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly by `workflow_dispatch`, the `build-exe` label on a pull request, or the [public publication workflow](../process/2026-08-11-python-publication-workflow.md); native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached, and pkg handles macOS ad-hoc signing. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects both the executable and native addon's GLIBC requirements and runs in a manylinux 2.28 container, while macOS verifies that the executable's deployment target fits the wheel tag. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. ### Python SDK distribution: two carriers, exe for production, node for development The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe and optional helper, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative stable `X.Y.Z` from the repository root `package.json` and stages both packages at that version, with `deepseek-harness-sdk` depending exactly on `deepseek-harness-runtime-bin==X.Y.Z`. An optional `python-vX.Y.Z` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, and the macOS wheel also contains its architecture-matched helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or `py3-none-macosx_11_0_arm64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra helpers, and unsupported platforms. +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, and the macOS wheel also contains its architecture-matched helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or the conservative `py3-none-macosx_14_0_arm64` tag for the Node 24 executable's macOS 13.5 deployment target; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra helpers, and unsupported platforms. The exe's "must be explicitly configured" hard semantic is unchanged; the zero-config experience is restored by the wrapper: when the caller gave no `cordis`, named no explicit runtime, and the environment has no `DSH_CORDIS_CONFIG`, the client explicitly injects the checked-in default `cordis.yml` (agent-core + preloaded llm-deepseek + JSONL persistence + bash-local + the `dsh-jsonrpc` serving entry, with `!!js` environment-variable fallbacks) via `DSH_CORDIS_CONFIG`. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 18ce0f0b56..58b70e6e89 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -40,15 +40,15 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 ### 构建流水线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`,而 `--legacy` 部署会省略该副作用目录,因此构建器会把它从根安装目录复制到暂存闭包;macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 -CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),且只允许显式触发:手动派发 `workflow_dispatch`,或给 PR(Pull Request)添加 `build-exe` 标签。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查 GLIBC 依赖,并在 manylinux 2.28 容器中运行。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-vX.Y.Z` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 +CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),由手动派发 `workflow_dispatch`、PR(Pull Request)的 `build-exe` 标签或[公开发布工作流](../process/2026-08-11-python-publication-workflow.md)显式触发。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并在 manylinux 2.28 容器中运行;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 与可选 helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的稳定版本 `X.Y.Z`,以该版本暂存两个包,并让 `deepseek-harness-sdk` 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。可选的 `python-vX.Y.Z` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe,macOS wheel 包还包含与其架构匹配的 helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64` 三种标签之一;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、helper 缺失或多余,以及不支持的平台。 +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe,macOS wheel 包还包含与其架构匹配的 helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`,或针对 Node 24 可执行文件 macOS 13.5 部署目标而保守选择的 `py3-none-macosx_14_0_arm64` 标签;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、helper 缺失或多余,以及不支持的平台。 exe「必须显式配置」的硬语义不变;零配置体验由包装层恢复:调用方没有提供 `cordis`、没有显式指定运行时,且环境中没有 `DSH_CORDIS_CONFIG` 时,客户端将检入的默认 `cordis.yml`(`agent-core` + 预载的 `llm-deepseek` + JSONL 持久化 + `bash-local` + `dsh-jsonrpc` 对外服务条目,并通过 `!!js` 使用环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。 diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml index 4f03801595..b6c7fbaa38 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.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-26-task-registry-seam.md -2026-07-26-task-registry-seam.md: d5864d86577839c77ab27d70c9dc1c6a79685d56 -2026-07-26-task-registry-seam.zh.md: 096cf41c614d1e9e15b6421412e9dc8160e38099 +2026-07-26-task-registry-seam.md: b3cbae94c2d90b0834fd3221a153808ffc763258 +2026-07-26-task-registry-seam.zh.md: 2551b503069abe237d1b95610531af256f23b508 diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md index d5864d8657..b3cbae94c2 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md @@ -12,8 +12,8 @@ The [background-task runtime](2026-06-20-generic-long-running-tool-runtime.md) s `tasks/` is now a three-package capability family in the bash-trio shape: -- **`@deepseek-ai/dsh-tasks` (Service Definition)** — the abstract `TaskService extends Service` owning `ctx.tasks`, the eight-method contract (`start`, `list`, `get`, `read`, `kill`, `wait`, `onTaskDone`, `attachController`), all vocabulary types (`TaskId`, `TaskKindMap`, `TaskStart`, `TaskHooks`, `TaskOutcome`, `TaskSnapshot`, `TaskRead`, `TaskDoneListener`), and the snapshot invariant companion. The class-level JSDoc states the semantics every Service provider owes: registrations outlive producer and controller fibers, owned access is session-fenced, settlement is first-wins with contained listeners, and `start` refuses work while no attached task controller serves the spec's owner (controllers and listeners are scope-layered, so one process-wide registry answers both questions per owner). -- **`@deepseek-ai/dsh-tasks-local` (Service provider)** — `LocalTaskService`, the process-local registry moved verbatim: the in-memory store, per-kind counters, waiter bookkeeping, `TASK_WAIT_TIMEOUT` deadline code, owner-cleanup effects, and force-fail teardown. The `dsh-timeout` dependency moves here with it; the Service Definition package has no provider dependencies. +- **`@deepseek-ai/dsh-tasks` (Service Definition)** — the abstract `TaskService extends Service` owning `ctx.tasks`, the nine-method contract (`start`, `list`, `get`, `read`, `kill`, `wait`, `onTaskDone`, `onTasksChanged`, `attachController`), all vocabulary types (`TaskId`, `TaskKindMap`, `TaskStart`, `TaskHooks`, `TaskOutcome`, `TaskSnapshot`, `TaskRead`, `TaskDoneListener`), and the snapshot invariant companion. The class-level JSDoc states the semantics every Service provider owes: registrations outlive producer and controller fibers, owned access is session-fenced, settlement is first-wins with contained listeners, and `start` refuses work while no attached task controller serves the spec's owner (controllers and listeners are scope-layered, so one process-wide registry answers both questions per owner). +- **`@deepseek-ai/dsh-tasks-local` (Service provider)** — `LocalTaskService`, the process-local registry: the in-memory store, per-kind id counters, waiter bookkeeping, `TASK_WAIT_TIMEOUT` deadline code, owner-cleanup effects, force-fail teardown, and the default-10 configurable admission policy. Admission derives `running` plus `stopping` capacity from the same records per exact owner, with one unowned bucket; it adds no public count or second state owner. The `dsh-timeout` dependency and Schemastery-owned provider config live here; the Service Definition package has no provider dependencies. - **`@deepseek-ai/dsh-tool-tasks` (Consumer)** — unchanged; it injects `'tasks'` and never imports provider types. Compositions load `dsh-tasks-local` where they previously loaded `dsh-tasks` (the CLI cordis.yml row, `agent-spine-demo`, test harnesses, the tool-catalog generator boot). Producer misconfiguration diagnostics ("background tasks unavailable: load …") name `dsh-tasks` — the Service Definition package that declares the absent `ctx.tasks` service — and the Service Definition package's own APIs (its README and the direct-mount fence) point at Service providers, so the producer message stays correct when another backend becomes the recommended default. Producers, `TaskKindMap` declaration merges, and the controller keep importing `@deepseek-ai/dsh-tasks` only. @@ -22,7 +22,7 @@ The seam keeps the in-process contract semantics unchanged: `TaskStart.run()` st ## Alternatives considered -**Keep the concrete service until a second backend exists (status quo).** This was the original runtime note's position: extracting a Service Definition before a second provider risks freezing the wrong boundary. It lost because the boundary is no longer speculative — the eight service methods and their semantics have been stable across every producer integration since introduction, they are exactly the API `dsh-tool-tasks` and the producers already program against, and the repository convention treats swappable capabilities as three packages by default. The residual risk (a durable backend needing contract changes) is unchanged by the split: those changes would land in the Service Definition package either way, and today they would also churn every Consumer's provider dependency. +**Keep the concrete service until a second backend exists (status quo).** This was the original runtime note's position: extracting a Service Definition before a second provider risks freezing the wrong boundary. It lost because the boundary is no longer speculative — the nine service methods and their semantics have been stable across every producer integration since introduction, they are exactly the API `dsh-tool-tasks` and the producers already program against, and the repository convention treats swappable capabilities as three packages by default. The residual risk (a durable backend needing contract changes) is unchanged by the split: those changes would land in the Service Definition package either way, and today they would also churn every Consumer's provider dependency. **Service-Definition-only extraction inside one package (export an abstract class beside the concrete one).** Rejected because it separates nothing operationally: Consumers still depend on the package that carries the provider and its dependencies, and a replacement backend still cannot ship without the local one in its graph. The package boundary is the unit of independent evolution here. @@ -30,6 +30,6 @@ The seam keeps the in-process contract semantics unchanged: `TaskStart.run()` st ## Consequences -Bought: the task registry now matches the repository-wide seam shape; a durable, remote, or instrumented registry is a sibling Service provider implementing eight abstract methods, and no producer, controller, or `TaskKindMap` extender changes when one lands. The Service Definition README states the contract; the provider README owns the lifecycle bookkeeping facts. The registry behavior suite (owner cleanup, settlement, waits, teardown) lives with `dsh-tasks-local`; the Service Definition package keeps a stub-subclass test pinning registration under `ctx.tasks` and single-service duplication behavior, plus the probe-based invariant suite. +Bought: the task registry now matches the repository-wide seam shape; a durable, remote, or instrumented registry is a sibling Service provider implementing nine abstract methods, and no producer, controller, or `TaskKindMap` extender changes when one lands. The Service Definition README states the contract; the provider README owns the lifecycle bookkeeping facts. The registry behavior suite (owner cleanup, settlement, waits, teardown) lives with `dsh-tasks-local`; the Service Definition package keeps a stub-subclass test pinning registration under `ctx.tasks` and single-service duplication behavior, plus the probe-based invariant suite. Cost: one more package (manifest, tsconfig, README, invariant companion), and compositions must name the Service provider package. `abstract` erases at runtime and this package name used to be the mountable registry, so the Service Definition constructor fails loudly when mounted directly — a stale composition row gets "load a Service provider such as @deepseek-ai/dsh-tasks-local" at load time instead of a half-registered `ctx.tasks` failing far from the misconfiguration. diff --git a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md index 096cf41c61..2551b50306 100644 --- a/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-26-task-registry-seam.zh.md @@ -12,8 +12,8 @@ Status: implemented `tasks/` 如今是一个 bash 三件套形态的三包能力家族: -- **`@deepseek-ai/dsh-tasks`(Service Definition)**——抽象的 `TaskService extends Service`,拥有 `ctx.tasks`、八个方法的约定(`start`、`list`、`get`、`read`、`kill`、`wait`、`onTaskDone`、`attachController`)、全部词汇类型(`TaskId`、`TaskKindMap`、`TaskStart`、`TaskHooks`、`TaskOutcome`、`TaskSnapshot`、`TaskRead`、`TaskDoneListener`),以及快照不变式配套插件。类级 JSDoc 陈述了每个 Service provider 都必须兑现的语义:注册的存续期长于生产方与控制器的 fiber,有所有者的访问以会话为界,结算遵循首次结果优先且监听器错误被隔离,并且当没有任何已附加的任务控制器服务于 spec 的所有者时 `start` 拒绝启动工作(控制器与监听器按 scope 分层,因此一个进程级注册表能逐所有者地回答这两个问题)。 -- **`@deepseek-ai/dsh-tasks-local`(Service provider)**——`LocalTaskService`,即原样迁移的进程内注册表:内存存储、按 kind 划分的计数器、等待方簿记、`TASK_WAIT_TIMEOUT` deadline 代码、所有者清理 effect,以及强制失败的拆除。`dsh-timeout` 依赖随之迁入此包;Service Definition 包不含任何提供方依赖。 +- **`@deepseek-ai/dsh-tasks`(Service Definition)**——抽象的 `TaskService extends Service`,拥有 `ctx.tasks`、九个方法的约定(`start`、`list`、`get`、`read`、`kill`、`wait`、`onTaskDone`、`onTasksChanged`、`attachController`)、全部词汇类型(`TaskId`、`TaskKindMap`、`TaskStart`、`TaskHooks`、`TaskOutcome`、`TaskSnapshot`、`TaskRead`、`TaskDoneListener`),以及快照不变式配套插件。类级 JSDoc 陈述了每个 Service provider 都必须兑现的语义:注册的存续期长于生产方与控制器的 fiber,有所有者的访问以会话为界,结算遵循首次结果优先且监听器错误被隔离,并且当没有任何已附加的任务控制器服务于 spec 的所有者时 `start` 拒绝启动工作(控制器与监听器按 scope 分层,因此一个进程级注册表能逐所有者地回答这两个问题)。 +- **`@deepseek-ai/dsh-tasks-local`(Service provider)**——`LocalTaskService`,即进程内注册表:内存存储、按 kind 划分的 id 计数器、等待方簿记、`TASK_WAIT_TIMEOUT` deadline 代码、所有者清理 effect、强制失败的拆除,以及默认值为 10 且可配置的准入策略。准入从同一组记录中按确切 owner 派生 `running` 加 `stopping` 容量,并为无 owner 任务使用一个共享桶;它不新增公开计数或第二个状态 owner。`dsh-timeout` 依赖与由 Schemastery 管理的 Service provider 配置都位于此包;Service Definition 包不含任何提供方依赖。 - **`@deepseek-ai/dsh-tool-tasks`(Consumer)**——保持不变;它注入 `'tasks'`,从不导入提供方类型。 各组合在原先加载 `dsh-tasks` 的位置改为加载 `dsh-tasks-local`:CLI(命令行界面)的 cordis.yml 配置项、`agent-spine-demo`、各测试 harness,以及工具目录生成器的启动流程。生产方的配置错误诊断信息(「background tasks unavailable: load …」)点名 `dsh-tasks`——即声明缺失的 `ctx.tasks` 服务的 Service Definition 包;Service Definition 包自身的 API(其 README 与直接挂载防线)会指向各 Service provider,因此当另一个后端日后成为推荐默认时,生产方的消息依旧正确。生产方、`TaskKindMap` 声明合并和控制器仍然只导入 `@deepseek-ai/dsh-tasks`。 @@ -22,7 +22,7 @@ Status: implemented ## 曾考虑的替代方案 -**在第二个后端出现之前保持具体服务(维持现状)。**这正是运行时 Agent Note 当初的立场:在第二个 Service provider 出现前抽取 Service Definition,可能固化错误的边界。该方案落选,因为这条边界已不再是臆测:八个服务方法及其语义自引入以来在每一次生产方集成中都保持稳定,它们正是 `dsh-tool-tasks` 与各生产方已经面向编程的那套接口,而且仓库约定默认将可替换能力拆成三个包。剩余风险(持久化后端可能需要变更约定)不因这次拆分而改变:无论拆分与否,这类变更都会落在 Service Definition 包里;而若维持现状,它们今天还会连带搅动每个 Consumer 的提供方依赖。 +**在第二个后端出现之前保持具体服务(维持现状)。**这正是运行时 Agent Note 当初的立场:在第二个 Service provider 出现前抽取 Service Definition,可能固化错误的边界。该方案落选,因为这条边界已不再是臆测:九个服务方法及其语义自引入以来在每一次生产方集成中都保持稳定,它们正是 `dsh-tool-tasks` 与各生产方已经面向编程的那套接口,而且仓库约定默认将可替换能力拆成三个包。剩余风险(持久化后端可能需要变更约定)不因这次拆分而改变:无论拆分与否,这类变更都会落在 Service Definition 包里;而若维持现状,它们今天还会连带搅动每个 Consumer 的提供方依赖。 **在单个包内仅抽取 Service Definition(在具体类旁导出一个抽象类)。**否决,因为它在运作层面并未分离任何东西:Consumer 依然依赖携带 Service provider 及其依赖项的那个包,而替换后端若不把本地 Service provider 纳入自身依赖图,就仍然无法发布。在这里,包边界才是独立演进的单位。 @@ -30,6 +30,6 @@ Status: implemented ## 后果 -换来的是:任务注册表如今与全仓库通行的 seam 形态一致;持久化、远程或带插桩的注册表将是一个实现八个抽象方法的同级 Service provider,这样的注册表落地时,任何生产方、控制器或 `TaskKindMap` 扩展方都无需改动。Service Definition 的 README 陈述约定;生命周期簿记方面的事实归 Service provider 的 README 所有。注册表行为测试套件(所有者清理、结算、等待、拆除)随 `dsh-tasks-local` 存放;Service Definition 包保留一个桩子类(stub subclass)测试,固定 `ctx.tasks` 下的注册行为与单一服务的重复注册行为,外加基于探针的不变式测试套件。 +换来的是:任务注册表如今与全仓库通行的 seam 形态一致;持久化、远程或带插桩的注册表将是一个实现九个抽象方法的同级 Service provider,这样的注册表落地时,任何生产方、控制器或 `TaskKindMap` 扩展方都无需改动。Service Definition 的 README 陈述约定;生命周期簿记方面的事实归 Service provider 的 README 所有。注册表行为测试套件(所有者清理、结算、等待、拆除)随 `dsh-tasks-local` 存放;Service Definition 包保留一个桩子类(stub subclass)测试,固定 `ctx.tasks` 下的注册行为与单一服务的重复注册行为,外加基于探针的不变式测试套件。 代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合必须点名 Service provider 包。`abstract` 在运行时会被擦除,而这个包名过去正是可挂载的具体注册表,因此直接挂载 Service Definition 时,其构造函数会明确报错——一条陈旧的组合配置行会在加载时得到「load a Service provider such as @deepseek-ai/dsh-tasks-local」,而不是一个未完整注册的 `ctx.tasks` 在远离错误配置处才失败。 diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml index a28caeef90..6a0e73b494 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.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-29-dsh-source-launch-tsx-esm.md -2026-07-29-dsh-source-launch-tsx-esm.md: b2428602a780f2880f0f803ba59b16a76b39790e -2026-07-29-dsh-source-launch-tsx-esm.zh.md: 8d4503c3d5760ac94ac7e48451796609627cf657 +2026-07-29-dsh-source-launch-tsx-esm.md: effa61d8e0f54023d6971f73149214683d79b8d6 +2026-07-29-dsh-source-launch-tsx-esm.zh.md: 5dc2c63e1bd40bb35ad7666bd8daace7e6003b41 diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md index b2428602a7..effa61d8e0 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md @@ -14,7 +14,7 @@ Startup latency also mattered: the off-thread `module.register()` hooks worker s ## Decision -The `dsh` TUI, Web, and headless source launches run `node --import tsx/esm`: tsx's ESM-only hook owns both TypeScript transformation and tsconfig `paths` projection. The root `dsh` script completes the repository build, then uses that vector from the repository root. The CJS hook stays off because the CLI source graph is ESM-only; measured runtime launch to the TUI banner is ~0.7s versus ~1.1s under the full tsx default and ~0.75s under the removed native chain. +The `dsh` TUI, Web, and headless source launches run `node --import tsx/esm`: tsx's ESM-only hook owns both TypeScript transformation and tsconfig `paths` projection. The root `dsh` script uses that vector directly from the repository root; artifact generation is a separate operation under the [source-launch/build separation decision](../simplification/2026-08-12-separate-source-launch-from-build.md). The CJS hook stays off because the CLI source graph is ESM-only; measured runtime launch to the TUI banner is ~0.7s versus ~1.1s under the full tsx default and ~0.75s under the removed native chain. `scripts/tspath-loader.ts` and `apps/cli/src/tsconfig-paths-loader.ts` are deleted. With them went the loader's runtime rule of mapping a workspace import only for declared runtime dependencies — tsx applies the `paths` map unconditionally. Declaration completeness now rests on the static gates alone: `verify-cordis-config` for configured bare plugins, and workspace constraints for manifests. (That runtime rule found real bugs: `dsh-plan-mode` and `dsh-tool-tasks` imported `@deepseek-ai/dsh-llm` while declaring it only in devDependencies; since fixed.) diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md index 8d4503c3d5..5dc2c63e1b 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md @@ -14,7 +14,7 @@ Status: implemented ## 决策 -`dsh` 的 TUI、Web 与无头源码启动运行 `node --import tsx/esm`:由 tsx 的 ESM-only 钩子同时负责 TypeScript 转换与 tsconfig `paths` 投影。根目录的 `dsh` 脚本先完成仓库构建,然后从仓库根目录使用同一启动方式。CJS 钩子保持关闭,因为 CLI(命令行界面)源码图是纯 ESM;实测运行时启动至 TUI banner 耗时约 0.7s,对比完整 tsx 默认形态约 1.1s、已移除的原生链约 0.75s。 +`dsh` 的 TUI、Web 与无头源码启动运行 `node --import tsx/esm`:由 tsx 的 ESM-only 钩子同时负责 TypeScript 转换与 tsconfig `paths` 投影。根目录的 `dsh` 脚本直接从仓库根目录使用同一启动方式;产物生成是独立操作,由[源码启动与构建分离决策](../simplification/2026-08-12-separate-source-launch-from-build.md)规定。CJS 钩子保持关闭,因为 CLI(命令行界面)源码图是纯 ESM;实测运行时启动至 TUI banner 耗时约 0.7s,对比完整 tsx 默认形态约 1.1s、已移除的原生链约 0.75s。 `scripts/tspath-loader.ts` 与 `apps/cli/src/tsconfig-paths-loader.ts` 已删除。随之消失的还有该 loader「仅为已声明运行时依赖映射 workspace import」的运行时规则——tsx 无条件应用 `paths` 映射。声明完整性现在仅由静态门禁保障:配置的裸插件走 `verify-cordis-config`,manifest(元数据清单)走 workspace constraints。(该运行时规则确实发现过真实缺陷:`dsh-plan-mode` 与 `dsh-tool-tasks` 导入 `@deepseek-ai/dsh-llm` 却只声明在 devDependencies;后已修复。) diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.i18n.yaml new file mode 100644 index 0000000000..42ea8361e2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.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/bug-fix/2026-08-11-bounded-background-task-admission.md +2026-08-11-bounded-background-task-admission.md: 24512a87f554cd2d775fe76c5a6e5a700a51f2e4 +2026-08-11-bounded-background-task-admission.zh.md: dc3abaf4a64a4dc5fe5cacaabd3c29e278874646 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.md b/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.md new file mode 100644 index 0000000000..24512a87f5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.md @@ -0,0 +1,55 @@ +# Agent Note: Bounded background task admission + +Status: implemented + +English | [中文](2026-08-11-bounded-background-task-admission.zh.md) + +## Problem + +A model can start background Bash, PowerShell, PTY operations, and one-shot subagents in separate tool calls and later turns. The agent loop's `maxParallelToolCalls` limits only calls still executing inside one step; each background producer returns a task id immediately, so repeated starts can grow live processes or child work without bound. + +The process-local task registry already owns the exact task owner and the authoritative lifecycle state, but it retained terminal history beside live records and had no admission policy. Releasing capacity when cancellation was requested would also be incorrect: a `stopping` producer may still own its process, PTY, or child until `TaskHooks.done` settles. + +## Decision + +`LocalTaskService` owns a `maxConcurrentTasksPerOwner` configuration field. It accepts positive safe integers, defaults to `10`, and is available through the provider's Cordis schema, the typed `agent-spine-demo` bundle, and the ACP app configuration. The bundle transports the value; the process-local provider owns its meaning. + +The [generic task runtime decision](../architecture/2026-06-20-generic-long-running-tool-runtime.md) owns the shared Task lifecycle and control API; this note owns the process-local admission policy. + +`start()` performs admission after the existing task-controller, task-field, and live-owner checks and before `TaskStart.run()`. It derives the active count from the registry's current records instead of storing another counter: + +| Record | Occupies capacity | Release fact | +|---|---:|---| +| `running` | yes | producer `done` settles | +| `stopping` | yes | producer `done` settles | +| `completed`, `killed`, or `failed` | no | already terminal | + +Owned tasks are bucketed by exact `Agent` object identity, matching owner cleanup. Replacement agents that reuse a session id receive an independent bucket. Tasks without an owner share one service-level bucket, so omitting ownership is not an unlimited bypass. + +When the bucket is full, `start()` throws before producer execution and task-id allocation. The diagnostic includes the current limit and tells the model to use `task_kill`, wait until the task finishes stopping, and retry. Rejection creates no execution resource, queue entry, reservation, or public task record; a later successful start receives the next ordinary per-kind id. + +Owner and service disposal keep their existing order: request cancellation, retain `stopping` occupancy while producers release resources, await settlement, then remove records. The admission policy therefore follows the same lifecycle fact used by reads, notices, and cleanup rather than treating a cancellation request as resource release. + +Continuable background subagents remain outside this budget. They own durable child sessions and live Activations rather than Task records, so limiting them requires a separate result and lifecycle contract. This decision also adds no Task snapshot, session-log, wire, persistence, process-wide CPU or memory budget, queue, priority, preemption, or automatic oldest-task termination. + +## Verification + +The task-provider suite covers the default and explicit limits, producer-before rejection, unchanged id counters, `stopping` occupancy, every terminal release state, exact-owner isolation, same-session replacement objects, the shared unowned bucket, invalid configuration, owner cleanup, and service teardown. Spine and ACP composition tests pin typed forwarding. A keyless ACP replay boots the real Loader composition with a limit of one, starts one real background Bash process, observes the second start's actionable error, stops the first task by its returned id, and verifies that the rejected producer's marker file was never created. + +## Alternatives considered + +**Rely on `maxParallelToolCalls`.** Rejected because a background tool call releases its step slot as soon as it returns a task id; the setting cannot bound work that remains live across later steps and turns. + +**Release capacity when `task_kill` succeeds.** Rejected because successful cancellation only changes the task to `stopping`. The producer may still hold the resource until `done` settles, so admitting a replacement immediately would exceed the configured live-resource bound. + +**Use one global process bucket.** Rejected because one busy agent would deny unrelated sessions, while unowned host work still needs an explicit bounded bucket. Exact owner identity already defines the cleanup lifecycle and supplies the correct partition. + +**Queue, preempt, or terminate the oldest task.** Rejected because each policy adds ordering, ownership, and cancellation behavior beyond the requested fail-closed limit. An explicit rejection lets the model decide which work is no longer needed through the existing `task_kill` control. + +**Maintain a mutable active-count map.** Rejected because the registry already holds the authoritative records and statuses. A second count would require rollback and settlement synchronization while providing no user result that a direct derivation lacks. + +## Consequences + +One exact owner cannot keep creating Task-backed live resources indefinitely, and unrelated owners retain independent allowances. A slow stop keeps a bucket full until `done` settles, which is deliberate: the configured number bounds work that may still own resources, not cancellation requests. A producer whose `cancel` returns but whose `done` never settles holds one slot for the rest of the service lifetime and can stall teardown because the registry cannot safely infer resource release. + +Admission scans the process-local registry on each start. The cost grows with retained Task history, accepted in exchange for one state authority and a default limit small enough to bound the common live set. Terminal history remains available to existing reads and listings without consuming capacity. diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.zh.md new file mode 100644 index 0000000000..dc3abaf4a6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-bounded-background-task-admission.zh.md @@ -0,0 +1,55 @@ +# Agent Note: 有界后台任务准入 + +Status: implemented + +[English](2026-08-11-bounded-background-task-admission.md) | 中文 + +## 问题 + +模型可以在不同工具调用和后续回合中启动后台 Bash、PowerShell、PTY 操作与一次性 subagent。agent loop 的 `maxParallelToolCalls` 只限制单个步骤中尚未返回的调用;每个后台生产方会立即返回 task id,因此反复启动会让仍存活的进程或子工作无限增长。 + +进程内任务注册表已经拥有确切任务 owner 与权威生命周期状态,但终止历史和实时记录保存在一起,且没有准入策略。在请求取消时立即释放容量也不正确:处于 `stopping` 的生产方仍可能拥有进程、PTY 或子任务,直到 `TaskHooks.done` 结算。 + +## 决策 + +`LocalTaskService` 拥有 `maxConcurrentTasksPerOwner` 配置字段。它只接受正的安全整数,默认值为 `10`,并通过 Service provider 的 Cordis schema、typed `agent-spine-demo` 组合包与 ACP 应用配置提供。组合包只传输该值;其含义归进程内 Service provider 所有。 + +[通用任务运行时决策](../architecture/2026-06-20-generic-long-running-tool-runtime.md)拥有共享 Task 生命周期与控制 API;本记录只拥有进程内准入策略。 + +`start()` 在现有任务控制器、任务字段与存活 owner 检查之后、`TaskStart.run()` 之前执行准入。它从注册表当前记录派生活动数量,而不保存另一份计数: + +| 记录 | 占用容量 | 释放事实 | +|---|---:|---| +| `running` | 是 | 生产方 `done` 结算 | +| `stopping` | 是 | 生产方 `done` 结算 | +| `completed`、`killed` 或 `failed` | 否 | 已经终止 | + +有 owner 的任务按确切 `Agent` 对象身份分桶,与 owner 清理保持一致。复用同一会话 id 的替代 agent 获得独立桶。无 owner 的任务共享一个服务级桶,因此省略 owner 不会成为无界旁路。 + +桶已满时,`start()` 会在生产方执行和 task id 分配前抛出异常。诊断包含当前上限,并告诉模型使用 `task_kill`、等待任务完全停稳后再重试。拒绝不会创建执行资源、排队项、预留或公开任务记录;后续成功启动仍会取得按 kind 正常递增的下一个 id。 + +owner 与服务释放保留现有顺序:请求取消,在生产方释放资源期间继续让 `stopping` 占位,等待结算,然后移除记录。因此,准入策略遵循读取、通知与清理共同使用的同一生命周期事实,而不会把取消请求误当成资源释放。 + +可继续后台 subagent 仍不纳入此预算。它们拥有持久 child session 与实时 Activation,而不是 Task 记录;限制它们需要独立的用户结果与生命周期约定。本决策也不会新增 Task 快照、会话日志、wire、持久化、进程级 CPU 或内存预算、队列、优先级、抢占或自动终止最旧任务。 + +## 验证 + +任务 Service provider 测试覆盖默认与显式上限、生产方执行前拒绝、id 计数器不变、`stopping` 占位、每种终态释放、确切 owner 隔离、同会话替代对象、共享无 owner 桶、非法配置、owner 清理和服务拆除。spine 与 ACP 组合测试固定 typed 转发。一条 keyless ACP 回放以 1 为上限启动真实 Loader 组合,启动一个真实后台 Bash 进程,观察第二次启动返回可操作错误,按返回的 task id 停止第一个任务,并验证被拒绝生产方的标记文件从未生成。 + +## 曾考虑的替代方案 + +**依赖 `maxParallelToolCalls`。**否决,因为后台工具调用一返回 task id 就会释放其步骤槽位;该设置无法限制在后续步骤和回合中继续存活的工作。 + +**在 `task_kill` 成功时释放容量。**否决,因为取消成功只会把任务改为 `stopping`。生产方在 `done` 结算前仍可能持有资源,立即准入替代任务会突破已配置的实时资源上限。 + +**使用一个全局进程桶。**否决,因为一个繁忙 agent 会拒绝无关会话,而无 owner 的宿主工作仍需要一个明确的有界桶。确切 owner 身份已经定义清理生命周期,并提供正确分区。 + +**排队、抢占或终止最旧任务。**否决,因为每种策略都会增加超出 fail-closed 上限要求的顺序、所有权和取消行为。显式拒绝让模型通过现有 `task_kill` 控制自行决定哪些工作不再需要。 + +**维护一张可变活动计数表。**否决,因为注册表已经保存权威记录与状态。第二份计数需要回滚和结算同步,却无法提供直接派生所缺少的用户结果。 + +## 后果 + +单个确切 owner 无法再无限创建由 Task 承载的实时资源,无关 owner 则保留独立额度。缓慢停止会让桶保持满载直到 `done` 结算,这是有意行为:配置值限制的是仍可能拥有资源的工作,而不是取消请求。如果生产方的 `cancel` 返回后始终不结算 `done`,它会在服务剩余生命周期内持续占用一个名额并阻塞销毁,因为注册表无法安全推断资源已经释放。 + +每次启动都会扫描进程内注册表。成本随保留的 Task 历史增长;为了保持单一状态权威,并利用足以约束常见实时集合的较小默认值,接受这一代价。终止历史仍可供现有读取与列表使用,但不消耗容量。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.i18n.yaml new file mode 100644 index 0000000000..13b8524906 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.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/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md +2026-08-11-synchronous-subprocess-exit-cleanup.md: fba5014d67f5152d6f8e42b3b41c1bbd20c7ede3 +2026-08-11-synchronous-subprocess-exit-cleanup.zh.md: 33e13b7a1af9a943a266ea3ec979bf14e24f3802 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md new file mode 100644 index 0000000000..fba5014d67 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md @@ -0,0 +1,51 @@ +# Agent Note: Synchronous cleanup of managed subprocesses on host exit + +Status: implemented + +English | [中文](2026-08-11-synchronous-subprocess-exit-cleanup.zh.md) + +## Problem + +The local subprocess provider owns ordinary detached process trees and terminal sessions, but it previously reached them only through asynchronous Cordis disposal. A fatal launcher may call `process.exit()` before that disposal finishes: the [fail-loud release](2026-07-31-fail-loud-releases-the-terminal.md) waits at most two seconds, while a local process can have a longer termination grace. Once Node enters its synchronous exit phase, pending promises and escalation timers do not continue, so a TERM-resistant child can outlive the host and keep CPU, memory, or ports. Some ACP, JSON-RPC, and SDK entry points also have no root release callback. + +The public subprocess seam correctly promises awaited quiescence during normal disposal. The defect is a separate final host-exit path below that seam, not a reason to weaken the normal lifecycle or duplicate process ownership in every launcher. + +## Decision + +`LocalSubprocessService` installs one synchronous Node `exit` listener in its Cordis effect. The same effect removes the listener only after normal disposal settles. Ordinary and terminal handles remain in the service's existing live sets while asynchronous cleanup is pending, so a shorter outer exit bound still sees and force-terminates them. If awaited disposal reports a cleanup failure, the service invokes the same synchronous final operations before clearing the sets and removing the listener. + +The listener uses local-only final operations that are absent from the public `SubprocessHandle` and `SubprocessTerminalHandle` interfaces: + +- An ordinary handle immediately sends SIGKILL to its detached POSIX process group or runs synchronous `taskkill /PID /T /F` on Windows. +- A terminal handle synchronously signals every captured and currently observable descendant with SIGKILL, kills the PTY root, then rescans once for members that became observable during that boundary. +- The service contains each target's failure and continues with the remaining handles. The callback creates no promise or timer, writes no diagnostic, and does not change the original exit code or error. + +Normal disposal remains the [subprocess seam's](../architecture/2026-07-26-subprocess-seam.md) terminate-and-join path: ordinary trees receive TERM, the configured grace, then KILL, and every ordinary or terminal cleanup is awaited to quiescence. The synchronous path requests final termination but does not publish a completion result or claim the OS tree is already gone when the callback returns. Remote providers retain their own sandbox ownership and do not inherit a local Node listener. + +| Host path | Local provider action | Completion evidence | +| --- | --- | --- | +| Normal Cordis disposal | Cooperative termination, bounded escalation, and awaited ordinary/terminal cleanup | Every owned handle reaches quiescence before disposal settles | +| `process.exit()`, default uncaught exception, or default unhandled rejection | Synchronous final signals against the service's current live sets | External observation after the host exits | +| Default termination for an unhandled `SIGTERM`, `SIGINT`, or `SIGHUP`; `SIGKILL`; fatal OOM; `process.abort()`; native crash; or power loss | No in-process action can run | External supervisor, container, or OS ownership is required unless the application installs a signal handler that performs disposal or calls `process.exit()` | + +## Verification + +A parent test starts an isolated TypeScript host through the repository source launcher, waits until exact root and descendant process identities are observable, then allows the host to take each fatal path. Direct exit, default uncaught exception, and default unhandled rejection cover ordinary TERM-resistant trees; direct exit also covers a real terminal root and descendant. The parent asserts the original host exit category and waits for every recorded process to disappear, while failure cleanup targets only recorded identities or the recorded Windows tree. + +Unit evidence pins synchronous POSIX group and Windows taskkill delivery, terminal scans before and after the PTY root kill, repeated finalization, per-target failure containment, normal TERM-to-KILL disposal, live-set retention during pending disposal, and listener removal after disposal. + +## Alternatives considered + +**Rely only on launcher release callbacks.** Rejected because not every entry point supplies one, and a bounded release can still end before the subprocess provider's grace and timers complete. + +**Call the existing asynchronous `terminate()` methods from the `exit` listener.** Rejected because Node does not await exit listeners; promises, timers, output draining, and quiescence polling cannot finish after the callback returns. + +**Add a public raw `forceKill()` operation to subprocess handles.** Rejected because consumers need one cooperative termination contract. Immediate final termination is an implementation responsibility used only by the local service's host-exit owner. + +**Delegate every failure mode to an external supervisor.** Rejected as the only solution because Node exposes a reliable synchronous callback for several common fatal paths and the provider already owns the exact targets. External ownership remains necessary when JavaScript cannot run. + +## Consequences + +Each active local subprocess service contributes one process-global exit listener, removed with the service effect. Fatal exit gives up grace, output draining, and an in-process quiescence proof in exchange for issuing the strongest available local termination before the host disappears. Normal disposal keeps those guarantees and costs unchanged. + +The listener cannot cover failures that do not execute JavaScript, and it cannot discover a terminal descendant that escaped before the provider ever observed it; that separate ownership gap remains tracked by Issue #1726. diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md new file mode 100644 index 0000000000..33e13b7a1a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md @@ -0,0 +1,51 @@ +# Agent Note: 宿主退出时同步清理受管子进程 + +Status: implemented + +[English](2026-08-11-synchronous-subprocess-exit-cleanup.md) | 中文 + +## Problem + +本地 subprocess provider拥有普通 detached进程树和 terminal session,但此前只能通过异步 Cordis dispose触及它们。致命 launcher可能在 dispose完成前调用 `process.exit()`:[fail-loud release](2026-07-31-fail-loud-releases-the-terminal.md)最多等待两秒,而本地进程可以拥有更长的终止宽限期。Node进入同步退出阶段后,待处理的 Promise与升级 timer不会继续执行,因此忽略 TERM的子进程可能比宿主存活更久,继续占用 CPU、内存或端口。部分 ACP、JSON-RPC和 SDK入口也没有 root release回调。 + +公共 subprocess seam在正常 dispose期间承诺等待完全停稳,这项承诺是正确的。缺陷属于 seam之下另一条最终宿主退出路径,不应削弱正常生命周期,也不应让每个 launcher重复保存进程所有权。 + +## Decision + +`LocalSubprocessService`在自身 Cordis effect中安装一个同步 Node `exit` listener。只有正常 dispose结算后,同一 effect才移除该 listener。异步清理仍在等待时,普通和 terminal handle继续保留在服务已有的存活集合中,因此更短的外层退出上限仍能看到并强制终止它们。等待中的 dispose报告清理失败时,服务会在清空集合并移除 listener前调用同一组同步最终操作。 + +该 listener使用本地实现私有的最终操作;公共 `SubprocessHandle`和 `SubprocessTerminalHandle`接口不包含这些操作: + +- 普通 handle立即向 detached POSIX进程组发送 SIGKILL,或在 Windows同步运行 `taskkill /PID /T /F`。 +- Terminal handle同步向全部已捕获及当前可观察的后代发送 SIGKILL,终止 PTY root,然后再扫描一次并终止在该边界期间变得可观察的成员。 +- 服务分别包含每个目标的失败并继续处理其余 handle。回调不会创建 Promise或 timer,不写诊断,也不改变原始退出码或错误。 + +正常 dispose继续使用[subprocess seam](../architecture/2026-07-26-subprocess-seam.md)的先终止再等待退出路径:普通进程树先接收 TERM,经过配置的宽限期后再接收 KILL,并等待每个普通或 terminal清理达到完全停稳。同步路径只请求最终终止,不发布完成结果,也不声称回调返回时 OS进程树已经消失。远程 provider继续由其 sandbox独立拥有,不继承本地 Node listener。 + +| 宿主路径 | 本地 provider动作 | 完成证据 | +| --- | --- | --- | +| 正常 Cordis dispose | 协作式终止、有界升级,并等待普通/terminal清理 | dispose结算前,每个自有 handle均达到完全停稳 | +| `process.exit()`、默认未捕获异常或默认未处理 rejection | 对服务当前存活集合发送同步最终信号 | 宿主退出后的外部观察 | +| 未安装 handler 时由 `SIGTERM`、`SIGINT` 或 `SIGHUP` 默认终止;`SIGKILL`;fatal OOM;`process.abort()`;native crash;或断电 | 进程内操作无法运行 | 必须由外部 supervisor、容器或 OS 所有权负责;应用安装执行 dispose 或调用 `process.exit()` 的信号 handler 时除外 | + +## Verification + +父测试通过仓库 source launcher启动隔离的 TypeScript宿主,等待精确 root与后代进程身份可观察后,再允许宿主进入各条致命路径。直接退出、默认未捕获异常和默认未处理 rejection覆盖忽略 TERM的普通进程树;直接退出还覆盖真实 terminal root与后代。父测试断言原始宿主退出类别,并等待所有已记录进程消失;失败清理只针对已记录身份或已记录的 Windows进程树。 + +单元证据固定同步 POSIX进程组与 Windows taskkill投递、PTY root终止前后的 terminal扫描、重复最终清理、逐目标失败包含、正常 TERM到 KILL dispose、dispose等待期间保留存活集合,以及 dispose后移除 listener。 + +## Alternatives considered + +**只依赖 launcher release回调。** 拒绝,因为不是每个入口都会提供该回调,而且有界 release仍可能在 subprocess provider的宽限期与 timer完成前结束。 + +**在 `exit` listener中调用现有异步 `terminate()`。** 拒绝,因为 Node不会等待 exit listener;回调返回后,Promise、timer、输出排空与停稳轮询都无法完成。 + +**向公共 subprocess handle增加 raw `forceKill()`操作。** 拒绝,因为消费方只需要一项协作式终止约定。立即最终终止属于实现职责,只由本地服务的宿主退出 owner使用。 + +**把所有故障模式交给外部 supervisor。** 不接受将其作为唯一方案,因为 Node为几条常见致命路径提供可靠的同步回调,而 provider已经拥有精确目标。JavaScript无法运行时仍必须依赖外部所有权。 + +## Consequences + +每个有效的本地 subprocess service都会贡献一个进程全局 exit listener,并随服务 effect移除。致命退出放弃宽限、输出排空与进程内停稳证明,以换取宿主消失前发出本地可用的最强终止操作。正常 dispose的保证与成本保持不变。 + +listener无法覆盖不执行 JavaScript的故障,也无法发现 provider首次观察前已经逃逸的 terminal后代;该独立所有权缺口仍由 Issue #1726跟踪。 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index 78a04b4f3f..4d4677e694 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.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/feature/2026-07-20-dsh-cli-personal-config.md -2026-07-20-dsh-cli-personal-config.md: bc2aff322de01bb9c6beebb1679b2ff9909d1fe3 -2026-07-20-dsh-cli-personal-config.zh.md: 793832fae754d34a69025cad447df51e7d457236 +2026-07-20-dsh-cli-personal-config.md: 58eba652b2dc8617c15313f45ed4af0a08678b08 +2026-07-20-dsh-cli-personal-config.zh.md: ab7e7266efca35f9e25e422e39027733e5474630 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index bc2aff322d..58eba652b2 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -14,7 +14,7 @@ The entry modes and the personal file's name and location below are superseded b Two coupled pieces, aligned with the `apps/` assembly tier proposed by the `dsh web` PR (#443): -**The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` is the product-assembly tier over `packages/*` libraries. One bin dispatches the default interactive TUI, `-p`/`--prompt` headless turns, and the `web` surface. The TUI boots `examples/tui-agent/cordis.yml` (or `--config`) with the invoking directory as the workspace. From a source checkout, the root `pnpm dsh` script builds the repository and runs the same entry with tsx's ESM hook; the [source-launch decision](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md) owns that contract. +**The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` is the product-assembly tier over `packages/*` libraries. One bin dispatches the default interactive TUI, `-p`/`--prompt` headless turns, and the `web` surface. The TUI boots `examples/tui-agent/cordis.yml` (or `--config`) with the invoking directory as the workspace. From a source checkout, the root `pnpm dsh` script runs the same entry with tsx's ESM hook without building; the [source-launch decision](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md) owns the runtime vector and the [source-launch/build separation decision](../simplification/2026-08-12-separate-source-launch-from-build.md) owns artifact generation. **Personal config (`dsh-app-boot`).** The personal overlay lives in the Harness home — `$DSH_HOME`, else `~/.dsh` — resolved by the shared [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md) (`@deepseek-ai/dsh-paths`), the same single root skills and AGENTS.md resolve against. The dsh TUI, Web, and headless surfaces consume its two optional files; the demo bins boot their committed trees verbatim: diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index 793832fae7..ab7e7266ef 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -14,7 +14,7 @@ Status: implemented 两个耦合的部分,与 `dsh web` PR(#443)提出的 `apps/` 装配层对齐: -**`dsh` CLI(命令行界面;`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 是位于 `packages/*` 库之上的产品组装层。一个 bin 负责分发默认交互式 TUI、`-p`/`--prompt` 无头轮次和 `web` 界面。TUI 以调用目录为 workspace,启动 `examples/tui-agent/cordis.yml`(或 `--config` 指定的配置)。在源码检出中,根目录的 `pnpm dsh` 脚本先构建仓库,再使用 tsx 的 ESM hook 运行同一入口;该约定由[源码启动决策](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md)维护。 +**`dsh` CLI(命令行界面;`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 是位于 `packages/*` 库之上的产品组装层。一个 bin 负责分发默认交互式 TUI、`-p`/`--prompt` 无头轮次和 `web` 界面。TUI 以调用目录为 workspace,启动 `examples/tui-agent/cordis.yml`(或 `--config` 指定的配置)。在源码检出中,根目录的 `pnpm dsh` 脚本不执行构建,直接使用 tsx 的 ESM hook 运行同一入口;运行方式由[源码启动决策](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md)规定,产物生成由[源码启动与构建分离决策](../simplification/2026-08-12-separate-source-launch-from-build.md)规定。 **个人配置(`dsh-app-boot`)。** 个人 overlay 存放在 Harness home——`$DSH_HOME`,否则 `~/.dsh`——由共享的 [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md)(`@deepseek-ai/dsh-paths`)解析,与 skill(技能)、AGENTS.md 解析所依据的单一根目录相同。dsh 的 TUI、Web 和无头界面使用其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动: diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index 8835928ce0..9a483390fb 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.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/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: f1fbcbd29b188505e647265c4c974886c066e936 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 499bd3565b3c8e9a2c315153862d49065fecbe93 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 8639a1ab638c85fc01a29083a1b81eacdc2152d4 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 62c8be9b6fa2b127203d595f0ac3b8620e3f6038 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md index f1fbcbd29b..8639a1ab63 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -140,7 +140,7 @@ Composer thumbnails and each `MessageImage` own ephemeral original-preview state ### Limits and trust boundaries -Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 5 MiB per image, 10 images and 20 MiB aggregate image bytes per message, and 40 million intrinsic pixels per image. These deployment-varying limits are validated backend configuration and enforced by the host before persistence. The client connection carrier has an independent configurable `maxRequestBodyBytes` cap (32 MiB by default) for every API request and fails load if it cannot hold the attachment service's aggregate image limit after base64 and envelope expansion; lowering image policy therefore never silently lowers the carrier limit for valid text or other RPCs. A body without a declared length is rejected the moment it crosses the cap rather than drained to its end. +Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 5 MiB per image, 20 images and 100 MiB aggregate image bytes per message, and 40 million intrinsic pixels per image. These deployment-varying limits are validated backend configuration and enforced by the host before persistence. The client connection carrier has an independent configurable `maxRequestBodyBytes` cap (160 MiB by default) for every API request and fails load if it cannot hold the attachment service's aggregate image limit after base64 and envelope expansion; lowering image policy therefore never silently lowers the carrier limit for valid text or other RPCs. A body without a declared length is rejected the moment it crosses the cap rather than drained to its end. Malformed base64, unsupported or mismatched media, truncated image payloads, excess bytes, excess image count, excess pixels, missing objects, and integrity mismatches return stable structured failures. Original filenames are reduced to a display basename, control characters are removed, and no local path is logged or returned to the browser. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index 499bd3565b..62c8be9b6f 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -140,7 +140,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme ### 限制与信任边界 -第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并由宿主在持久化前强制执行。客户端连接载体为每个 API 请求设置独立且可配置的 `maxRequestBodyBytes` 上限(默认 32 MiB);如果该上限无法容纳附件服务的图片总量限制经 base64 和请求封装膨胀后的大小,加载就会失败。因此,降低图片策略绝不会静默降低有效文本或其他 RPC 的载体上限。未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 +第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 20 张图片和 100 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并由宿主在持久化前强制执行。客户端连接载体为每个 API 请求设置独立且可配置的 `maxRequestBodyBytes` 上限(默认 160 MiB);如果该上限无法容纳附件服务的图片总量限制经 base64 和请求封装膨胀后的大小,加载就会失败。因此,降低图片策略绝不会静默降低有效文本或其他 RPC 的载体上限。未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 格式错误的 base64、不支持或不匹配的媒体、截断的图片数据、超出字节限制、超出图片数量、超出像素限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。 diff --git a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.i18n.yaml b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.i18n.yaml new file mode 100644 index 0000000000..1ea48bbef6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.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-12-web-image-intake-and-limits-alignment.md +2026-08-12-web-image-intake-and-limits-alignment.md: 2f8b99bb4850d9875dcba0a03ae8ad9f340d1506 +2026-08-12-web-image-intake-and-limits-alignment.zh.md: 62d5ebd54275ae8de0e0b9ba701ba34042dfcde7 diff --git a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.md b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.md new file mode 100644 index 0000000000..2f8b99bb48 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.md @@ -0,0 +1,35 @@ +# Agent Note: Whole-page image drop, projected intake limits, and thumbnail tiling + +Status: implemented + +English | [中文](2026-08-12-web-image-intake-and-limits-alignment.zh.md) + +## Problem + +The second alignment step for issue #2248, after the [attachment display note](2026-08-11-web-attachment-display-alignment.md) (whose rail/toast/atoms decisions stand; this note supersedes its history-gallery geometry and the lightbox backdrop specifics). Remaining gaps against DeepSeek Chat: images could only be dropped on the composer card — a drop over the transcript navigated the browser away to the file; the lightbox close glyph was a bare `×` text character (buttons inherit no font family and the glyph's ink sits above the line box center, so it rendered visibly off-center) over a `color-mix(label-primary 74%)` backdrop that inverts to a bright white wash in dark mode; a message's images stacked vertically as up-to-240px blocks because the gallery container itself was pinned to 240px; and nothing client-side enforced or displayed the image limits — a user could stack 50 images and learn about `maxImagesPerMessage` only from a raw `attachment-error (TOO_MANY_IMAGES)` toast after submit, watching the rail empty and refill around the failure. + +## Decision + +**Whole-page drop.** InputBar binds `dragenter`/`dragover`/`dragleave`/`drop` on the document (enter/leave depth counting, viewport-edge and `dragend` resets, `Files`-type gating so text drags keep their native textarea path) and renders the new `DropOverlay` atom in `ui-attachment`: a body-portaled, pointer-inert full-viewport layer (DeepSeek Chat's DragMask visuals — white/70% + 10px blur, dark `rgba(39,39,48,0.7)`, illustration, title, limits line) whose `disabled` variant announces a locked/busy composer. Pointer-inertness is load-bearing: drag events keep targeting the page below, so the depth count never sees the overlay itself. Document-level listener state is safe because the composer-bar slot is `kind: 'single'`. + +**Lightbox.** The close control is `ui-primitives`' `IconCloseOutline16` (the Modal precedent — an SVG centered in its viewBox needs no font metrics). The backdrop is the shared dialog mask (`--dsw-alias-bg-mask-1` + `--dsw-mask-blur`, black-based in both themes) painted on a separate sibling layer, because `backdrop-filter` on the container would blur the previewed image itself. + +**History thumbnails (DeepSeek Chat rules).** A message's lone image renders at 240px on its long edge with the displayed ratio clamped to [0.25, 4], cropped by `cover` with the anchor at the top of very tall images and the left of very wide ones, never upscaled; several images render as fixed 64px square tiles in one wrapping row (10px gap, user messages right-aligned). Consecutive assistant `image` blocks merge into one gallery so they tile instead of each opening a one-image row. + +**Limits aligned and projected.** Defaults are 20 images / 5 MiB per image / 100 MiB aggregate (`attachment-local`), with the HTTP carrier cap raised to one shared `DEFAULT_MAX_REQUEST_BODY_BYTES = 160 MiB` (http-bridge, previously two independent 32 MiB literals) to satisfy the load-time capacity assertion (aggregate × 4/3 + headroom ≈ 134.3 MiB). Consumer products cluster at 10–20 attachments (ChatGPT 10, Gemini 10, Claude 20; DeepSeek Chat's 50 is the outlier), and a vision-model image costs roughly 1300–4800 tokens, so 50 images can fill a 200k context in one message. The 5 MiB per-image default admits images across Anthropic routes that impose either a 5 MiB or 10 MiB maximum; deployments using only routes with the larger limit can override it. A 512 MiB aggregate cannot pass this transport because base64-in-JSON would need a single JSON string past V8's ~512 MiB string ceiling. The limits reach clients as the `imageLimits` session projection — a constant-per-boot unit (`apply` returns the same state reference, so baselines alone carry it and no change frames exist) registered by **apiproxy**, not the attachment Service Definition: `dsh-llm` depends on `dsh-attachment` (`ImageBlock` → `ImageAttachmentRef`), so the seam package referencing `dsh-session-projection` (whose graph reaches `dsh-llm` through `dsh-session`) closes a project-reference cycle, and the per-message count/aggregate rules the value describes are the proxy's own admission checks anyway. The `SessionProjectionMap` merge rides the proxy's sessions wire-contract file, which every client program already includes through the carrier's type re-exports. + +**Intake pre-check and error copy.** Both intake gestures converge on one `intakeImages` wrapper in InputBar that checks count, per-image bytes, and aggregate bytes against the projection before `addImages`: a violating batch is refused whole (DeepSeek Chat semantics) with an immediate banner naming the limit — no submit-time rollback theater. The host checks stay as the backstop for callers that bypass the composer. Banner copy follows one principle the user set: reasons a user can act on (model without vision, count, size, resolution, format — now a positive list of supported formats instead of echoing the rejected MIME type) get product sentences naming the way out; reasons they cannot act on (corrupt base64, lost references, read failures) fold into one send-failed sentence that keeps the reason code, because the product currently faces developers and a reportable code beats a dead end. Non-attachment error codes keep the raw message + code presentation. + +## Alternatives considered + +**Registering the projection unit in the attachment Service Definition's constructor.** The natural seam owner, and the first implementation — rejected by the dependency graph (the cycle above) and by a test-harness interaction: the base constructor calling `ctx.inject` made directly-constructed stores in specs trigger the global invariant host, which then double-mounted an `attachments` service into the same root. + +**`--dsw-alias-bg-mask-photo` (0.88 black, theme-stable, unused) for the lightbox.** The design system's photo-viewer token and dsweb's likely lightbox wash; the user chose consistency with the settings dialog mask (`bg-mask-1` + blur) — both fix the dark-mode inversion. + +**Pre-checking inside `apply.ts`'s `addImages` inject.** The seam-purist placement, rejected for plumbing cost: the projection store has no non-React face exposed to the inject factory, while InputBar already consumes projections idiomatically and is the single caller of both gestures. + +**A `host.describe` field instead of a projection.** Session-independent and cheaper, but delivered through an injected prop chain rather than `useProjection`, and the projection's key-absence semantics ("no attachment service composed → no pre-check") fall out for free. + +## Consequences + +A drop anywhere on the window now lands in the rail, over-limit intake fails at the moment of the gesture with copy naming the limit, and history images tile like DeepSeek Chat's. The carrier's default request-body budget is ~5× larger and remains a per-request resident-memory bound (the bridge buffers bodies whole; recorded in the connection README's limitations). The fixture transport mirrors the projection with hardcoded default numbers — a deployment that overrides the limits diverges from fixture-mode copy, acceptable for a keyless demo lane. Gallery arrow navigation, lightbox zoom/download, and non-image file cards remain deferred (#2248). diff --git a/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.zh.md b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.zh.md new file mode 100644 index 0000000000..62d5ebd542 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-12-web-image-intake-and-limits-alignment.zh.md @@ -0,0 +1,35 @@ +# Agent Note:整页图片拖放、上限投影预检与缩略图平铺 + +状态:implemented + +[English](2026-08-12-web-image-intake-and-limits-alignment.md) | 中文 + +## 问题 + +issue #2248 的第二步对齐,接在[附件展示 note](2026-08-11-web-attachment-display-alignment.md) 之后(其附件栏、toast 与原子组件包的决策继续有效;本 note 取代其中历史画廊几何与灯箱 backdrop 的具体规格)。与 DeepSeek Chat 相比剩下的差距:图片只能拖到 composer 卡片上——拖到聊天记录区会让浏览器直接导航到文件;灯箱关闭钮是裸 `×` 文本字符(button 不继承字体,且该字形的墨迹在行框中心之上,因此明显偏斜),backdrop 用 `color-mix(label-primary 74%)`,dark 下反转成刺眼的白色蒙层;一条消息的多张图各自以最大 240px 的块竖着堆叠,因为画廊容器本身被钉在 240px;客户端完全不执行也不展示图片限额——用户可以攒 50 张图,直到提交后收到原始的 `attachment-error (TOO_MANY_IMAGES)` toast,眼看附件栏清空又回滚。 + +## 决策 + +**整页拖放。** InputBar 在 document 上绑定 `dragenter`/`dragover`/`dragleave`/`drop`(enter/leave 深度计数、视口边缘与 `dragend` 复位、按 `Files` 类型门控使文本拖拽保留原生 textarea 路径),并渲染 `ui-attachment` 新增的 `DropOverlay` 原子组件:经 body portal、不接收指针事件的全视口层(DeepSeek Chat DragMask 的视觉——白色 70% 加 10px 模糊,dark 为 `rgba(39,39,48,0.7)`,插画、标题、上限行),`disabled` 变体宣告锁定或忙碌的 composer。指针惰性是承重的:拖拽事件继续命中下方页面,深度计数永远看不到遮罩自己。document 级监听状态是安全的,因为 composer-bar slot 为 `kind: 'single'`。 + +**灯箱。** 关闭钮换成 `ui-primitives` 的 `IconCloseOutline16`(Modal 的先例——在 viewBox 内居中的 SVG 不依赖字体度量)。backdrop 用共享的对话框遮罩(`--dsw-alias-bg-mask-1` 加 `--dsw-mask-blur`,两个主题都是黑基色),画在独立的兄弟图层上,因为 `backdrop-filter` 画在容器上会把预览图自己也模糊掉。 + +**历史缩略图(DeepSeek Chat 规则)。** 一条消息仅有的一张图长边 240px、展示比例钳制在 [0.25, 4],`cover` 裁切,特别高的图锚定顶部、特别宽的锚定左侧,从不放大;多张图渲染为固定 64px 方块,单个可换行的横排(10px 间距,用户消息右对齐)。assistant 连续的 `image` 块合并进同一个画廊,平铺而不是各占一行。 + +**上限对齐并投影。** 默认值为每条消息 20 张、单图 5 MiB、总量 100 MiB(`attachment-local`),HTTP 载体上限提为唯一共享的 `DEFAULT_MAX_REQUEST_BODY_BYTES = 160 MiB`(http-bridge,原先是两个独立的 32 MiB 字面量),以满足加载时的容量断言(总量 × 4/3 加余量 ≈ 134.3 MiB)。消费级产品集中在 10 到 20 个附件(ChatGPT 10、Gemini 10、Claude 20;DeepSeek Chat 的 50 是例外),且视觉模型一张图约 1300 到 4800 token,因此 50 张图可在一条消息中填满 200k 上下文。默认单图上限采用 5 MiB,可适用于分别采用 5 MiB 或 10 MiB 上限的 Anthropic 路由;仅使用较大上限路由的部署可以覆盖该值。512 MiB 总量无法通过当前传输,因为 base64 进 JSON 需要一个超过 V8 约 512 MiB 字符串上限的单个 JSON 字符串。限额以 `imageLimits` 会话投影到达客户端。它是每次启动恒定的单元(`apply` 返回同一状态引用,因此只靠基线携带、不存在变更帧),由 **apiproxy** 而非 attachment Service Definition 注册:`dsh-llm` 依赖 `dsh-attachment`(`ImageBlock` → `ImageAttachmentRef`),seam 包引用 `dsh-session-projection`(其图谱经 `dsh-session` 到达 `dsh-llm`)会闭合 project-reference 环,而该值描述的每消息数量与总量规则本来就是 proxy 自己的准入检查。`SessionProjectionMap` 合并放在 proxy 的 sessions 协议文件里,每个客户端程序都经载体的类型再导出包含它。 + +**加入预检与错误文案。** 两种加入手势汇合到 InputBar 的一个 `intakeImages` 包装:在 `addImages` 之前按投影检查数量、单图字节与总字节,违规的一批整体拒收(DeepSeek Chat 语义)并立刻弹出点名上限的横幅——不再有提交时的回滚戏码。宿主检查保留,兜底绕过 composer 的调用方。横幅文案遵循用户定下的一条原则:用户能解决的原因(模型不支持视觉、数量、大小、分辨率、格式——格式改为正面列出支持列表而不是回显被拒的 MIME 类型)用点明出路的产品句子;用户无法解决的原因(base64 损坏、引用丢失、读取失败)折叠为一条保留原因码的发送失败句子,因为产品当前面向开发者,可上报的码好过死胡同。非附件错误码保留原文加错误码的展示。 + +## 备选方案 + +**在 attachment Service Definition 构造函数里注册投影单元。** 天然的 seam 归属,也是第一版实现——被依赖图(上述环)和一个测试基建交互否决:基类构造函数调用 `ctx.inject` 使得 spec 中直接构造的 store 触发全局 invariant 宿主,后者往同一 root 重复挂载 `attachments` 服务。 + +**灯箱用 `--dsw-alias-bg-mask-photo`(0.88 黑、主题恒定、无人使用)。** 设计系统的照片查看器 token,也可能是 dsweb 灯箱实际的蒙层;用户选择与 settings 对话框遮罩一致(`bg-mask-1` 加模糊)——两者都能修复 dark 反转。 + +**在 `apply.ts` 的 `addImages` inject 里预检。** seam 纯度上的位置,因管线成本否决:投影仓没有暴露给 inject 工厂的非 React 面,而 InputBar 已经以惯用方式消费投影,且是两种手势的唯一调用方。 + +**用 `host.describe` 字段代替投影。** 与会话无关且更便宜,但要经注入 prop 链而非 `useProjection` 送达,而投影的键缺席语义("未组合 attachment 服务 → 不预检")是白拿的。 + +## 后果 + +拖到窗口任何位置都能进附件栏,超限加入在手势发生的那一刻就以点名上限的文案失败,历史图片像 DeepSeek Chat 一样平铺。载体的默认请求体预算扩大约 5 倍,并且仍是单请求驻留内存上界(桥把请求体整体缓冲;已记录在 connection README 的限制节)。fixture 传输用硬编码的默认数字镜像该投影——改配置的部署会与 fixture 模式的文案分叉,对 keyless 演示通道可接受。画廊左右切换、灯箱缩放与下载、非图片文件卡片仍然推迟(#2248)。 diff --git a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.i18n.yaml b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.i18n.yaml new file mode 100644 index 0000000000..d31b17817e --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.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/process/2026-08-11-python-publication-workflow.md +2026-08-11-python-publication-workflow.md: 870db08e1d59ad7840fa9acf822915f83ecbd31b +2026-08-11-python-publication-workflow.zh.md: 0b2b4a71b909a510bc5a7f52132dbb0ba2bf3e67 diff --git a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md new file mode 100644 index 0000000000..870db08e1d --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md @@ -0,0 +1,51 @@ +# Agent Note: Python public publication workflow + +Status: implemented + +English | [中文](2026-08-11-python-publication-workflow.zh.md) + +## Problem + +The Python SDK comprises one platform-independent client wheel and three native runtime wheels that must carry one version and become installable as a set. Public PyPI uploads expose package metadata and files immediately, cannot replace an uploaded filename, and create a temporarily unusable SDK if its exact runtime dependency has not arrived. The private repository needs to exercise the complete native build and validation sequence without publishing any artifact externally. + +## Decision + +The `Release (Python)` GitHub workflow exposes credential-free validation to pull requests labeled `python-release-dry-run` and to manual runs with `publish=false`. Both paths call the native wheel builder for all three platforms, install the Linux release set on Python 3.10 and 3.14, download the four resulting artifacts, verify their exact filenames and package metadata, enforce PyPI's default per-file size limit, record SHA-256 hashes, and retain one aggregate release candidate. These jobs have only repository read permission and no registry credential or OIDC permission, and pull request events cannot enter either publication job. + +A run with `publish=true` must use the `python-v` tag in the private automation repository, match that repository's `github.repository` to its repository-scoped `PYPI_PUBLISHER_REPOSITORY` variable, find `PUBLIC_PYPI_RELEASE_ENABLED=true`, and receive approval from the `pypi-runtime` and `pypi` GitHub environments for runtime and SDK publication, respectively. The read-only public mirror supplies the package metadata URLs but does not run release Actions. Only the two publication jobs receive `id-token: write`; PyPI Trusted Publishing exchanges the private repository identity for short-lived project credentials, so the repository stores no PyPI token. + +Publication consumes the aggregate artifact produced and checked in the same workflow run. Each publication job verifies the retained `SHA256SUMS` before selecting its upload set. A runtime job uploads all three platform wheels before a dependent job uploads the SDK wheel because PyPI uploads are not atomic and the SDK pins the runtime distribution at the exact same version. Neither job checks out source or rebuilds a wheel. Separating them lets GitHub's failed-job retry resume an SDK failure without attempting to replace immutable runtime files. + +Both publication actions disable public attestations. The action still uses Trusted Publishing for authentication, while omitting provenance that would disclose the private publisher repository instead of the public source mirror. + +Repository versions may be stable or use the supported prerelease spellings. Tags retain the repository spelling, while wheel filenames, metadata, dependency pins, and artifact lookup use the normalized PEP 440 spelling. + +The runtime package's `platforms.json` is the source of truth for native wheel tags and executable names. The repository release builder and the isolated Hatch build hook validate and load that file independently. GitHub Actions and GitLab CI call one repository-owned macOS deployment-target check for both the runtime executable and its required spawn helper, so every Mach-O file in the wheel must fit the declared platform tag. + +Both Python build-system requirements pin Hatchling 1.30.1. The next available Hatchling release emits Core Metadata 2.5, which the pinned Twine 6.2.0 validator rejects; keeping the builder exact makes local, GitHub, and GitLab output agree until the validation toolchain supports that metadata version. + +## Alternatives considered + +**TestPyPI rehearsal.** TestPyPI is a public index, so uploading there would expose package names, metadata, and wheel contents before the repository opens. The credential-free aggregate artifact and the existing private GitLab package registry cover validation and upload-protocol rehearsal without that disclosure. + +**A long-lived PyPI API token.** A stored token gives unrelated workflow steps a reusable secret and needs manual rotation. Trusted Publishing limits the credential to the registered repository, workflow, and environment and mints it only for each protected publication job. + +**Building again inside the publication job.** A second build can differ from the candidate that passed native smoke tests. Publication downloads the same retained bytes and checks no source out. + +**Uploading the SDK before its runtime carriers.** The SDK would become visible while its exact dependency remained unavailable if a later upload failed. Runtime-first ordering leaves partial failures without an installable client that points at missing files. + +**Publishing from the public mirror.** The public mirror is a read-only source projection and does not run release Actions. Binding the PyPI publisher to it would leave no workload capable of presenting the registered OIDC identity. + +**Publishing public attestations.** The default action behavior makes the Trusted Publisher repository identity publicly verifiable. That provenance identifies the private automation repository rather than the package's public source mirror, so the publication jobs disable it. + +## Consequences + +The complete release candidate and the public release both run from the private automation repository. Selecting `publish=true` fails before the protected publication jobs unless the publisher-repository variable, release switch, and tag identify an intentional public release. Mirroring code does not copy those private repository settings, so the read-only public mirror cannot satisfy the authorization checks. + +The private automation repository owner and name, workflow filename, and each job's environment (`pypi-runtime` for runtime and `pypi` for SDK) are part of the Trusted Publisher identity. A source-repository transfer, workflow rename, or environment rename requires updating the affected PyPI publishers and the publisher-repository variable when the repository identity changes. Changing the read-only public mirror changes package metadata URLs instead, not the publishing identity. + +PyPI publication remains non-atomic across the two distribution projects. Runtime-first ordering narrows the visible failure mode, while separate publication jobs and checksum verification let a failed SDK upload resume with the exact checked bytes; an uploaded filename is never replaced. + +Disabling public attestations gives up public cryptographic provenance for the upload identity. Trusted Publishing still authenticates each upload, and the retained aggregate artifact keeps the checked wheel hashes inside the private release workflow. + +Upgrading Hatchling now requires validating the emitted Core Metadata version with the release pipeline's pinned Twine version before changing both package build requirements together. diff --git a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.zh.md b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.zh.md new file mode 100644 index 0000000000..0b2b4a71b9 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.zh.md @@ -0,0 +1,51 @@ +# Agent Note: Python 公开发布工作流 + +Status: implemented + +[English](2026-08-11-python-publication-workflow.md) | 中文 + +## 问题 + +Python SDK 由一个平台无关的客户端 wheel 包和三个原生运行时 wheel 包组成,它们必须使用同一版本,并作为一组可安装。public PyPI 上传会立即公开包元数据和文件,无法替换已上传的同名文件;如果精确版本的运行时依赖尚未到达,还会产生暂时不可用的 SDK。私有仓库需要在不向外发布任何产物的情况下,执行完整的原生构建与验证流程。 + +## 决策 + +GitHub 的 `Release (Python)` 工作流为带有 `python-release-dry-run` 标签的拉取请求和设置 `publish=false` 的手动运行提供无凭据验证。两条路径都会为全部三个平台调用原生 wheel 包构建器,在 Python 3.10 和 3.14 上安装 Linux 发行集合,下载所得四份产物,验证其精确文件名和包元数据,执行 PyPI 默认单文件大小限制,记录 SHA-256 哈希,并保留一份汇总候选发行版。这些作业只有仓库读取权限,没有注册表凭据或 OIDC 权限,拉取请求事件无法进入任何发布作业。 + +设置 `publish=true` 时,运行必须在私有自动化仓库使用 `python-v` 标签,将该仓库的 `github.repository` 与其仓库级 `PYPI_PUBLISHER_REPOSITORY` 变量匹配,找到 `PUBLIC_PYPI_RELEASE_ENABLED=true`,并分别获得 GitHub `pypi-runtime` 和 `pypi` 环境对运行时与 SDK 发布的批准。只读公开镜像提供包元数据 URL,但不运行发布 Actions。只有两个发布作业获得 `id-token: write`;PyPI Trusted Publishing 会把私有仓库身份换成短期项目凭据,因此仓库不保存 PyPI token。 + +发布过程使用同一次工作流运行中生成并检查过的汇总产物。每个发布作业都会在选择上传文件前验证保留的 `SHA256SUMS`。一个运行时作业先上传全部三个平台 wheel 包,再由依赖它的作业上传 SDK wheel 包,因为 PyPI 上传不是原子操作,而 SDK 会把运行时分发包固定到完全相同的版本。两个作业都不会检出源码,也不会重新构建 wheel 包。将它们拆开后,GitHub 的失败作业重试可以在 SDK 上传失败时继续执行,而不会尝试替换不可变的运行时文件。 + +两个发布 action 都会禁用公开 attestation。action 仍使用 Trusted Publishing 进行身份认证,同时不上传会披露私有发布仓库而非公开源码镜像的 provenance。 + +仓库版本可以是稳定版,也可以使用受支持的预发布写法。标签保留仓库写法,wheel 包文件名、元数据、依赖版本固定和产物查找则使用规范化的 PEP 440 写法。 + +运行时包的 `platforms.json` 是原生 wheel 包标签和可执行文件名的事实来源。仓库发行构建器与隔离 Hatch 构建钩子会分别校验并加载该文件。GitHub Actions 与 GitLab CI 对运行时可执行文件及其必需的 spawn helper 调用同一个仓库自有的 macOS 部署目标检查,因此 wheel 包中的每个 Mach-O 文件都必须符合声明的平台标签。 + +两个 Python 构建系统依赖都固定使用 Hatchling 1.30.1。下一个可用的 Hatchling 版本会生成 Core Metadata 2.5,而固定使用的 Twine 6.2.0 校验器会拒绝该版本;精确固定构建器后,本地、GitHub 与 GitLab 的输出会保持一致,直到校验工具链支持该元数据版本。 + +## 考虑过的替代方案 + +**使用 TestPyPI 演练。** TestPyPI 是公开索引,上传会在仓库开放前暴露包名、元数据和 wheel 包内容。无凭据的汇总产物与既有私有 GitLab 包注册表可以覆盖验证和上传协议演练,而不会造成这种披露。 + +**使用长期 PyPI API token。** 保存的 token 会让无关工作流步骤接触可复用的密钥,并需要人工轮换。Trusted Publishing 把凭据限制到已登记的仓库、工作流和环境,并且只为每个受保护的发布作业生成凭据。 + +**在发布作业中重新构建。** 第二次构建可能与通过原生冒烟测试的候选产物不同。发布过程下载并使用同一批已保留文件,且不检出任何源码。 + +**先上传 SDK,再上传运行时载体。** 如果后续上传失败,SDK 会先公开,而其精确依赖仍不可用。运行时优先的顺序使部分失败不会产生指向缺失文件的可安装客户端。 + +**从公开镜像发布。** 公开镜像是只读源码投影,不运行发布 Actions。将 PyPI Publisher 绑定到该镜像后,没有工作负载能够提供已登记的 OIDC 身份。 + +**发布公开 attestation。** action 默认行为会让 Trusted Publisher 仓库身份可公开验证。该 provenance 标识私有自动化仓库而非包的公开源码镜像,因此发布作业将其禁用。 + +## 后果 + +完整候选发行版与公开发布都从私有自动化仓库运行。选择 `publish=true` 后,只有发布仓库变量、发布开关和标签都能标识一次有意的公开发布,工作流才会进入受保护的发布作业,否则会提前失败。镜像代码不会复制这些私有仓库设置,因此只读公开镜像无法满足授权检查。 + +私有自动化仓库 owner 和仓库名、工作流文件名以及每个作业的环境(运行时使用 `pypi-runtime`,SDK 使用 `pypi`)都是 Trusted Publisher 身份的一部分。源码仓库转移、工作流改名或环境改名后,必须更新受影响的 PyPI Publisher;仓库身份变化时还必须更新发布仓库变量。只读公开镜像发生变化时,需要修改的是包元数据 URL,而不是发布身份。 + +两个分发项目之间的 PyPI 发布仍然不是原子操作。运行时优先的顺序会缩小可见的失败状态;独立的发布作业和校验和验证则让失败的 SDK 上传能够从经过检查的精确文件继续执行,并且绝不替换已上传的同名文件。 + +禁用公开 attestation 会放弃上传身份的公开密码学 provenance。Trusted Publishing 仍会认证每次上传,而保留的汇总产物会在私有发布工作流内部保存经过检查的 wheel 包哈希。 + +升级 Hatchling 时,必须先使用发布流水线固定的 Twine 版本验证其生成的 Core Metadata 版本,再同时修改两个包的构建依赖。 diff --git a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.i18n.yaml index 9920c09c11..a1263c1db4 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.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/simplification/2026-08-10-source-run-without-managed-installer.md -2026-08-10-source-run-without-managed-installer.md: ac506b72acab0dd6c92ce6111487d091b2bf4a73 -2026-08-10-source-run-without-managed-installer.zh.md: 86e44a90a9e7b34ad37b6a4bfd3ad14de40868f5 +2026-08-10-source-run-without-managed-installer.md: ce618c88327fc11ad0cad9042e171800a5492663 +2026-08-10-source-run-without-managed-installer.zh.md: e54ec93b8bd13a36b7e8b6813899d5a1e27b788c diff --git a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md index ac506b72ac..ce618c8832 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md +++ b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md @@ -12,7 +12,7 @@ That lifecycle is not required to run or develop DeepSeek Harness from a source ## Decision -The repository supports source execution through its root `pnpm` scripts. The `dsh` entry in `package.json` runs `pnpm run build`, then launches `apps/cli/src/bin.ts` through `node --import tsx/esm`; build output remains visible before the CLI output. The package script forwards arguments and inherits the caller's environment, including `NODE_USE_ENV_PROXY=1` when a supporting Node version must honor `HTTP_PROXY` and `HTTPS_PROXY`. Users select Web with `pnpm dsh web` and headless execution with `pnpm dsh --profile headless "task"`. The independent ACP example remains available through `pnpm run demo:acp`. +The repository supports source execution through its root `pnpm` scripts. The `dsh` entry in `package.json` launches `apps/cli/src/bin.ts` directly through `node --import tsx/esm`; artifact generation is the separate `pnpm run build` operation defined by the [source-launch/build separation decision](2026-08-12-separate-source-launch-from-build.md). The package script forwards arguments and inherits the caller's environment, including `NODE_USE_ENV_PROXY=1` when a supporting Node version must honor `HTTP_PROXY` and `HTTPS_PROXY`. Users select Web with `pnpm dsh web` and headless execution with `pnpm dsh --profile headless "task"`. The independent ACP example remains available through `pnpm run demo:acp`. The repository does not distribute a source installer, an installer test suite, or skills that assume a managed `current` symlink and timestamped staging worktrees. Users own source checkout placement, Git updates, and any launcher they create outside the repository. @@ -28,4 +28,4 @@ The repository does not distribute a source installer, an installer test suite, Source users invoke repository scripts rather than an installed `dsh` command. The repository provides no atomic upgrade cutover or preserved staging rollback checkout, and it does not automate the integration or upstream publication of personal source modifications. A future distribution mechanism must justify its ownership of installation and upgrade state, define recovery behavior, and add tests and user documentation without making the source-run path depend on it. Any future publication workflow must isolate one approved feature and obtain explicit approval before its first push and draft PR. -Verification covers repository-wide references to the removed entry points, documentation links, generated third-party-notice freshness, the build-first `package.json` command, and a source CLI smoke through the exact `node --import tsx/esm` runtime vector. +Verification covers repository-wide references to the removed entry points, documentation links, generated third-party-notice freshness, the direct `package.json` source command, and a source CLI smoke through the exact `node --import tsx/esm` runtime vector. diff --git a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md index 86e44a90a9..e54ec93b8b 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -仓库通过根目录的 `pnpm` 脚本支持从源码运行。`package.json` 中的 `dsh` 项先执行 `pnpm run build`,再通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`;构建输出会显示在 CLI(命令行界面)输出之前。该包脚本会转发参数并继承调用方环境;当支持环境代理的 Node 版本必须遵循 `HTTP_PROXY` 和 `HTTPS_PROXY` 时,调用方可设置 `NODE_USE_ENV_PROXY=1`。用户使用 `pnpm dsh web` 选择 Web,使用 `pnpm dsh --profile headless "task"` 选择无头执行。独立的 ACP(Agent Client Protocol)示例仍可通过 `pnpm run demo:acp` 运行。 +仓库通过根目录的 `pnpm` 脚本支持从源码运行。`package.json` 中的 `dsh` 项通过 `node --import tsx/esm` 直接启动 `apps/cli/src/bin.ts`;产物生成是独立的 `pnpm run build` 操作,由[源码启动与构建分离决策](2026-08-12-separate-source-launch-from-build.md)规定。该包脚本会转发参数并继承调用方环境;当支持环境代理的 Node 版本必须遵循 `HTTP_PROXY` 和 `HTTPS_PROXY` 时,调用方可设置 `NODE_USE_ENV_PROXY=1`。用户使用 `pnpm dsh web` 选择 Web,使用 `pnpm dsh --profile headless "task"` 选择无头执行。独立的 ACP(Agent Client Protocol)示例仍可通过 `pnpm run demo:acp` 运行。 仓库不分发源码安装器、安装器测试套件,也不分发依赖受管理的 `current` 符号链接和带时间戳 staging worktree 的 skill。源码检出的存放位置、Git 更新,以及用户在仓库外创建的任何启动器均由用户负责。 @@ -28,4 +28,4 @@ Status: implemented 源码用户通过仓库脚本运行程序,而非使用已安装的 `dsh` 命令。仓库不提供原子升级切换,也不保留 staging 回滚检出;仓库同样不会自动集成个人源码修改或将其发布到上游。未来的分发机制必须说明为何应由其管理安装和升级状态,定义恢复行为,并补充测试与用户文档,同时不得让源码运行路径依赖该机制。未来任何发布工作流都必须隔离出一项获批功能,并在首次推送和创建草稿 PR(Pull Request)前取得明确批准。 -验证范围包括仓库内对已移除入口点的所有引用、文档链接、生成的第三方声明文件的新鲜度、`package.json` 中的先构建后启动命令,以及通过准确的 `node --import tsx/esm` 运行方式对源码 CLI 进行的冒烟测试。 +验证范围包括仓库内对已移除入口点的所有引用、文档链接、生成的第三方声明文件的新鲜度、`package.json` 中的直接源码启动命令,以及通过准确的 `node --import tsx/esm` 运行方式对源码 CLI 进行的冒烟测试。 diff --git a/.agents/notes/implemented/simplification/2026-08-12-separate-source-launch-from-build.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-12-separate-source-launch-from-build.i18n.yaml new file mode 100644 index 0000000000..6920712a45 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-12-separate-source-launch-from-build.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/simplification/2026-08-12-separate-source-launch-from-build.md +2026-08-12-separate-source-launch-from-build.md: d3f2d21c9bf74c699c1468f99074d88ab2138cb4 +2026-08-12-separate-source-launch-from-build.zh.md: 2cc2decd0a46bd1c30bd7113a665740dcf359947 diff --git a/.agents/notes/implemented/simplification/2026-08-12-separate-source-launch-from-build.md b/.agents/notes/implemented/simplification/2026-08-12-separate-source-launch-from-build.md new file mode 100644 index 0000000000..d3f2d21c9b --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-12-separate-source-launch-from-build.md @@ -0,0 +1,38 @@ +# Agent Note: Separate source launch from repository build + +Status: implemented + +English | [中文](2026-08-12-separate-source-launch-from-build.zh.md) + +## Problem + +The TypeScript source launcher does not need a complete repository build for every invocation. The Web surface does need built frontend and client-plugin artifacts. Making one package script own both operations adds repository-wide build latency to repeated TUI, headless, and Web startup and obscures when browser artifacts are refreshed. + +Source modules reached through tsx and browser modules reached through built bundles have different freshness behavior. Separating their commands requires explicit ownership of artifact production and an accurate failure model for missing and stale output. + +## Decision + +The root `dsh` script only runs `node --import tsx/esm apps/cli/src/bin.ts`. `pnpm run build` remains the separate operation that generates package and frontend artifacts. Source users run the build before the first production-like launch and whenever frontend or client-plugin artifacts need refreshing. + +Missing TypeRT host artifacts fail profile boot through module-resolution errors without a build instruction. Once those host artifacts exist, missing frontend and client-plugin artifacts fail at startup with diagnostics that direct the user to `pnpm run build`. The launcher does not validate artifact freshness: existing stale frontend or client-plugin bundles are accepted and can run older browser code until the next build. After package Node halves have been built once, `pnpm run dev:web` rebuilds only packages that declare `dsh.client`; it keeps client-plugin bundles current and activates their hot-reload path, but does not rebuild the frontend shell. + +This decision owns build scheduling only. The [tsx ESM source-launch decision](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md) owns TypeScript transformation and workspace resolution, the [source-run decision](2026-08-10-source-run-without-managed-installer.md) owns repository scripts as the supported checkout entry points, and the [personal-config decision](../feature/2026-07-20-dsh-cli-personal-config.md) owns the machine-level configuration layer. + +## Alternatives considered + +**Build before every source launch.** This provides the strongest default freshness guarantee, but charges every invocation for repository-wide artifact generation even when the relevant outputs are already current. + +**Build only when an artifact is missing.** This avoids some startup work but leaves stale output undetected while making build behavior implicit and dependent on the current filesystem contents. + +**Start the Web artifact watcher from `pnpm dsh`.** This keeps client-plugin bundles current but changes a one-shot launcher into an owner of another long-lived process. The explicit `pnpm run dev:web` command already owns that development lifecycle. + +## Consequences + +- Repeated source launches do not wait for a complete repository build, and build output is not mixed with CLI output. +- Source users own artifact freshness. Missing artifacts stop startup, but only frontend and client-plugin failures direct users to `pnpm run build`; existing stale frontend and client-plugin bundles can silently serve older browser code. +- TUI, Web, and headless selection, argument forwarding, environment inheritance, and the tsx ESM launch vector remain unchanged. +- The root onboarding and CLI reference show build and launch as separate commands and document the stale-artifact behavior. + +## Verification + +`apps/cli/tests/source-launch.compat.spec.ts` pins the exact root package command and exercises the production source-launch vector. `packages/bundle/web-app/tests/web-app.spec.ts` and `packages/client/modules/tests/node-half.client.spec.ts` pin the missing-artifact diagnostics. diff --git a/.agents/notes/implemented/simplification/2026-08-12-separate-source-launch-from-build.zh.md b/.agents/notes/implemented/simplification/2026-08-12-separate-source-launch-from-build.zh.md new file mode 100644 index 0000000000..2cc2decd0a --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-12-separate-source-launch-from-build.zh.md @@ -0,0 +1,38 @@ +# Agent Note: 将源码启动与仓库构建分离 + +Status: implemented + +[English](2026-08-12-separate-source-launch-from-build.md) | 中文 + +## 问题 + +TypeScript 源码启动器无需在每次调用前完成整个仓库的构建。Web 界面则需要已构建的前端与 Client plugin 产物。由同一个包脚本同时负责这两项操作,会让重复启动 TUI、无头模式和 Web 时都承担全仓库构建延迟,也会掩盖浏览器产物何时刷新。 + +经由 tsx 加载的源码模块与经由已构建组合包加载的浏览器模块具有不同的新鲜度表现。将两条命令分离后,需要明确产物生成的责任,并准确说明产物缺失与过期时的失败模式。 + +## 决策 + +根目录的 `dsh` 脚本只运行 `node --import tsx/esm apps/cli/src/bin.ts`。`pnpm run build` 仍是生成包与前端产物的独立操作。源码用户在首次进行类生产启动前运行构建,并在前端或 Client plugin 产物需要刷新时再次运行。 + +TypeRT Host 产物缺失时,profile 启动会因不含构建指引的模块解析错误而失败。这些 Host 产物存在后,如果前端或 Client plugin 产物缺失,启动会失败,诊断信息会指示用户运行 `pnpm run build`。启动器不会验证产物是否为最新:已有的陈旧前端或 Client plugin 组合包仍会被接受,并可能继续运行旧版浏览器代码,直至下次构建。各包的 Node 半侧至少构建过一次后,`pnpm run dev:web` 只重建声明了 `dsh.client` 的包;它会保持 Client plugin 组合包为最新状态并启用其热重载路径,但不会重建前端 shell。 + +本决策仅规定构建调度。[tsx ESM 源码启动决策](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md)规定 TypeScript 转换与 workspace 解析,[源码运行决策](2026-08-10-source-run-without-managed-installer.md)规定以仓库脚本作为受支持的检出入口,[个人配置决策](../feature/2026-07-20-dsh-cli-personal-config.md)规定机器级配置层。 + +## 考虑过的备选方案 + +**每次源码启动前都执行构建。**这样可提供最强的默认新鲜度保证,但即使相关产物已经是最新状态,每次调用仍要承担全仓库产物生成的开销。 + +**仅在产物缺失时执行构建。**这样可避免部分启动开销,但无法发现过期产物,还会让构建行为变成由当前文件系统内容决定的隐式策略。 + +**由 `pnpm dsh` 启动 Web 产物 watcher。**这样可保持 Client plugin 组合包为最新状态,却会让一次性启动器负责另一个长时间运行的进程。显式的 `pnpm run dev:web` 命令已经负责这套开发生命周期。 + +## 影响 + +- 重复的源码启动无需等待完整的仓库构建,构建输出也不会与 CLI 输出混在一起。 +- 源码用户负责产物新鲜度。产物缺失会阻止启动,但只有前端与 Client plugin 产物缺失的错误会指示用户运行 `pnpm run build`;已有的过期前端与 Client plugin 组合包可能静默提供旧版浏览器代码。 +- TUI、Web 与无头模式选择、参数转发、环境继承,以及 tsx ESM 启动方式保持不变。 +- 根目录上手指南与 CLI 参考将构建和启动列为独立命令,并说明过期产物行为。 + +## 验证 + +`apps/cli/tests/source-launch.compat.spec.ts` 固定根目录包命令的准确内容,并执行生产源码启动方式。`packages/bundle/web-app/tests/web-app.spec.ts` 与 `packages/client/modules/tests/node-half.client.spec.ts` 固定产物缺失诊断。 diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 0924da5b35..fea0e27ec6 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -4,10 +4,22 @@ name: Build single-exe # .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md. # A full target run retains one SDK wheel and three runtime wheels; subset # dispatch retains the SDK wheel and selected runtime wheels. Bare executables -# and source closures are test inputs. Run manually or label a PR -# `build-exe` (remove and reapply to rerun). Checkout uses the triggering ref, -# so dispatch needs no separate ref input. +# and source closures are test inputs. Run manually, label a PR `build-exe` +# (remove and reapply to rerun), or call it from the Python release workflow. +# Checkout uses the triggering ref, so dispatch needs no separate ref input. on: + workflow_call: + inputs: + targets: + description: Comma-separated pkg targets to build; empty builds all three. + type: string + required: false + default: '' + release: + description: Run as the native builder for the Python release workflow. + type: boolean + required: false + default: false workflow_dispatch: inputs: targets: @@ -22,7 +34,9 @@ on: types: [labeled] concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + # Keep the called workflow distinct from its caller's concurrency group; + # github.workflow identifies the caller inside a reusable workflow. + group: build-single-exe-${{ github.ref }} cancel-in-progress: true permissions: @@ -38,12 +52,13 @@ jobs: # construct the matrix before the dependent jobs. plan: name: plan targets - if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'build-exe' + if: inputs.release || github.event_name == 'workflow_dispatch' || github.event.label.name == 'build-exe' runs-on: ubuntu-latest timeout-minutes: 5 outputs: matrix: ${{ steps.plan.outputs.matrix }} version: ${{ steps.version.outputs.version }} + repository-version: ${{ steps.version.outputs.repository-version }} steps: - uses: actions/checkout@v6 @@ -51,12 +66,15 @@ jobs: id: version run: | set -euo pipefail - version="$(jq -r '.version // empty' package.json)" - [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { - echo "::error::package.json version must be stable X.Y.Z, got '$version'" - exit 1 - } - echo "version=$version" >> "$GITHUB_OUTPUT" + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import runpy + + release = runpy.run_path("scripts/build-python-release.py") + repository_version = release["repository_version"]() + wheel_version = release["pep440_version"](repository_version) + print(f"repository-version={repository_version}") + print(f"version={wheel_version}") + PY - name: Compute matrix from targets input id: plan @@ -116,6 +134,7 @@ jobs: name: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl path: dist-python/deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl if-no-files-found: error + retention-days: 7 build: needs: [plan, sdk-wheel] @@ -157,6 +176,41 @@ jobs: - name: Install (immutable) run: pnpm install --frozen-lockfile + - name: Rebuild Linux node-pty against manylinux 2.28 + if: runner.os == 'Linux' + env: + RUNNER_ARCH: ${{ runner.arch }} + run: | + set -euo pipefail + case "$RUNNER_ARCH" in + X64) image=quay.io/pypa/manylinux_2_28_x86_64 ;; + ARM64) image=quay.io/pypa/manylinux_2_28_aarch64 ;; + *) echo "::error::Unsupported Linux runner architecture $RUNNER_ARCH"; exit 1 ;; + esac + addon_dir="$(realpath packages/subprocess/subprocess-local/node_modules/node-pty)" + addon="$addon_dir/build/Release/pty.node" + [ -f "$addon_dir/build/Makefile" ] || { + echo "::error::node-pty install did not generate $addon_dir/build/Makefile" + exit 1 + } + docker run --rm \ + --user "$(id -u):$(id -g)" \ + -v "$PWD:$PWD" \ + -v "$HOME/.cache/node-gyp:$HOME/.cache/node-gyp:ro" \ + -v "$HOME/setup-pnpm:$HOME/setup-pnpm:ro" \ + -w "$addon_dir" \ + "$image" \ + bash -euxo pipefail -c \ + 'rm -rf build/Release && make -C build -j2 BUILDTYPE=Release' + [ -f "$addon" ] || { echo "::error::$addon missing after manylinux rebuild"; exit 1; } + readelf --version-info "$addon" | tee node-pty-glibc-versions.txt + maximum="$(sed -n 's/.*Name: GLIBC_\([0-9.]*\).*/\1/p' node-pty-glibc-versions.txt | sort -V | tail -1)" + [ -n "$maximum" ] || { echo "::error::No GLIBC requirements found in $addon"; exit 1; } + dpkg --compare-versions "$maximum" le 2.28 || { + echo "::error::node-pty addon requires GLIBC_$maximum but wheel claims manylinux_2_28" + exit 1 + } + - name: Build single-exe run: pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }} @@ -173,7 +227,7 @@ jobs: case "$platform" in linux-x64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_x86_64.whl ;; linux-arm64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_aarch64.whl ;; - macos-arm64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-macosx_11_0_arm64.whl ;; + macos-arm64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-macosx_14_0_arm64.whl ;; *) echo "::error::Unsupported runtime platform $platform"; exit 1 ;; esac echo "platform=$platform" >> "$GITHUB_OUTPUT" @@ -224,6 +278,14 @@ jobs: exit 1 } + - name: Check macOS deployment target + if: runner.os == 'macOS' + env: + EXE: ${{ steps.runtime.outputs.exe }} + run: >- + python3 scripts/check-macos-deployment-target.py + "$EXE" "$EXE-spawn-helper" + - name: Run wheel in a manylinux 2.28 container if: runner.os == 'Linux' env: @@ -236,7 +298,7 @@ jobs: ARM64) image=quay.io/pypa/manylinux_2_28_aarch64 ;; *) echo "::error::Unsupported Linux runner architecture $RUNNER_ARCH"; exit 1 ;; esac - docker run --rm -e VERSION -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c ' + docker run --rm -e VERSION -e DSH_TELEMETRY_DISABLED -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c ' /opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk /tmp/dsh-sdk/bin/python -m pip install --find-links /work/dist-python deepseek-harness-sdk=="$VERSION" /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default @@ -247,3 +309,4 @@ jobs: name: ${{ steps.runtime.outputs.wheel }} path: dist-python/${{ steps.runtime.outputs.wheel }} if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml new file mode 100644 index 0000000000..4a2f05b10b --- /dev/null +++ b/.github/workflows/python-release.yml @@ -0,0 +1,244 @@ +name: Release (Python) + +# A PR labeled python-release-dry-run or a manual run with publish=false builds +# and validates the complete release without registry credentials. Publication +# is accepted only from a manual run on the matching python-v* tag when the +# private publisher-repository identity and public-PyPI switch are configured. +on: + workflow_dispatch: + inputs: + publish: + description: Publish the validated wheels to public PyPI. Must run from a python-v* tag. + required: true + type: boolean + default: false + pull_request: + types: [labeled] + +permissions: + contents: read + +concurrency: + # Public runs stay globally serialized across tags. Dry runs remain isolated + # by ref so they do not block an intentional publication. + group: ${{ github.event_name == 'workflow_dispatch' && inputs.publish && 'python-publication' || format('{0}-{1}', github.workflow, github.ref) }} + cancel-in-progress: false + +jobs: + build: + name: Build four wheels + if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'python-release-dry-run' + uses: ./.github/workflows/build-exe-for-python-sdk.yml + with: + targets: node24-linux-x64,node24-linux-arm64,node24-macos-arm64 + release: true + + python-compat: + name: Python ${{ matrix.python }} / installed SDK + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + python: ['3.10', '3.14'] + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: actions/setup-python@v6.3.0 + with: + python-version: ${{ matrix.python }} + + - uses: actions/download-artifact@v8 + with: + pattern: deepseek_harness_* + path: dist + merge-multiple: true + + - name: Resolve installed wheel version + id: compatibility-version + run: | + python - <<'PY' >> "$GITHUB_OUTPUT" + import runpy + + release = runpy.run_path("scripts/build-python-release.py") + repository_version = release["repository_version"]() + print(f"version={release['pep440_version'](repository_version)}") + PY + + - name: Install and run the published entry path + run: | + python -m pip install --find-links dist "deepseek-harness-sdk==${{ steps.compatibility-version.outputs.version }}" + python scripts/smoke-python-runtime.py --scenario sdk-default + + validate: + name: Validate release candidate + needs: [build, python-compat] + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: actions/setup-python@v6.3.0 + with: + python-version: '3.10' + + - name: Resolve release version + id: version + run: | + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import runpy + + release = runpy.run_path("scripts/build-python-release.py") + repository_version = release["repository_version"]() + wheel_version = release["pep440_version"](repository_version) + print(f"repository-version={repository_version}") + print(f"version={wheel_version}") + PY + + - name: Authorize publication request + env: + PUBLISH: ${{ github.event_name == 'workflow_dispatch' && inputs.publish }} + PUBLIC_PYPI_RELEASE_ENABLED: ${{ vars.PUBLIC_PYPI_RELEASE_ENABLED }} + PYPI_PUBLISHER_REPOSITORY: ${{ vars.PYPI_PUBLISHER_REPOSITORY }} + REPOSITORY: ${{ github.repository }} + REF_NAME: ${{ github.ref_name }} + REF_TYPE: ${{ github.ref_type }} + REPOSITORY_VERSION: ${{ steps.version.outputs.repository-version }} + run: | + set -euo pipefail + if [ "$PUBLISH" = true ]; then + [ -n "$PYPI_PUBLISHER_REPOSITORY" ] || { + echo "::error::Set the repository variable PYPI_PUBLISHER_REPOSITORY before publication." + exit 1 + } + [ "$REPOSITORY" = "$PYPI_PUBLISHER_REPOSITORY" ] || { + echo "::error::This repository is not the configured PyPI publisher repository." + exit 1 + } + [ "$PUBLIC_PYPI_RELEASE_ENABLED" = true ] || { + echo "::error::Set PUBLIC_PYPI_RELEASE_ENABLED=true before public publication." + exit 1 + } + [ "$REF_TYPE" = tag ] && [ "$REF_NAME" = "python-v$REPOSITORY_VERSION" ] || { + echo "::error::Publication must run from tag python-v$REPOSITORY_VERSION." + exit 1 + } + fi + + - uses: actions/download-artifact@v8 + with: + pattern: deepseek_harness_* + path: dist + merge-multiple: true + + - name: Check release contents + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + expected="$(mktemp)" + actual="$(mktemp)" + printf '%s\n' \ + "deepseek_harness_runtime_bin-$VERSION-py3-none-macosx_14_0_arm64.whl" \ + "deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_aarch64.whl" \ + "deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_x86_64.whl" \ + "deepseek_harness_sdk-$VERSION-py3-none-any.whl" > "$expected" + find dist -maxdepth 1 -type f -name '*.whl' -exec basename {} \; | sort > "$actual" + diff -u "$expected" "$actual" + while IFS= read -r wheel; do + size="$(stat -c '%s' "dist/$wheel")" + [ "$size" -lt 100000000 ] || { + echo "::error::$wheel is $size bytes; public PyPI accepts at most 100000000 bytes by default." + exit 1 + } + done < "$actual" + + - name: Validate package metadata + run: | + python -m pip install twine==6.2.0 + python -m twine check dist/*.whl + + - name: Record artifact hashes + run: | + cd dist + sha256sum *.whl | sort -k2 > SHA256SUMS + cat SHA256SUMS + + - uses: actions/upload-artifact@v7 + with: + name: python-release-${{ steps.version.outputs.version }} + path: dist/* + if-no-files-found: error + retention-days: 7 + + publish-runtime: + name: Publish runtime wheels to public PyPI + if: github.event_name == 'workflow_dispatch' && inputs.publish + needs: validate + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: pypi-runtime + permissions: + contents: read + id-token: write + steps: + - uses: actions/download-artifact@v8 + with: + name: python-release-${{ needs.validate.outputs.version }} + path: dist + + - name: Verify release artifact hashes + run: cd dist && sha256sum -c SHA256SUMS + + - name: Select runtime wheels + run: | + mkdir -p dist/runtime + mv dist/deepseek_harness_runtime_bin-*.whl dist/runtime/ + + - name: Publish runtime wheels + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/runtime/ + # Public attestations reveal the private publisher repository. OIDC + # authentication remains enabled without uploading that provenance. + attestations: false + + # Keep the SDK in a dependent job. If its upload fails after the immutable + # runtime files arrive, "re-run failed jobs" resumes here without attempting + # to overwrite the runtime release. + publish-sdk: + name: Publish SDK wheel to public PyPI + if: github.event_name == 'workflow_dispatch' && inputs.publish + needs: [validate, publish-runtime] + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: pypi + permissions: + contents: read + id-token: write + steps: + - uses: actions/download-artifact@v8 + with: + name: python-release-${{ needs.validate.outputs.version }} + path: dist + + - name: Verify release artifact hashes + run: cd dist && sha256sum -c SHA256SUMS + + - name: Select SDK wheel + run: | + mkdir -p dist/sdk + mv dist/deepseek_harness_sdk-*.whl dist/sdk/ + + - name: Publish SDK wheel + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/sdk/ + attestations: false diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index fd56278195..87c4e8dccb 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,6 +1,6 @@ workflow: rules: - - if: '$CI_COMMIT_TAG =~ /^python-v\d+\.\d+\.\d+$/' + - if: '$CI_COMMIT_TAG =~ /^python-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$/' - when: never stages: @@ -16,6 +16,7 @@ variables: - python3 -m venv .ci-python - . .ci-python/bin/activate - export DSH_VERSION="$(python -c 'import json; print(json.load(open("package.json"))["version"])')" + - export DSH_WHEEL_VERSION="$(python -c 'import runpy; release = runpy.run_path("scripts/build-python-release.py"); print(release["pep440_version"](release["repository_version"]()))')" - test "$CI_COMMIT_TAG" = "python-v$DSH_VERSION" || { echo "Tag $CI_COMMIT_TAG does not match package.json version $DSH_VERSION"; exit 1; } - python -m pip install uv==0.11.23 @@ -42,7 +43,7 @@ sdk-wheel: - uv run --python 3.10 --group test --project python/sdk python scripts/smoke-python-runtime.py --scenario all --exe "$EXE" - python scripts/build-python-release.py --package runtime --tag "$CI_COMMIT_TAG" --platform "$PLATFORM" --runtime-exe "$EXE" --output-dir "release/$PLATFORM" - python -m venv .wheel-smoke - - .wheel-smoke/bin/python -m pip install --find-links "release/$PLATFORM" --find-links release/sdk deepseek-harness-sdk=="$DSH_VERSION" + - .wheel-smoke/bin/python -m pip install --find-links "release/$PLATFORM" --find-links release/sdk deepseek-harness-sdk=="$DSH_WHEEL_VERSION" - .wheel-smoke/bin/python scripts/smoke-python-runtime.py --scenario sdk-default - | if [ "${PLATFORM#linux-}" != "$PLATFORM" ]; then @@ -55,7 +56,11 @@ sdk-wheel: linux-arm64) image=quay.io/pypa/manylinux_2_28_aarch64 ;; *) echo "Unsupported Linux platform $PLATFORM"; exit 1 ;; esac - docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c "/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk && /tmp/dsh-sdk/bin/python -m pip install --find-links /work/release/$PLATFORM --find-links /work/release/sdk deepseek-harness-sdk==$DSH_VERSION && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default" + docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c "/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk && /tmp/dsh-sdk/bin/python -m pip install --find-links /work/release/$PLATFORM --find-links /work/release/sdk deepseek-harness-sdk==$DSH_WHEEL_VERSION && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default" + fi + - | + if [ "$PLATFORM" = macos-arm64 ]; then + python3 scripts/check-macos-deployment-target.py "$EXE" "$EXE-spawn-helper" fi artifacts: paths: [release/$PLATFORM/*.whl] @@ -108,16 +113,17 @@ publish-python: - python3 -m venv .ci-python - . .ci-python/bin/activate - export DSH_VERSION="$(python -c 'import json; print(json.load(open("package.json"))["version"])')" + - export DSH_WHEEL_VERSION="$(python -c 'import runpy; release = runpy.run_path("scripts/build-python-release.py"); print(release["pep440_version"](release["repository_version"]()))')" - test "$CI_COMMIT_TAG" = "python-v$DSH_VERSION" || { echo "Tag $CI_COMMIT_TAG does not match package.json version $DSH_VERSION"; exit 1; } - python -m pip install twine==6.2.0 script: - test "$(find release -name '*.whl' | wc -l | tr -d ' ')" = 4 - - test -f "release/sdk/deepseek_harness_sdk-${DSH_VERSION}-py3-none-any.whl" - - test -f "release/linux-x64/deepseek_harness_runtime_bin-${DSH_VERSION}-py3-none-manylinux_2_28_x86_64.whl" - - test -f "release/linux-arm64/deepseek_harness_runtime_bin-${DSH_VERSION}-py3-none-manylinux_2_28_aarch64.whl" - - test -f "release/macos-arm64/deepseek_harness_runtime_bin-${DSH_VERSION}-py3-none-macosx_11_0_arm64.whl" + - test -f "release/sdk/deepseek_harness_sdk-${DSH_WHEEL_VERSION}-py3-none-any.whl" + - test -f "release/linux-x64/deepseek_harness_runtime_bin-${DSH_WHEEL_VERSION}-py3-none-manylinux_2_28_x86_64.whl" + - test -f "release/linux-arm64/deepseek_harness_runtime_bin-${DSH_WHEEL_VERSION}-py3-none-manylinux_2_28_aarch64.whl" + - test -f "release/macos-arm64/deepseek_harness_runtime_bin-${DSH_WHEEL_VERSION}-py3-none-macosx_14_0_arm64.whl" - python -m twine check release/*/*.whl - export TWINE_USERNAME=gitlab-ci-token - export TWINE_PASSWORD="$CI_JOB_TOKEN" - export TWINE_REPOSITORY_URL="$CI_API_V4_URL/projects/$CI_PROJECT_ID/packages/pypi" - - python -m twine upload --non-interactive release/*/*.whl || { echo 'Publish failed. GitLab does not overwrite an existing version; create a new python-vX.Y.Z tag.'; exit 1; } + - python -m twine upload --non-interactive release/*/*.whl || { echo 'Publish failed. GitLab does not overwrite an existing version; create a new python-v tag.'; exit 1; } diff --git a/AGENTS.md b/AGENTS.md index 902b4ab976..5f9e50aa14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,7 @@ pnpm run hygiene # knip + publint + workspace constraints + NodeNext cons pnpm run check:windows-wine # ONLY when diagnosing a known Windows failure (needs wine); CI owns this signal pnpm run doc-sync # all documentation gates; leaf list in scripts/run-gates.ts pnpm run website:build # VitePress build (doubles as dead-link check) -pnpm dsh --profile headless "task" # build, then run one task (needs DEEPSEEK_API_KEY) +pnpm dsh --profile headless "task" # run one task from source (needs DEEPSEEK_API_KEY) pnpm run demo:cordis # the agent modifies its own runtime (needs key) pnpm run demo:acp # ACP automation server (needs DEEPSEEK_API_KEY) ``` @@ -115,6 +115,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Source plane vs artifact plane, never mixed.** Static gates and tests resolve workspace imports through tsconfig `paths` to `src` and pass on a clean tree; gates consuming built `lib/` declare that dependency ([layout](docs/development.md#typescript-project-layout)). - **Keep compiler faces explicit.** Each package uses one aggregate except `api/remotes`; repo-wide programs seed a face config, never the root solution ([layout](docs/development.md#typescript-project-layout)). - **An empty `catch` names what it swallows** and why nothing else can reach it; keep the `try` to one statement. +- Do not comment on facts obvious from code. - **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction. - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR. - **Non-trivial changes MUST include an Agent Note in the same PR;** only mechanical/local edits are exempt ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)). diff --git a/README.i18n.yaml b/README.i18n.yaml index 4b30041645..6786d3a10d 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: 690cde099d93ea2a371b31f441030153b1aca973 -README.zh.md: ab0881d108446e6d871a0aa22a94cf72dad1a62d +README.md: 37c9bc77cde1e2259b4233d4dc37f9c8b1264a96 +README.zh.md: 9aca0013881205bee11eb98b91b849f93536d673 diff --git a/README.md b/README.md index 690cde099d..37c9bc77cd 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ npx @deepseek-ai/dsh web The command initializes the Web profile and prints the Web UI URL, which is `http://127.0.0.1:3080` by default. Open it, add a DeepSeek API key under **Settings → Models**, then start a session. The invoking directory is the default workspace; try `Summarize this repository and identify its main packages.` -Continue with the [Web UI guide](docs/user/guide/). +Continue with the [Web UI guide](docs/user/guide/index.md). ### Run from source @@ -32,10 +32,11 @@ To run a repository checkout instead: git clone https://github.com/deepseek-harness/deepseek-harness.git cd deepseek-harness pnpm install +pnpm run build pnpm dsh web ``` -The last command builds the repository and opens the same Web UI path. +`pnpm run build` prepares the repository artifacts. `pnpm dsh web` starts the Web UI without rebuilding and opens the same path. ## Profiles and plugins diff --git a/README.zh.md b/README.zh.md index ab0881d108..9aca001388 100644 --- a/README.zh.md +++ b/README.zh.md @@ -22,7 +22,7 @@ npx @deepseek-ai/dsh web 该命令会初始化 Web profile 并打印 Web UI 地址,默认地址为 `http://127.0.0.1:3080`。打开该地址,在**设置 → 模型**中添加 DeepSeek API 密钥,然后启动一个会话。运行命令时所在的目录将作为默认工作区;你可以尝试输入 `Summarize this repository and identify its main packages.`。 -下一步请阅读 [Web UI 指南](docs/user/guide/)。 +下一步请阅读 [Web UI 指南](docs/user/guide/index.md)。 ### 从源码运行 @@ -32,10 +32,11 @@ npx @deepseek-ai/dsh web git clone https://github.com/deepseek-harness/deepseek-harness.git cd deepseek-harness pnpm install +pnpm run build pnpm dsh web ``` -最后一条命令会先构建仓库,再启动同一个 Web UI。 +`pnpm run build` 会准备仓库产物。`pnpm dsh web` 不会重新构建,而是直接启动同一个 Web UI。 ## Profile 与插件 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 191d6c8e68..af3c04dea5 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: 98a856261bc632c97f350db8fc7bb0b10c22235d -README.zh.md: 283e54138e24202ed6b88d1d309538c58cd66b3e +README.md: 4fbd0692a2df403c6395235e096e193c994ea198 +README.zh.md: 7861f5b4f8cb447aff01ec8b64b12fd298485b4b diff --git a/apps/cli/README.md b/apps/cli/README.md index 98a856261b..4fbd0692a2 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -35,4 +35,4 @@ The [CLI behavior reference](reference/README.md) owns exact layer precedence, f ## Development -Production runs require built package and frontend artifacts. From the repository root, `pnpm dsh ` builds those artifacts, runs the TypeScript entry, and forwards every argument; the [source-execution reference](reference/README.md#source-execution) owns the module-resolution contract. +Production runs require built package and frontend artifacts. From the repository root, run `pnpm run build` separately, then use `pnpm dsh ` to run the TypeScript entry and forward every argument; the [source-execution reference](reference/README.md#source-execution) owns the module-resolution contract. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 283e54138e..7861f5b4f8 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -35,4 +35,4 @@ profile 目录包含一个 `package.json`(树外插件依赖,加上 profile ## 开发 -生产运行需要已构建的包与前端产物。从仓库根目录运行 `pnpm dsh ` 会先构建这些产物,再运行 TypeScript 入口并转发所有参数;模块解析约定由[源码执行参考](reference/README.md#source-execution)负责。 +生产运行需要已构建的包与前端产物。请在仓库根目录单独运行 `pnpm run build`,然后使用 `pnpm dsh ` 运行 TypeScript 入口并转发所有参数;模块解析约定由[源码执行参考](reference/README.md#source-execution)负责。 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 9968fedb98..68fabbfd49 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: 46ea3c241d6775ce90a89c7be58901375a0634a3 -README.zh.md: 59633b224c5d1512668513fbe9ee9b61eb6e399f +README.md: e8bc99f7b2b15f0679ec268a8e778e815758c00f +README.zh.md: 375b87f97451cc309f17e0abdde5ff4864539df2 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 46ea3c241d..e8bc99f7b2 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -81,4 +81,4 @@ Install external plugin bundles through `dsh plugin --profile add `. The `package.json` script runs the complete repository build, launches `apps/cli/src/bin.ts` with `node --import tsx/esm`, and forwards every argument. Build output appears before CLI output. The process inherits the launch environment; set `NODE_USE_ENV_PROXY=1` when a supporting Node version must honor `HTTP_PROXY` and `HTTPS_PROXY`. The installed form launches the built `apps/cli/lib/bin.js` without rebuilding the repository. +From the repository root, run `pnpm run build` separately after a fresh checkout and whenever artifacts need updating, then use `pnpm dsh `. The `package.json` script launches `apps/cli/src/bin.ts` with `node --import tsx/esm` without building and forwards every argument. Missing TypeRT host artifacts fail profile boot through module-resolution errors without a build instruction. Once those host artifacts exist, missing frontend or client-plugin bundles fail at startup with an instruction to run `pnpm run build`. The launcher does not check freshness, so existing stale bundles can run older browser code until rebuilt. The process inherits the launch environment; set `NODE_USE_ENV_PROXY=1` when a supporting Node version must honor `HTTP_PROXY` and `HTTPS_PROXY`. The installed form launches the built `apps/cli/lib/bin.js` without rebuilding the repository. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 59633b224c..375b87f974 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -81,4 +81,4 @@ dsh web --help ## 源码执行 -请从仓库根目录使用 `pnpm dsh `。`package.json` 中的脚本会完成整个仓库的构建,通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`,并转发所有参数。构建输出会显示在 CLI 输出之前。该进程会继承启动环境;当支持环境代理的 Node 版本必须遵循 `HTTP_PROXY` 和 `HTTPS_PROXY` 时,请设置 `NODE_USE_ENV_PROXY=1`。安装形式会直接启动构建后的 `apps/cli/lib/bin.js`,不会重新构建仓库。 +请在仓库根目录中,于全新 checkout 之后及产物需要更新时单独运行 `pnpm run build`,然后使用 `pnpm dsh `。`package.json` 中的脚本不会构建,而是通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`,并转发所有参数。TypeRT Host 产物缺失时,profile 启动会因不含构建指引的模块解析错误而失败。这些 Host 产物存在后,如果前端或 Client plugin 组合包缺失,启动会失败并提示运行 `pnpm run build`。启动器不会检查产物是否为最新,因此已有的陈旧组合包可能继续运行旧版浏览器代码,直至重新构建。该进程会继承启动环境;当支持环境代理的 Node 版本必须遵循 `HTTP_PROXY` 和 `HTTPS_PROXY` 时,请设置 `NODE_USE_ENV_PROXY=1`。安装形式会直接启动构建后的 `apps/cli/lib/bin.js`,不会重新构建仓库。 diff --git a/apps/cli/tests/source-launch.compat.spec.ts b/apps/cli/tests/source-launch.compat.spec.ts index 597c3cff26..975e4c3591 100644 --- a/apps/cli/tests/source-launch.compat.spec.ts +++ b/apps/cli/tests/source-launch.compat.spec.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest' /** * Keyless smoke for SOURCE `dsh` execution: run `apps/cli/src/bin.ts` * with the exact production runtime vector (`node --import tsx/esm`, the - * vector the root `dsh` script invokes after building) and assert the + * vector the root `dsh` script invokes directly) and assert the * required-config diagnostic. The Node compatibility matrix runs this * WHOLE file, so a Node release changing module hooks or TypeScript handling * breaks this gate instead of every developer's `pnpm dsh`; the built-bin @@ -17,11 +17,11 @@ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const dshSourceBin = 'apps/cli/src/bin.ts' describe('dsh SOURCE launcher (node --import tsx/esm)', () => { - it('builds before launching the source CLI', async () => { + it('launches the source CLI without building', async () => { const rootPackage = JSON.parse(await readFile(new URL('../../../package.json', import.meta.url), 'utf8')) as { readonly scripts?: Record } - expect(rootPackage.scripts?.dsh).toBe('pnpm run build && node --import tsx/esm apps/cli/src/bin.ts') + expect(rootPackage.scripts?.dsh).toBe('node --import tsx/esm apps/cli/src/bin.ts') }) it('boots the source entry and requires a profile', async () => { diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts index 2d2dda42d6..b596203c39 100644 --- a/apps/web/tests/image-display.snapshot.ts +++ b/apps/web/tests/image-display.snapshot.ts @@ -143,8 +143,55 @@ it('accepts pasted images into the composer rail in order and removes them', asy }, }) const toast = await screen.findByRole('alert') - expect(toast.textContent).toContain('Unsupported image format: text/plain') + expect(toast.textContent).toContain('Only PNG, JPG, WebP, and GIF images are supported') await waitFor(() => { expect(screen.queryByRole('alert')).toBeNull() }, { timeout: 6_000 }) }) + +it('accepts a whole-page drop under the limits-labeled overlay and refuses an over-limit batch at intake', async () => { + mountAssembledApp() + + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + const start = tree.querySelector('button[aria-label="New session in fixture"]') + if (start === null) throw new Error('fixture Workspace new-session action missing') + fireEvent.click(start) + const textarea = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) + + // A file drag anywhere over the page raises the full-viewport overlay whose + // desc line carries the projected limits — copy that can only render after + // the imageLimits projection crossed the real fixture transport. + const image = new File([new Uint8Array([137, 80, 78, 71])], 'dropped.png', { type: 'image/png' }) + const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' } + fireEvent.dragEnter(document.body, { dataTransfer }) + const overlay = await screen.findByRole('status') + expect(overlay.textContent).toContain('Drag images here to add them') + await waitFor(() => { + expect(overlay.textContent).toContain('Up to 20 images, 5MB each') + }) + + // Dropping on the transcript area (not the composer card) lands in the rail. + fireEvent.drop(document.body, { dataTransfer }) + await waitFor(() => { + const rail = document.querySelector('[role="group"][aria-label="Pending images"]') + if (rail === null) throw new Error('attachment rail missing after page drop') + expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toEqual(['dropped.png']) + }, { timeout: 5_000 }) + expect(screen.queryByRole('status')).toBeNull() + + // An intake that would exceed the projected per-message count is refused as + // a whole batch at add time: the banner names the limit and the rail keeps + // only the previously accepted thumbnail — no submit-time rollback. + const batch = Array.from({ length: 20 }, (_, i) => + new File([new Uint8Array([137, 80, 78, 71])], `bulk-${String(i)}.png`, { type: 'image/png' })) + fireEvent.paste(textarea, { + clipboardData: { + items: batch.map(file => ({ kind: 'file', type: 'image/png', getAsFile: () => file })), + getData: () => '', + }, + }) + const banner = await screen.findByRole('alert') + expect(banner.textContent).toContain('A message can include up to 20 images') + const rail = document.querySelector('[role="group"][aria-label="Pending images"]') + expect([...(rail?.querySelectorAll('img') ?? [])]).toHaveLength(1) +}) diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index bdad2083aa..15956877f4 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -56,9 +56,9 @@ describe('web e2e: plugin configuration section', () => { await page.getByRole('button', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: '插件' }).click() + await dialog.getByRole('button', { name: '插件配置', exact: true }).click() await expect - .poll(() => dialog.getByRole('button', { name: '插件' }).getAttribute('aria-current'), { timeout: 5_000 }) + .poll(() => dialog.getByRole('button', { name: '插件配置', exact: true }).getAttribute('aria-current'), { timeout: 5_000 }) .toBe('true') return dialog } diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index a3b387b660..5d057a7bed 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -23,6 +23,8 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import.meta.url)) const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md') +const PLUGINS_EXPECTED = join(SNAPSHOT_DIR, 'plugins.expected.md') +const PLUGIN_ROW_SELECTOR = '[data-plugin-entry$="ui-settings"]' const MODE = webSnapshotMode() describe('web e2e: settings modal and General preferences', () => { @@ -92,6 +94,28 @@ describe('web e2e: settings modal and General preferences', () => { await dialog.getByRole('button', { name: '模型' }).click() await expect.poll(() => dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current'), { timeout: 5_000 }).toBe('true') expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBeNull() + // Plugins is a read-only projection of the same assembled Loader tree. + // Capture one stable shipped row rather than the whole inventory so adding + // an unrelated plugin does not rewrite this surface's golden. + await dialog.getByRole('button', { name: '插件', exact: true }).click() + await dialog.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 }) + const pluginRow = dialog.locator(PLUGIN_ROW_SELECTOR) + await pluginRow.waitFor({ timeout: 10_000 }) + const expectedPluginCount = [...scaffold.ctx.loader.entries()] + .filter(entry => !entry.options.group) + .length + expect(await dialog.getByRole('searchbox', { name: '搜索插件' }).count()).toBe(1) + expect(await dialog.locator('[data-plugin-entry]').count()).toBe(expectedPluginCount) + expect(await dialog.locator('[data-plugin-count]').getAttribute('data-plugin-count')) + .toBe(String(expectedPluginCount)) + expect(await dialog.getByRole('button', { name: '插件', exact: true }).getAttribute('aria-current')).toBe('true') + expect(await dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current')).toBeNull() + const pluginsSnapshot = await captureStableAria( + page, + PLUGIN_ROW_SELECTOR, + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(PLUGINS_EXPECTED, pluginsSnapshot, MODE) // Close path 1: Escape. await page.keyboard.press('Escape') await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0) @@ -454,6 +478,6 @@ describe('web e2e: settings modal and General preferences', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md', 'plugins.expected.md']) }) }) diff --git a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md index fc96442a85..40b9497831 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md index b8136ec702..c3e9035098 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md index 9bc3d8db4d..e411e8ea28 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/models-settings/configured.expected.md b/apps/web/tests/snapshots/models-settings/configured.expected.md index e4fb6e13e8..66cc7c88b6 100644 --- a/apps/web/tests/snapshots/models-settings/configured.expected.md +++ b/apps/web/tests/snapshots/models-settings/configured.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/models-settings/declared-edit.expected.md b/apps/web/tests/snapshots/models-settings/declared-edit.expected.md index 1acd03b4aa..86f8b77fe8 100644 --- a/apps/web/tests/snapshots/models-settings/declared-edit.expected.md +++ b/apps/web/tests/snapshots/models-settings/declared-edit.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/models-settings/declared.expected.md b/apps/web/tests/snapshots/models-settings/declared.expected.md index b126a5025b..b9e5dca61f 100644 --- a/apps/web/tests/snapshots/models-settings/declared.expected.md +++ b/apps/web/tests/snapshots/models-settings/declared.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/models-settings/empty.expected.md b/apps/web/tests/snapshots/models-settings/empty.expected.md index 5a1dba54ee..03e87a7a51 100644 --- a/apps/web/tests/snapshots/models-settings/empty.expected.md +++ b/apps/web/tests/snapshots/models-settings/empty.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md index 2624f4db70..562b54d837 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md index 182fadf973..b3e1141abc 100644 --- a/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md +++ b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/plugin-config/section.expected.md b/apps/web/tests/snapshots/plugin-config/section.expected.md index 7d10d05cd1..18cdef13d5 100644 --- a/apps/web/tests/snapshots/plugin-config/section.expected.md +++ b/apps/web/tests/snapshots/plugin-config/section.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md index 914293aee3..2a7c767bf8 100644 --- a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md +++ b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md @@ -7,6 +7,9 @@ - button "模型": - img - text: 模型 + - button "插件": + - img + - text: 插件 - button "Agent 预设": - img - text: Agent 预设 diff --git a/apps/web/tests/snapshots/settings-chrome/plugins.expected.md b/apps/web/tests/snapshots/settings-chrome/plugins.expected.md new file mode 100644 index 0000000000..9e8362a942 --- /dev/null +++ b/apps/web/tests/snapshots/settings-chrome/plugins.expected.md @@ -0,0 +1,6 @@ +- listitem: + - button "ui-settings, 已挂载, 已启用": + - strong: ui-settings + - img "已挂载" + - text: 已启用 + - img diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index a886b7c0b2..7866a51848 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.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 docs/api-gateway.md -api-gateway.md: c60793532d621585fc9878b6197497b45015856b -api-gateway.zh.md: 21711e5b7e1463f8ce90e60791957b49caee596f +api-gateway.md: e2878dce4611af562ebc4f30cbb2c5cb529dd61e +api-gateway.zh.md: 62ee241e77a14cafa93fdca88778bf7a64e941c8 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index c60793532d..e2878dce46 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -138,7 +138,7 @@ SRC solves only dispatch for a Host process running from source. The Client does ## Development mode -The repository `dsh` script completes the Host, Client, and Web build before starting the source Host. Web development runs that command and the Client plugin watcher in separate terminals: +Web development prepares current Host, Client, and Web artifacts with `pnpm run build`, then runs the source Host and the Client plugin watcher in separate terminals: ```sh pnpm dsh web diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 21711e5b7e..62ee241e77 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -138,7 +138,7 @@ SRC 只解决 Host 源码进程的分发问题。Client 不会从运行中的 Ho ## 开发模式 -仓库的 `dsh` 脚本会先完成 Host、Client 与 Web 构建,再启动源码 Host。Web 开发需要在两个终端中分别运行该命令和 Client plugin watcher: +Web 开发先使用 `pnpm run build` 准备当前 Host、Client 与 Web 产物,然后在两个终端中分别运行源码 Host 和 Client plugin watcher: ```sh pnpm dsh web diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 38a96b5f35..b470ccea4c 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 2bb1f315e02c3f4bab379593227f1c381818a9fa -config-catalog.zh.md: 79d4bd0668fcfe7207a08e18df871720f7b55fcd +config-catalog.md: 415f4a305caf52121aa3ae9396bfce5442782f71 +config-catalog.zh.md: 6272f2a31bb994f2d2b95c4db2e258bd3e108f25 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2bb1f315e0..415f4a305c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -69,6 +69,8 @@ export interface Config { skills?: agentCore.SkillConfig /** Model-facing bash tool config forwarded through agent-core. */ toolBash?: NonNullable + /** Process-local background-task admission config forwarded through agent-core. */ + tasks?: NonNullable /** Generic background-task controls forwarded through agent-core; set false to omit their tools. */ toolTasks?: NonNullable /** Persisted same-session goals; owner defaults enable them, or false disables the stack and tools. */ @@ -172,9 +174,10 @@ Source: [`packages/preset/agent-presets/src/preset.ts:52`](../packages/preset/ag * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the - * workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool - * plugins this bundle owns. Provider adapters own their `retryPolicy`; this - * bundle always mounts its executor. + * workspace-context loader, `tasks` to the process-local task provider, and + * `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns. + * Provider adapters own their `retryPolicy`; this bundle always mounts its + * executor. * `goals` opts into and configures the persisted goal domain plus its model tool * and same-session driver; `invariants` configures global and package-filtered * relational checks. Owner schemas supply defaults for optional input; @@ -211,6 +214,8 @@ export interface Config { skills?: SkillConfig /** Model-facing bash tool config, or false when another plugin owns `bash`. */ toolBash?: toolBash.Config | false + /** Process-local background-task admission config. */ + tasks?: TasksConfig /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ toolTasks?: toolTasks.Config | false /** Global enablement and package-name filters for invariant companions. */ @@ -240,9 +245,9 @@ export interface GoalConfig { } ``` -Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) +Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`TasksConfig`](#deepseek-aidsh-tasks-local) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:90`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:91`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-agent-tool-mode` @@ -362,7 +367,7 @@ export interface ConnectionConfig { } ``` -Source: [`packages/client/connection/src/index.ts:52`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:50`](../packages/client/connection/src/index.ts) ## `@deepseek-ai/dsh-client-hmr` @@ -2034,6 +2039,21 @@ export interface Config { Source: [`packages/core/system-prompt/src/index.ts:186`](../packages/core/system-prompt/src/index.ts) +## `@deepseek-ai/dsh-tasks-local` + +```ts config-catalog +/** Configuration for the process-local task registry. */ +export interface Config { + /** + * Maximum `running` plus `stopping` tasks per exact owner or in the shared unowned bucket; + * omission defaults to 10. + */ + maxConcurrentTasksPerOwner?: number +} +``` + +Source: [`packages/tasks/tasks-local/src/index.ts:31`](../packages/tasks/tasks-local/src/index.ts) + ## `@deepseek-ai/dsh-time-context` Requires: `agents` @@ -2770,6 +2790,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-permission` ([`packages/client/ui-permission/src/index.ts`](../packages/client/ui-permission/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plan` ([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plugin-config` ([`packages/client/ui-plugin-config/src/index.ts`](../packages/client/ui-plugin-config/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-plugins` ([`packages/client/ui-plugins/src/index.ts`](../packages/client/ui-plugins/src/index.ts)) - `@deepseek-ai/dsh-client-ui-question` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) @@ -2792,6 +2813,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-auto` — requires `httpServer` · `loader` ([`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-native` ([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts)) +- `@deepseek-ai/dsh-host-plugin-inventory` — requires `loader` ([`packages/host/plugin-inventory/src/index.ts`](../packages/host/plugin-inventory/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts)) @@ -2802,7 +2824,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts)) -- `@deepseek-ai/dsh-tasks-local` ([`packages/tasks/tasks-local/src/index.ts`](../packages/tasks/tasks-local/src/index.ts)) - `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/interaction/tool-ask-user/src/index.ts`](../packages/interaction/tool-ask-user/src/index.ts)) - `@deepseek-ai/dsh-tool-schedule` — requires `agents` · `sessions` · `tools` · `sessionPersistence` ([`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 79d4bd0668..6272f2a31b 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -71,6 +71,8 @@ export interface Config { skills?: agentCore.SkillConfig /** Model-facing bash tool config forwarded through agent-core. */ toolBash?: NonNullable + /** Process-local background-task admission config forwarded through agent-core. */ + tasks?: NonNullable /** Generic background-task controls forwarded through agent-core; set false to omit their tools. */ toolTasks?: NonNullable /** Persisted same-session goals; owner defaults enable them, or false disables the stack and tools. */ @@ -174,9 +176,10 @@ export type PresetTrust = 'system' | 'user' * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the - * workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool - * plugins this bundle owns. Provider adapters own their `retryPolicy`; this - * bundle always mounts its executor. + * workspace-context loader, `tasks` to the process-local task provider, and + * `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns. + * Provider adapters own their `retryPolicy`; this bundle always mounts its + * executor. * `goals` opts into and configures the persisted goal domain plus its model tool * and same-session driver; `invariants` configures global and package-filtered * relational checks. Owner schemas supply defaults for optional input; @@ -213,6 +216,8 @@ export interface Config { skills?: SkillConfig /** Model-facing bash tool config, or false when another plugin owns `bash`. */ toolBash?: toolBash.Config | false + /** Process-local background-task admission config. */ + tasks?: TasksConfig /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ toolTasks?: toolTasks.Config | false /** Global enablement and package-name filters for invariant companions. */ @@ -242,9 +247,9 @@ export interface GoalConfig { } ``` -依赖:[`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) +依赖:[`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`GoalDomainConfig`](#deepseek-aidsh-goal) · [`InvariantConfig`](#deepseek-aidsh-invariants) · [`SessionTitleConfig`](#deepseek-aidsh-session-title) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`TasksConfig`](#deepseek-aidsh-tasks-local) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`toolGoal`](../packages/goal/tool-goal/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -来源:[`packages/examples/agent-spine-demo/src/index.ts:90`](../packages/examples/agent-spine-demo/src/index.ts) +来源:[`packages/examples/agent-spine-demo/src/index.ts:91`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-agent-tool-mode` @@ -364,7 +369,7 @@ export interface ConnectionConfig { } ``` -来源:[`packages/client/connection/src/index.ts:52`](../packages/client/connection/src/index.ts) +来源:[`packages/client/connection/src/index.ts:50`](../packages/client/connection/src/index.ts) ## `@deepseek-ai/dsh-client-hmr` @@ -2036,6 +2041,21 @@ export interface Config { 来源:[`packages/core/system-prompt/src/index.ts:186`](../packages/core/system-prompt/src/index.ts) +## `@deepseek-ai/dsh-tasks-local` + +```ts config-catalog +/** Configuration for the process-local task registry. */ +export interface Config { + /** + * Maximum `running` plus `stopping` tasks per exact owner or in the shared unowned bucket; + * omission defaults to 10. + */ + maxConcurrentTasksPerOwner?: number +} +``` + +来源:[`packages/tasks/tasks-local/src/index.ts:31`](../packages/tasks/tasks-local/src/index.ts) + ## `@deepseek-ai/dsh-time-context` 需要:`agents` @@ -2771,6 +2791,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-permission`([`packages/client/ui-permission/src/index.ts`](../packages/client/ui-permission/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plan`([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plugin-config`([`packages/client/ui-plugin-config/src/index.ts`](../packages/client/ui-plugin-config/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-plugins`([`packages/client/ui-plugins/src/index.ts`](../packages/client/ui-plugins/src/index.ts)) - `@deepseek-ai/dsh-client-ui-question`([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings`([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general`([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) @@ -2793,6 +2814,7 @@ export interface Config { - `@deepseek-ai/dsh-goal-session` — 需要 `agents` · `goals` · `sessions`([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-auto` — 需要 `httpServer` · `loader`([`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-native`([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts)) +- `@deepseek-ai/dsh-host-plugin-inventory` — 需要 `loader`([`packages/host/plugin-inventory/src/index.ts`](../packages/host/plugin-inventory/src/index.ts)) - `@deepseek-ai/dsh-llm`([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp`([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-pty`([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts)) @@ -2803,7 +2825,6 @@ export interface Config { - `@deepseek-ai/dsh-storage`([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent`([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-subprocess-local`([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts)) -- `@deepseek-ai/dsh-tasks-local`([`packages/tasks/tasks-local/src/index.ts`](../packages/tasks/tasks-local/src/index.ts)) - `@deepseek-ai/dsh-timeout-policy` — 需要 `tools`([`packages/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — 需要 `tools` · `userInteraction`([`packages/interaction/tool-ask-user/src/index.ts`](../packages/interaction/tool-ask-user/src/index.ts)) - `@deepseek-ai/dsh-tool-schedule` — 需要 `agents` · `sessions` · `tools` · `sessionPersistence`([`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts)) diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index e3d5ef8e98..df522071e0 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.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 docs/development.md -development.md: d5bd18f83f6f2f8fd3d507ec133eea4d4e06abef -development.zh.md: f2c7985a2e2018d600c1f135a9c81403d4edbcd9 +development.md: 8d79756c50f82a840d412f52ee980cb0b505f6f1 +development.zh.md: aca2e99381af245b51715b8439d1210dadff7755 diff --git a/docs/development.md b/docs/development.md index d5bd18f83f..8d79756c50 100644 --- a/docs/development.md +++ b/docs/development.md @@ -126,6 +126,12 @@ The root [contributor instructions](../AGENTS.md#commands) summarize common comm ### Demos +Run the repository build separately before using these source-checkout demos: + +```sh +pnpm run build +``` + The one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`: ```sh diff --git a/docs/development.zh.md b/docs/development.zh.md index f2c7985a2e..aca2e99381 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -126,6 +126,12 @@ keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若 ### 演示 +从源码 checkout 运行这些演示前,请单独执行仓库构建: + +```sh +pnpm run build +``` + 单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`: ```sh diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index 2551bbfdbf..7f40689322 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/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 docs/i18n/README.md -README.md: 23400801426f77dae5136406cd747dbe4b06a4c5 -README.zh.md: 445037ac3afba081a0235c40663286d2c35b9a92 +README.md: 3acddd310a423b6a19014063418d81350562b188 +README.zh.md: acfb80d78c8891fddcbe97499372139055d3d2c1 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index 2340080142..3acddd310a 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -18,7 +18,7 @@ This repo's documentation is read by people and agents both inside and outside t Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. Routine work makes that patch directly; when the user explicitly invokes the extended workflow, `pnpm run gen-translation-brief ` can instead assemble the update at the narrowest safely aligned granularity and `--apply` can splice a code-fence-only change after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form). When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives. -- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output. +- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output. A README published outside GitHub, such as PyPI project metadata, may use the canonical `https://github.com/deepseek-ai/deepseek-harness/blob/master/` URL to the same counterpart so the switcher still resolves there. - **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`). ## The gate: verify-translation-pairing diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index 445037ac3a..acfb80d78c 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -18,7 +18,7 @@ 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的 worktree 内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。日常工作会直接完成这份修补;用户显式调用扩展工作流时,可改由 `pnpm run gen-translation-brief ` 以能安全对齐的最窄粒度汇集这次更新,并由 `--apply` 在结构校验后拼接仅涉及围栏代码块的改动([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 YAML diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。 -- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。 +- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。发布到 GitHub 以外位置的 README(例如 PyPI 项目元数据)可以改用指向同一对侧文件的规范 `https://github.com/deepseek-ai/deepseek-harness/blob/master/` URL,使切换行在该位置仍可访问。 - **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。 ## 门禁:verify-translation-pairing diff --git a/docs/i18n/translation-rules.i18n.yaml b/docs/i18n/translation-rules.i18n.yaml index 34b03c956f..6d41efc36d 100644 --- a/docs/i18n/translation-rules.i18n.yaml +++ b/docs/i18n/translation-rules.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 docs/i18n/translation-rules.md -translation-rules.md: ce20ed9a9673b0782ef07c9a4a21ff1c98ace960 -translation-rules.zh.md: daea57ab1d3a1abbad442982c8bb1c189478b8a8 +translation-rules.md: 79ec3de50ecbb57bc84cac39a0edc9b5ac26c5d2 +translation-rules.zh.md: 5861c074e8e6cd79e3fd2799cf4152c6bac93182 diff --git a/docs/i18n/translation-rules.md b/docs/i18n/translation-rules.md index ce20ed9a96..79ec3de50e 100644 --- a/docs/i18n/translation-rules.md +++ b/docs/i18n/translation-rules.md @@ -28,7 +28,7 @@ The pairing gate checks heading depths, fenced code blocks, table row and column - tables (same columns, same row order; header cells translated per terminology), - fenced code blocks — **byte-identical, including comments**; the pairing signature compares their info strings and contents, and ` ```ts ` blocks compile under `doc-typecheck`, - inline code spans (commands, flags, config keys, file paths, event names, API names, version numbers) — verbatim, never translated or reformatted, -- links and anchors: every relative link MUST point at the same target in both files — by convention the `.md` path, not the `.zh.md` sibling — so links never dangle when one pair lands before its neighbors. The ONLY zh-specific link is the language switcher. Link TEXT is translated; the target is not. +- links and anchors: every relative link MUST point at the same target in both files — by convention the `.md` path, not the `.zh.md` sibling — so links never dangle when one pair lands before its neighbors. The ONLY zh-specific link is the language switcher. A README rendered outside GitHub MAY use the canonical public repository URL to its exact counterpart as documented in [README.md](README.md). Link TEXT is translated; the target is not. The repo's Markdown conventions apply to `.zh.md` files unchanged: one physical line per paragraph (`verify-md-wrap`), resolving relative links (`verify-md-links`), exactly one trailing newline. diff --git a/docs/i18n/translation-rules.zh.md b/docs/i18n/translation-rules.zh.md index daea57ab1d..5861c074e8 100644 --- a/docs/i18n/translation-rules.zh.md +++ b/docs/i18n/translation-rules.zh.md @@ -28,7 +28,7 @@ - 表格(相同的列、相同的行序;表头单元格按术语表翻译); - 围栏代码块:**逐字节一致,包括注释**。配对签名比对信息字符串与内容,` ```ts ` 块还要通过 `doc-typecheck` 编译; - 行内代码(命令、flag、配置键、文件路径、事件名、API 名、版本号):原样保留,从不翻译或重排; -- 链接与锚点:每个相对链接在两个文件中必须指向相同的目标(按约定是 `.md` 路径而非 `.zh.md` 兄弟文件),这样即使某对文档先于相邻文件落地,链接也不会悬空。唯一的 zh 特有链接是语言切换行。链接**文字**翻译;链接目标不翻。 +- 链接与锚点:每个相对链接在两个文件中必须指向相同的目标(按约定是 `.md` 路径而非 `.zh.md` 兄弟文件),这样即使某对文档先于相邻文件落地,链接也不会悬空。唯一的 zh 特有链接是语言切换行。在 GitHub 以外位置渲染的 README 可以按 [README.md](README.md) 的规定,使用指向确切对侧文件的规范公开仓库 URL。链接**文字**翻译;链接目标不翻。 本仓库的 Markdown 约定对 `.zh.md` 文件原样生效:一个段落一个物理行(`verify-md-wrap`)、相对链接必须可解析(`verify-md-links`)、文件末尾恰好一个换行。 diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 5b26c5608a..cbd76b01e5 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: e24d2601fd74f58eec8c5e5b1a35701e1800baaa -module-graph.zh.md: b71da431b05634c4c599096c8cd33fed23a6be45 +module-graph.md: 9ca575137f3ebdddbc77e89c3b3b97da90c94843 +module-graph.zh.md: 6978ff30b7e8e927a0d0a10dd7b06cfbbeb1f7e9 diff --git a/docs/module-graph.md b/docs/module-graph.md index e24d2601fd..9ca575137f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -162,6 +162,7 @@ flowchart TD pkg_client_ui_permission["client-ui-permission"] pkg_client_ui_plan["client-ui-plan"] pkg_client_ui_plugin_config["client-ui-plugin-config"] + pkg_client_ui_plugins["client-ui-plugins"] pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_question["client-ui-question"] pkg_client_ui_settings["client-ui-settings"] @@ -219,6 +220,7 @@ flowchart TD pkg_host_directory_picker_auto["host-directory-picker-auto"] pkg_host_directory_picker_browse["host-directory-picker-browse"] pkg_host_directory_picker_native["host-directory-picker-native"] + pkg_host_plugin_inventory["host-plugin-inventory"] pkg_host_webserver["host-webserver"] end subgraph group_interaction["packages/interaction"] @@ -358,6 +360,9 @@ flowchart TD pkg_subprocess_e2b --> pkg_timeout pkg_frontend_static --> pkg_host_webserver pkg_frontend_static --> pkg_invariants + pkg_host_plugin_inventory --> pkg_brand + pkg_host_plugin_inventory --> pkg_invariants + pkg_host_plugin_inventory --> pkg_type_meta pkg_user_id --> pkg_brand pkg_user_id --> pkg_invariants pkg_user_id --> pkg_paths @@ -642,6 +647,7 @@ flowchart TD pkg_api_remotes --> pkg_commands pkg_api_remotes --> pkg_credentials pkg_api_remotes --> pkg_goal + pkg_api_remotes --> pkg_host_plugin_inventory pkg_api_remotes --> pkg_invariants pkg_api_remotes --> pkg_llm pkg_api_remotes --> pkg_session @@ -1136,6 +1142,13 @@ flowchart TD pkg_client_ui_plugin_config --> pkg_client_ui_slots pkg_client_ui_plugin_config --> pkg_client_web_react pkg_client_ui_plugin_config --> pkg_invariants + pkg_client_ui_plugins --> pkg_api_remotes + pkg_client_ui_plugins --> pkg_client_locale + pkg_client_ui_plugins --> pkg_client_runtime + pkg_client_ui_plugins --> pkg_client_ui_primitives + pkg_client_ui_plugins --> pkg_client_ui_settings + pkg_client_ui_plugins --> pkg_client_ui_slots + pkg_client_ui_plugins --> pkg_invariants pkg_client_ui_question --> pkg_api_remotes pkg_client_ui_question --> pkg_client_locale pkg_client_ui_question --> pkg_invariants @@ -1374,6 +1387,7 @@ flowchart TD | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | +| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta) | | [`user-id`](../packages/session/user-id) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | @@ -1443,7 +1457,7 @@ flowchart TD | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | @@ -1522,6 +1536,7 @@ flowchart TD | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`client-ui-plugin-config`](../packages/client/ui-plugin-config) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`client-ui-plugins`](../packages/client/ui-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-question`](../packages/client/ui-question) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index b71da431b0..6978ff30b7 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -164,6 +164,7 @@ flowchart TD pkg_client_ui_permission["client-ui-permission"] pkg_client_ui_plan["client-ui-plan"] pkg_client_ui_plugin_config["client-ui-plugin-config"] + pkg_client_ui_plugins["client-ui-plugins"] pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_question["client-ui-question"] pkg_client_ui_settings["client-ui-settings"] @@ -221,6 +222,7 @@ flowchart TD pkg_host_directory_picker_auto["host-directory-picker-auto"] pkg_host_directory_picker_browse["host-directory-picker-browse"] pkg_host_directory_picker_native["host-directory-picker-native"] + pkg_host_plugin_inventory["host-plugin-inventory"] pkg_host_webserver["host-webserver"] end subgraph group_interaction["packages/interaction"] @@ -360,6 +362,9 @@ flowchart TD pkg_subprocess_e2b --> pkg_timeout pkg_frontend_static --> pkg_host_webserver pkg_frontend_static --> pkg_invariants + pkg_host_plugin_inventory --> pkg_brand + pkg_host_plugin_inventory --> pkg_invariants + pkg_host_plugin_inventory --> pkg_type_meta pkg_user_id --> pkg_brand pkg_user_id --> pkg_invariants pkg_user_id --> pkg_paths @@ -644,6 +649,7 @@ flowchart TD pkg_api_remotes --> pkg_commands pkg_api_remotes --> pkg_credentials pkg_api_remotes --> pkg_goal + pkg_api_remotes --> pkg_host_plugin_inventory pkg_api_remotes --> pkg_invariants pkg_api_remotes --> pkg_llm pkg_api_remotes --> pkg_session @@ -1138,6 +1144,13 @@ flowchart TD pkg_client_ui_plugin_config --> pkg_client_ui_slots pkg_client_ui_plugin_config --> pkg_client_web_react pkg_client_ui_plugin_config --> pkg_invariants + pkg_client_ui_plugins --> pkg_api_remotes + pkg_client_ui_plugins --> pkg_client_locale + pkg_client_ui_plugins --> pkg_client_runtime + pkg_client_ui_plugins --> pkg_client_ui_primitives + pkg_client_ui_plugins --> pkg_client_ui_settings + pkg_client_ui_plugins --> pkg_client_ui_slots + pkg_client_ui_plugins --> pkg_invariants pkg_client_ui_question --> pkg_api_remotes pkg_client_ui_question --> pkg_client_locale pkg_client_ui_question --> pkg_invariants @@ -1376,6 +1389,7 @@ flowchart TD | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`frontend-static`](../packages/host/frontend-static) | `host` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | +| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta) | | [`user-id`](../packages/session/user-id) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | @@ -1445,7 +1459,7 @@ flowchart TD | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | @@ -1524,6 +1538,7 @@ flowchart TD | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`client-ui-plugin-config`](../packages/client/ui-plugin-config) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`client-ui-plugins`](../packages/client/ui-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-question`](../packages/client/ui-question) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | diff --git a/docs/subsystems/tasks.i18n.yaml b/docs/subsystems/tasks.i18n.yaml index 43e87a4d7c..de0f9ac1be 100644 --- a/docs/subsystems/tasks.i18n.yaml +++ b/docs/subsystems/tasks.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 docs/subsystems/tasks.md -tasks.md: 37807bc446f607c3e1635a49432670e86760f2a4 -tasks.zh.md: 420c133f768d1b48cb5e3ffee3a5f35e46a313f4 +tasks.md: 8142e7d21a3db717745b841b95a0227c20ad5137 +tasks.zh.md: ba11228ae31eb4a46406bcdd7191e3a74b810e81 diff --git a/docs/subsystems/tasks.md b/docs/subsystems/tasks.md index 37807bc446..8142e7d21a 100644 --- a/docs/subsystems/tasks.md +++ b/docs/subsystems/tasks.md @@ -154,7 +154,7 @@ interface TaskRead { ## Service behavior -The abstract [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition specifies atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, failure-isolated `onTaskDone` and `onTasksChanged` listeners, and when `attachController` becomes available; [`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) is the process-local Service provider. Authorization compares owner sessions; owner cleanup selects the exact registered `Agent` instance. See [`dsh-tasks`](../../packages/tasks/tasks/README.md) for the Service Definition contract, [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md) for the registry lifecycle, and [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md) for the model-facing Consumer. +The abstract [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition specifies atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, failure-isolated `onTaskDone` and `onTasksChanged` listeners, and when `attachController` becomes available; [`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) is the process-local Service provider. Authorization compares owner sessions; owner cleanup and admission use the exact registered `Agent` instance. The local provider's positive-safe-integer `maxConcurrentTasksPerOwner` config defaults to `10` and counts `running` plus `stopping` records per exact owner, with one shared bucket for unowned tasks; terminal producer settlement releases capacity. See [`dsh-tasks`](../../packages/tasks/tasks/README.md) for the Service Definition contract, [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md) for the registry lifecycle and admission policy, and [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md) for the model-facing Consumer. @@ -179,10 +179,11 @@ Implementations must honor these semantics: ```ts cordis-catalog /** - * Preflight access, validation, and owner cleanup before starting and - * atomically registering work. A throwing starter leaves nothing registered; - * after it returns, registration cannot fail. Settlement records the outcome, - * notifies listeners, and releases waiters. + * Preflight access, validation, owner cleanup, and implementation-owned + * admission before starting and atomically registering work. Any preflight + * rejection leaves no task id or execution resource. A throwing starter + * leaves nothing registered; after it returns, registration cannot fail. + * Settlement records the outcome, notifies listeners, and releases waiters. * @param spec - task identity, owner, and synchronous starter. * @returns the registry-issued `-N` id. */ diff --git a/docs/subsystems/tasks.zh.md b/docs/subsystems/tasks.zh.md index 420c133f76..ba11228ae3 100644 --- a/docs/subsystems/tasks.zh.md +++ b/docs/subsystems/tasks.zh.md @@ -154,7 +154,7 @@ interface TaskRead { ## 服务行为 -抽象的 [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition 规定原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onTaskDone` 与 `onTasksChanged` 监听器,以及 `attachController` 何时可用;[`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) 是其进程局部 Service provider。授权会比较拥有者会话;拥有者清理会选择确切的已注册 `Agent` 实例。Service Definition 约定见 [`dsh-tasks`](../../packages/tasks/tasks/README.md),注册表生命周期见 [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md),面向模型的 Consumer 见 [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md)。 +抽象的 [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition 规定原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onTaskDone` 与 `onTasksChanged` 监听器,以及 `attachController` 何时可用;[`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) 是其进程局部 Service provider。授权会比较拥有者会话;拥有者清理与准入会使用确切的已注册 `Agent` 实例。本地 Service provider 的 `maxConcurrentTasksPerOwner` 配置必须是正的安全整数,默认值为 `10`;它按确切 owner 统计 `running` 与 `stopping` 记录,所有无 owner 任务共享一个服务级桶,并在生产方终止结算后释放容量。Service Definition 约定见 [`dsh-tasks`](../../packages/tasks/tasks/README.md),注册表生命周期与准入策略见 [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md),面向模型的 Consumer 见 [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md)。 @@ -179,10 +179,11 @@ Implementations must honor these semantics: ```ts cordis-catalog /** - * Preflight access, validation, and owner cleanup before starting and - * atomically registering work. A throwing starter leaves nothing registered; - * after it returns, registration cannot fail. Settlement records the outcome, - * notifies listeners, and releases waiters. + * Preflight access, validation, owner cleanup, and implementation-owned + * admission before starting and atomically registering work. Any preflight + * rejection leaves no task id or execution resource. A throwing starter + * leaves nothing registered; after it returns, registration cannot fail. + * Settlement records the outcome, notifies listeners, and releases waiters. * @param spec - task identity, owner, and synchronous starter. * @returns the registry-issued `-N` id. */ diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml index 11d3323bad..a8d877a99f 100644 --- a/docs/user/guide/python-sdk.i18n.yaml +++ b/docs/user/guide/python-sdk.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 docs/user/guide/python-sdk.md -python-sdk.md: 3ef0e6595b0b5b7dddfe05e659c58556dcc48874 -python-sdk.zh.md: a46c79aa0c7cd3b6a286e1f64e01a8a81496c0f0 +python-sdk.md: 5e1c31bf006fe22bf0c79bb3f80e96fe6ba3072f +python-sdk.zh.md: ee2a7306ec4b322a77647ab97ffe4e24aba5fc80 diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md index 3ef0e6595b..5e1c31bf00 100644 --- a/docs/user/guide/python-sdk.md +++ b/docs/user/guide/python-sdk.md @@ -8,7 +8,7 @@ This tutorial is the programmatic alternative to the Web UI. It installs the pub - Python 3.10 or newer - Git -- Linux x64, Linux arm64, or macOS arm64 +- Linux x64, Linux arm64, or macOS 14 or newer on arm64 - A DeepSeek-compatible API endpoint and credential - An isolated workspace that the agent may modify diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md index a46c79aa0c..ee2a7306ec 100644 --- a/docs/user/guide/python-sdk.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -8,7 +8,7 @@ - Python 3.10 或更高版本 - Git -- Linux x64、Linux arm64 或 macOS arm64 +- Linux x64、Linux arm64 或 macOS 14 或更高版本的 arm64 - DeepSeek 兼容的 API 端点与凭据 - agent 可以修改的隔离 workspace diff --git a/examples/acp-agent/background-task-admission.cordis.snapshot.yml b/examples/acp-agent/background-task-admission.cordis.snapshot.yml new file mode 100644 index 0000000000..ec174f1f51 --- /dev/null +++ b/examples/acp-agent/background-task-admission.cordis.snapshot.yml @@ -0,0 +1,36 @@ +# Keyless counterpart to background-task-admission.cordis.yml: replace the +# DeepSeek adapter with replay while preserving the app's one-task admission +# config and the recorded flash route. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + tasks: + maxConcurrentTasksPerOwner: 1 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro diff --git a/examples/acp-agent/background-task-admission.cordis.yml b/examples/acp-agent/background-task-admission.cordis.yml new file mode 100644 index 0000000000..374e8449ea --- /dev/null +++ b/examples/acp-agent/background-task-admission.cordis.yml @@ -0,0 +1,24 @@ +# Bounded-task admission overlay: keep the ordinary ACP composition while +# configuring its task provider to allow one active task per exact owner. The +# scenario starts a real background Bash process, observes the second producer +# rejection, and cleans up the first task by its returned id. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + workspaceContext: + maxBytes: 65536 + tasks: + maxConcurrentTasksPerOwner: 1 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 39c5209202..736b17ed04 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -60,6 +60,9 @@ const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) const PARTIAL_LANDLOCK_CONFIG = fileURLToPath(new URL('../partial-landlock.cordis.yml', import.meta.url)) const PWSH_CONFIG = fileURLToPath(new URL('./pwsh.cordis.yml', import.meta.url)) +const BACKGROUND_TASK_ADMISSION_CONFIG = fileURLToPath( + new URL('../background-task-admission.cordis.yml', import.meta.url), +) const PRODUCT_SUBAGENT_CODEX_CONFIG = fileURLToPath(new URL('../product-subagent-codex.cordis.yml', import.meta.url)) const PRODUCT_SUBAGENT_BOTH_CONFIG = fileURLToPath(new URL('../product-subagent-both.cordis.yml', import.meta.url)) const FS_DIFF_BOUND_CONFIG = fileURLToPath(new URL('./fs-diff-bound.cordis.yml', import.meta.url)) @@ -218,6 +221,14 @@ const SCENARIOS: Scenario[] = [ configPath: PTY_CONFIG, }, { name: 'bash-tool-turn', hasModelTurn: true, recorded: true }, + { + name: 'background-task-admission', + hasModelTurn: true, + recorded: false, + overridden: true, + configPath: BACKGROUND_TASK_ADMISSION_CONFIG, + posixOnly: true, + }, // The pwsh overlay (pwsh.cordis.yml / pwsh.cordis.snapshot.yml) swaps the // bundle's bash tool for the PowerShell twin, so its header class pins its // own prompt/tool sidecars and a recorded transcript. diff --git a/examples/acp-agent/tests/snapshots/background-task-admission/input.json b/examples/acp-agent/tests/snapshots/background-task-admission/input.json new file mode 100644 index 0000000000..38b1ab6dde --- /dev/null +++ b/examples/acp-agent/tests/snapshots/background-task-admission/input.json @@ -0,0 +1,10 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { + "op": "prompt", + "text": "Start one background Bash task that stays alive. Immediately try to start a second background Bash task, observe the limit error, stop the first task by its returned task id, verify that second-task-ran.txt does not exist, then reply with exactly BOUNDED_BACKGROUND_TASKS and stop." + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/background-task-admission/replay.override.json b/examples/acp-agent/tests/snapshots/background-task-admission/replay.override.json new file mode 100644 index 0000000000..45c6a4acda --- /dev/null +++ b/examples/acp-agent/tests/snapshots/background-task-admission/replay.override.json @@ -0,0 +1,52 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "bounded-task-first", "name": "bash", "argumentsDelta": "{\"command\":\"while :; do sleep 60; done\",\"description\":\"Hold the only background task slot\",\"run_in_background\":true}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "bounded-task-first", "name": "bash", "arguments": "{\"command\":\"while :; do sleep 60; done\",\"description\":\"Hold the only background task slot\",\"run_in_background\":true}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "bounded-task-second", "name": "bash", "argumentsDelta": "{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background task\",\"run_in_background\":true}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "bounded-task-second", "name": "bash", "arguments": "{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background task\",\"run_in_background\":true}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "bounded-task-kill", "name": "task_kill", "argumentsDelta": "{\"task_id\":\"bash-1\",\"reason\":\"free the bounded task slot\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "bounded-task-kill", "name": "task_kill", "arguments": "{\"task_id\":\"bash-1\",\"reason\":\"free the bounded task slot\"}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "bounded-task-side-effect-check", "name": "bash", "argumentsDelta": "{\"command\":\"test ! -e second-task-ran.txt\",\"description\":\"Verify the rejected producer did not run\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "bounded-task-side-effect-check", "name": "bash", "arguments": "{\"command\":\"test ! -e second-task-ran.txt\",\"description\":\"Verify the rejected producer did not run\"}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "BOUNDED_BACKGROUND_TASKS" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "BOUNDED_BACKGROUND_TASKS" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 2 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/examples/acp-agent/tests/snapshots/background-task-admission/session.jsonl b/examples/acp-agent/tests/snapshots/background-task-admission/session.jsonl new file mode 100644 index 0000000000..d9d40b2539 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/background-task-admission/session.jsonl @@ -0,0 +1,58 @@ +{"type":"session","version":0,"id":"77777777-7777-4777-8777-777777777777","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1786434813544,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Start one background Bash task that stays alive. Immediately try to start a second background Bash task, observe the limit error, stop the first task by its returned task id, verify that second-task-ran.txt does not exist, then reply with exactly BOUNDED_BACKGROUND_TASKS and stop."}],"source":{"kind":"user"},"role":"user","id":"fca9abcd-66a9-4c79-ab34-7e25e65e01af"}]}} +{"type":"turn/start","seq":1,"time":1786434813545,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1786434813546,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1786434813574,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1786434813574,"data":{"content":[{"type":"text","text":"Start one background Bash task that stays alive. Immediately try to start a second background Bash task, observe the limit error, stop the first task by its returned task id, verify that second-task-ran.txt does not exist, then reply with exactly BOUNDED_BACKGROUND_TASKS and stop."}],"source":{"kind":"user"},"role":"user","id":"fca9abcd-66a9-4c79-ab34-7e25e65e01af"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1786434813575,"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":"f7801581-b729-4cbc-b205-1eabd5b96de7"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1786434813575,"data":{"title":"Start one background Bash task","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1786434813576,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786434813576,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1786434813581,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1786434813581,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"bounded-task-first","name":"bash","argumentsDelta":"{\"command\":\"while :; do sleep 60; done\",\"description\":\"Hold the only background task slot\",\"run_in_background\":true}"}}} +{"type":"assistant/chunk","seq":11,"time":1786434813581,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bounded-task-first","name":"bash","arguments":"{\"command\":\"while :; do sleep 60; done\",\"description\":\"Hold the only background task slot\",\"run_in_background\":true}"}}}} +{"type":"assistant/chunk","seq":12,"time":1786434813581,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":13,"time":1786434813581,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1786434813582,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bounded-task-first","name":"bash","arguments":"{\"command\":\"while :; do sleep 60; done\",\"description\":\"Hold the only background task slot\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f25e0e7c-76a4-45a6-a825-64d1bd42fe59"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1786434813582,"data":{"turn":1,"step":1,"callId":"bounded-task-first","name":"bash","arguments":"{\"command\":\"while :; do sleep 60; done\",\"description\":\"Hold the only background task slot\",\"run_in_background\":true}"}} +{"type":"tool/result","seq":16,"time":1786434813594,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"bounded-task-first"},"content":[{"type":"tool-result","toolCallId":"bounded-task-first","content":[{"type":"text","text":"started background task bash-1"}],"isError":false}],"role":"user","id":"0e19086f-2a9a-4e78-b5eb-5a117cad9416"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1786434813594,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":1786434813600,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":19,"time":1786434813605,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":20,"time":1786434813605,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"bounded-task-second","name":"bash","argumentsDelta":"{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background task\",\"run_in_background\":true}"}}} +{"type":"assistant/chunk","seq":21,"time":1786434813605,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bounded-task-second","name":"bash","arguments":"{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background task\",\"run_in_background\":true}"}}}} +{"type":"assistant/chunk","seq":22,"time":1786434813605,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":23,"time":1786434813605,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":24,"time":1786434813605,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bounded-task-second","name":"bash","arguments":"{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background task\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"48c909b3-5651-462f-b0d3-09198d119a2f"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"tool/call","seq":25,"time":1786434813606,"data":{"turn":1,"step":2,"callId":"bounded-task-second","name":"bash","arguments":"{\"command\":\"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done\",\"description\":\"Attempt a second background task\",\"run_in_background\":true}"}} +{"type":"tool/result","seq":26,"time":1786434813609,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"bounded-task-second"},"content":[{"type":"tool-result","toolCallId":"bounded-task-second","content":[{"type":"text","text":"Error: background task limit reached for this owner (limit: 1); use task_kill to stop an unneeded task, wait for it to finish, then retry"}],"isError":true}],"role":"user","id":"c0386bdf-df3c-4d2b-af8e-04ee28682214"}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"step/end","seq":27,"time":1786434813609,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":28,"time":1786434813614,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":29,"time":1786434813618,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":30,"time":1786434813618,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"bounded-task-kill","name":"task_kill","argumentsDelta":"{\"task_id\":\"bash-1\",\"reason\":\"free the bounded task slot\"}"}}} +{"type":"assistant/chunk","seq":31,"time":1786434813618,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bounded-task-kill","name":"task_kill","arguments":"{\"task_id\":\"bash-1\",\"reason\":\"free the bounded task slot\"}"}}}} +{"type":"assistant/chunk","seq":32,"time":1786434813618,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":33,"time":1786434813618,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":34,"time":1786434813618,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bounded-task-kill","name":"task_kill","arguments":"{\"task_id\":\"bash-1\",\"reason\":\"free the bounded task slot\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6dc2d854-59f7-4c70-8a0f-64416b324055"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} +{"type":"tool/call","seq":35,"time":1786434813618,"data":{"turn":1,"step":3,"callId":"bounded-task-kill","name":"task_kill","arguments":"{\"task_id\":\"bash-1\",\"reason\":\"free the bounded task slot\"}"}} +{"type":"tool/result","seq":36,"time":1786434813623,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"bounded-task-kill"},"content":[{"type":"tool-result","toolCallId":"bounded-task-kill","content":[{"type":"text","text":"requested cancellation of task bash-1"}],"isError":false}],"role":"user","id":"5abf87b2-3e10-448f-a529-dfc40dce2f08"}},"sourceEventSeqs":[35],"surfaceOp":"append"} +{"type":"step/end","seq":37,"time":1786434813623,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":38,"time":1786434813628,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":39,"time":1786434813632,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":40,"time":1786434813632,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"bounded-task-side-effect-check","name":"bash","argumentsDelta":"{\"command\":\"test ! -e second-task-ran.txt\",\"description\":\"Verify the rejected producer did not run\"}"}}} +{"type":"assistant/chunk","seq":41,"time":1786434813632,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bounded-task-side-effect-check","name":"bash","arguments":"{\"command\":\"test ! -e second-task-ran.txt\",\"description\":\"Verify the rejected producer did not run\"}"}}}} +{"type":"assistant/chunk","seq":42,"time":1786434813632,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":43,"time":1786434813632,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":44,"time":1786434813632,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bounded-task-side-effect-check","name":"bash","arguments":"{\"command\":\"test ! -e second-task-ran.txt\",\"description\":\"Verify the rejected producer did not run\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"85ebd1ec-c3b2-4bd2-87cb-135089efc440"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} +{"type":"tool/call","seq":45,"time":1786436340879,"data":{"turn":1,"step":4,"callId":"bounded-task-side-effect-check","name":"bash","arguments":"{\"command\":\"test ! -e second-task-ran.txt\",\"description\":\"Verify the rejected producer did not run\"}"}} +{"type":"tool/result","seq":46,"time":1786436340886,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"bounded-task-side-effect-check"},"content":[{"type":"tool-result","toolCallId":"bounded-task-side-effect-check","content":[{"type":"text","text":"(no output)"}],"isError":false}],"role":"user","id":"49f76b57-92a2-4ae2-9711-11ad7cbd4e4c"}},"sourceEventSeqs":[45],"surfaceOp":"append"} +{"type":"step/end","seq":47,"time":1786436340886,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":48,"time":1786436340891,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":49,"time":1786436340897,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":50,"time":1786436340897,"data":{"turn":1,"step":5,"chunk":{"type":"text-delta","index":0,"text":"BOUNDED_BACKGROUND_TASKS"}}} +{"type":"assistant/chunk","seq":51,"time":1786436340897,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"BOUNDED_BACKGROUND_TASKS"}}}} +{"type":"assistant/chunk","seq":52,"time":1786436340897,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":53,"time":1786436340897,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":54,"time":1786436340897,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"BOUNDED_BACKGROUND_TASKS"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"de775b06-2bb8-4bc0-8716-4fc31b9685c6"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} +{"type":"step/end","seq":55,"time":1786436340898,"data":{"turn":1,"step":5}} +{"type":"turn/end","seq":56,"time":1786436340898,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/background-task-admission/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/background-task-admission/stdout.expected.jsonl new file mode 100644 index 0000000000..7f71f1b79b --- /dev/null +++ b/examples/acp-agent/tests/snapshots/background-task-admission/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"BOUNDED_BACKGROUND_TASKS"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/package.json b/package.json index 5760b0497b..0b32c2a3b2 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,7 @@ "release:pack": "tsx scripts/release/pack.ts", "release:verify-packed-install": "tsx scripts/release/verify-packed-install.ts", "release:publish": "tsx scripts/release/publish.ts", - "dsh": "pnpm run build && node --import tsx/esm apps/cli/src/bin.ts", + "dsh": "node --import tsx/esm apps/cli/src/bin.ts", "demo:code-mode": "node scripts/demo-code-mode.mjs", "demo:cordis": "node scripts/demo-cordis.mjs", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index 421a56d951..35117e9dda 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/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/api/remotes/README.md -README.md: cc903af7204ca715c6c7931cfe44823d4d5fc71e -README.zh.md: fe34b8774c9864cef442ff8a58f22f541d40768a +README.md: 288c63c9f43654dfec428a6a8955dc537efe81a6 +README.zh.md: 1fd599b08ecc1b4ae1dba738ce8946aa4eab5946 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index cc903af720..288c63c9f4 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -6,7 +6,7 @@ Two-sided BFF for Host Remote capabilities selected by this application. The Hos `createApiRemoteAgentResolver()` reuses live Agents, resumes ordinary cold sessions, deduplicates concurrent resumes, preserves the subagent ownership fence, and configures the same resolver for TypeRT `agent` and `session` lookups. The standard Web API Proxy supplies its Agent defaults and scope setup, then uses the returned resolver for legacy methods, so migrated and unmigrated methods share one policy implementation. -The current Client assembly mounts only the Goal Remote contribution. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. +The current Client assembly mounts the Goal Remote contribution and the read-only Host plugin inventory contribution (`pluginInventory/list`). Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypeRTClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract. diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index fe34b8774c..1fd599b08e 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -6,8 +6,7 @@ `createApiRemoteAgentResolver()` 会复用 live Agent、恢复普通冷会话、对并发恢复去重、保留 subagent ownership fence,并为 TypeRT `agent` 和 `session` lookup 配置同一个 resolver。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,再将返回的 resolver 用于旧方法,使已迁移与未迁移方法共用同一份策略实现。 -当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 - +当前 Client 组合挂载 Goal Remote 贡献和只读 Host 插件清单贡献(`pluginInventory/list`)。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypeRTClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 约定,均可复用其 Client face。 diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 30ebcb3195..5c643a2aea 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -64,6 +64,7 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", @@ -78,6 +79,7 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index c08190ddc1..026fd864f2 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -3,11 +3,14 @@ import type { Context } from '@deepseek-ai/cordis' import commandsRemote from '@deepseek-ai/dsh-commands/remote' import goalsRemote from '@deepseek-ai/dsh-goal/remote' +import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote' import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta' export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta' +export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types' export type {} from '@deepseek-ai/dsh-commands/remote' export type {} from '@deepseek-ai/dsh-goal/remote' +export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypeRTRemoteEvent` is `never` and every `$on` call fails. export type { ApiRemoteForwardedEvent } from '../types.ts' @@ -54,7 +57,7 @@ export const inject = ['remote'] export async function apply(ctx: Context): Promise<() => Promise> { const disposers: Array<() => Promise> = [] try { - for (const contribution of [commandsRemote, goalsRemote]) { + for (const contribution of [commandsRemote, goalsRemote, pluginInventoryRemote]) { disposers.push(await ctx.remote.$mount(contribution)) } } catch (error) { diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 8f28b83a6e..274570dc52 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -26,6 +26,9 @@ { "path": "../../goal/goal" }, + { + "path": "../../host/plugin-inventory" + }, { "path": "../../interaction/commands" }, diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index ceb46f415d..81dec7c509 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -14,9 +14,9 @@ export { readImageFile, saveImageFile, validateImageFile } from './store.ts' /** Default maximum encoded bytes for one image. */ export const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024 /** Default maximum images in one prompt. */ -export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 10 +export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 20 /** Default maximum aggregate image bytes in one prompt. */ -export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 20 * 1024 * 1024 +export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 100 * 1024 * 1024 /** Default maximum intrinsic pixels for one image. */ export const DEFAULT_MAX_IMAGE_PIXELS = 40_000_000 diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index e196966fa4..75629d3635 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -14,6 +14,7 @@ import LocalAttachmentStore, { describe('local attachment service', () => { it('resolves every omitted admission limit explicitly', () => { const service = new LocalAttachmentStore(new Context(), {}) + expect(DEFAULT_MAX_IMAGE_BYTES).toBe(5 * 1024 * 1024) expect(service.imageLimits).toEqual({ maxImageBytes: DEFAULT_MAX_IMAGE_BYTES, maxImagesPerMessage: DEFAULT_MAX_IMAGES_PER_MESSAGE, diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index c696b41503..a5d039e3dd 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -80,6 +80,10 @@ - id: directory-picker name: '@deepseek-ai/dsh-host-directory-picker-auto' + # Read-only projection of current Loader entries for trusted client RPCs. + - id: plugin-inventory + name: '@deepseek-ai/dsh-host-plugin-inventory' + # The API gateway: the transport-agnostic dispatch face every client shape # shares. The base layer's agent-default-model service owns the default model. - id: api-gateway @@ -172,6 +176,9 @@ - id: ui-models name: '@deepseek-ai/dsh-client-ui-models' + - id: ui-plugins + name: '@deepseek-ai/dsh-client-ui-plugins' + - id: ui-conversation name: '@deepseek-ai/dsh-client-ui-conversation' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 3375fed71a..efd11b896d 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -62,6 +62,7 @@ "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-model": "workspace:^", "@deepseek-ai/dsh-client-ui-models": "workspace:^", + "@deepseek-ai/dsh-client-ui-plugins": "workspace:^", "@deepseek-ai/dsh-client-ui-permission": "workspace:^", "@deepseek-ai/dsh-client-ui-plan": "workspace:^", "@deepseek-ai/dsh-client-ui-plugin-config": "workspace:^", @@ -86,6 +87,7 @@ "@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", + "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index da30bf2ae1..11b0aa9478 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/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/client/README.md -README.md: 75abe408952ed66dcc237ce489e417f61159bcc3 -README.zh.md: 237735b6b9b0b42af2e454e231025da070f5a3e3 +README.md: 236531281c17ef982982e97caad99491584bd0b5 +README.zh.md: 73bc3e31c90a4f12c4c5f11e9fd0552601dcc7a6 diff --git a/packages/client/README.md b/packages/client/README.md index 75abe40895..236531281c 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -41,6 +41,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas. | | [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section. | | [`ui-models/`](ui-models/README.md) | Provides model-provider configuration and DeepSeek onboarding. | +| [`ui-plugins/`](ui-plugins/README.md) | Shows the current Host Loader entries in a read-only Settings section. | Each child reference owns its contract and detailed behavior. The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) and [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) own the cross-package composition and loading decisions. diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 237735b6b9..73bc3e31c9 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -41,6 +41,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 | | [`ui-settings-general/`](ui-settings-general/README.md) | 提供常规设置分区。 | | [`ui-models/`](ui-models/README.md) | 提供模型提供方配置与 DeepSeek 配置引导。 | +| [`ui-plugins/`](ui-plugins/README.md) | 在只读设置分区中展示当前 Host Loader 条目。 | 每个子文档负责自身的约定和详细行为。[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)与 [Web 客户端架构 Agent Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)负责跨包组合与加载决策。 diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index d34e8862e0..51319fc5e5 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/client/connection/README.md -README.md: a82bb55ab65df2732ad16248d2cc9aa15b60e94d -README.zh.md: cc9e4e01bb0dec278cf162ae442154d11c76aa85 +README.md: d3727df981ecbc022345def48b38fbc879e29cb2 +README.zh.md: 5e632bf6d7135bda60178f0699691aff2d5623db diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index a82bb55ab6..d3727df981 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -23,3 +23,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **History resumes an unattached session** — opening history may create the host-side agent and add latency to the first open; there is no persistence-only read path. +- **The `/api` bridge buffers each request body in memory** — `maxRequestBodyBytes` (default 160 MiB, sized for the default 100 MiB aggregate image limit after base64 expansion plus envelope headroom) is therefore also the per-request resident bound; a streaming body path would be needed to lower it without shrinking the image limits. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index cc9e4e01bb..5e632bf6d7 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -23,3 +23,4 @@ node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-r ## 已知限制与暂缓事项 - **History 会恢复未附加的会话**:打开 history 可能创建宿主侧 agent,并增加首次打开的延迟;没有仅从持久化读取的路径。 +- **`/api` 桥把每个请求体整体缓冲在内存里**:`maxRequestBodyBytes`(默认 160 MiB,按默认 100 MiB 图片总量上限经 base64 膨胀加信封余量得出)因此同时是单请求的驻留内存上界;要降低它而不缩小图片限额,需要流式请求体路径。 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 8085c7d323..b9dfb98ffe 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -979,6 +979,18 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record { const abort = new AbortController() // Client-disconnect detection MUST hang off the response, not the request: diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 11d9a2fd87..6690568393 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -6,7 +6,7 @@ import type {} from '@deepseek-ai/dsh-attachment' import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts' -import { bridge } from './http-bridge.ts' +import { bridge, DEFAULT_MAX_REQUEST_BODY_BYTES } from './http-bridge.ts' import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts' import { HostConnectionService } from './rpc-host.ts' import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts' @@ -42,8 +42,6 @@ function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): voi ) } } -/** Default carrier cap for all HTTP RPC bodies. */ -const DEFAULT_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024 /** Services required before providing Connection; API Proxy is an optional `/api` fallback. */ export const inject = ['httpServer'] diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index 109e7acd93..5bf2aa4e2e 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -164,6 +164,13 @@ describe('createFixtureApi', () => { toolsTokens: 0, messageTokens: 0, }, + imageLimits: { + maxImageBytes: 5 * 1024 * 1024, + maxImagesPerMessage: 20, + maxMessageImageBytes: 100 * 1024 * 1024, + maxImagePixels: 40_000_000, + mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], + }, } }, }) }) @@ -374,10 +381,14 @@ describe('createFixtureApi', () => { value: { systemTokens: 0, toolsTokens: 0 }, }) expect((first[8]?.payload as { value: { messageTokens: number } }).value.messageTokens).toBeGreaterThan(0) - expect(first[9]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) - expect(second[9]?.rpcId).toBe(first[9]?.rpcId) // stable rpcId across replays (host replay semantics) - expect(first[10]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) - expect(second[10]?.rpcId).toBe(first[10]?.rpcId) + expect(first[9]?.payload).toMatchObject({ + type: 'session/projection', sessionId: 'fx-alpha', key: 'imageLimits', + value: { maxImagesPerMessage: 20, maxImageBytes: 5 * 1024 * 1024 }, + }) + expect(first[10]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) + expect(second[10]?.rpcId).toBe(first[10]?.rpcId) // stable rpcId across replays (host replay semantics) + expect(first[11]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) + expect(second[11]?.rpcId).toBe(first[11]?.rpcId) }) it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => { diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index faea84bfdb..e566cbf04a 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/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/client/ui-attachment/README.md -README.md: 9fab9c23b958606030b1e87fcbfa45130c980947 -README.zh.md: 668dba11154538f52a9a87692020868c1b8a63d5 +README.md: 65db3f03b3ef174d12de75786e197da4712c4de1 +README.zh.md: 2b714e880912d6dc097ce73aca4286a674bdade5 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 9fab9c23b9..65db3f03b3 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Pure React attachment atoms (zero cordis): the composer draft-image rail (`AttachmentRail`), the chat-history image gallery (`MessageImage`/`ImageGallery`), and the original-image lightbox (`ImageLightbox`). Every string arrives through label props resolved by the owning plugin's own locale namespace, and nothing here reads application state; `@deepseek-ai/dsh-client-ui-conversation` is the current consumer, bridging its `conversation` dictionary through its `image-labels` module. +Pure React attachment atoms (zero cordis): the composer draft-image rail (`AttachmentRail`), the chat-history image gallery (`MessageImage`/`ImageGallery`), the original-image lightbox (`ImageLightbox`), and the full-page drop overlay (`DropOverlay`). Every string arrives through label props resolved by the owning plugin's own locale namespace, and nothing here reads application state; `@deepseek-ai/dsh-client-ui-conversation` is the current consumer, bridging its `conversation` dictionary through its `image-labels` module. ## Attachment rail @@ -10,7 +10,11 @@ Pure React attachment atoms (zero cordis): the composer draft-image rail (`Attac ## Message images and the lightbox -`MessageImage` renders one durable history image bounded to 240px on its longer edge, loading a session-authorized URL through the owner's `ImageLoader`; a failed load renders an explicit retry control, and a settled load answers a single click by opening `ImageLightbox` (clicks during loading are ignored). `ImageGallery` wraps a message's images in one aligned flex group (`end` for user messages, `start` for assistant messages) and renders nothing for an empty list. `ImageLightbox` is a document-level modal preview that closes on Escape, a backdrop press, or its close control, and restores focus to its opener on unmount. +`MessageImage` renders one durable history image, loading a session-authorized URL through the owner's `ImageLoader`; a failed load renders an explicit retry control, and a settled load answers a single click by opening `ImageLightbox` (clicks during loading are ignored). Sizing follows DeepSeek Chat: a message's lone image (`variant="single"`) renders at 240px on its longer edge with the displayed aspect ratio clamped to [0.25, 4] — the overflow is cropped by `object-fit: cover`, anchored to the top of very tall images and the left of very wide ones — and never upscales past its natural size; an image among several (`variant="tile"`) is a fixed 64px square. `ImageGallery` wraps a message's images in one aligned wrapping flex group (`end` for user messages, `start` for assistant messages), picks the variant from the image count, and renders nothing for an empty list. `ImageLightbox` is a document-level modal preview over the shared dialog mask (`--dsw-alias-bg-mask-1` + `--dsw-mask-blur`, painted on its own layer so the blur never touches the previewed image) that closes on Escape, a mask press, or its close control, and restores focus to its opener on unmount. + +## Drop overlay + +`DropOverlay` is the full-viewport invitation shown while a file drag is over the page: illustration, title, and a limits line while drops are accepted (`disabled` swaps the blocked illustration and hides the limits line). The layer is pointer-inert — the owner's document-level drag listeners keep the enter/leave count and decide accept/reject; the overlay only shows state. It portals to the body like the lightbox. ## Model Experience diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index 668dba1115..2b714e8809 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -纯 React 附件原子组件(零 cordis):输入框草稿图片栏(`AttachmentRail`)、聊天历史图片画廊(`MessageImage`/`ImageGallery`)与原图灯箱(`ImageLightbox`)。所有文案都由持有方插件在自己的语言命名空间中解析后经 label props 传入,此包不读取任何应用状态;当前消费者是 `@deepseek-ai/dsh-client-ui-conversation`,经其 `image-labels` 模块桥接 `conversation` 词典。 +纯 React 附件原子组件(零 cordis):输入框草稿图片栏(`AttachmentRail`)、聊天历史图片画廊(`MessageImage`/`ImageGallery`)、原图灯箱(`ImageLightbox`)与整页拖放遮罩(`DropOverlay`)。所有文案都由持有方插件在自己的语言命名空间中解析后经 label props 传入,此包不读取任何应用状态;当前消费者是 `@deepseek-ai/dsh-client-ui-conversation`,经其 `image-labels` 模块桥接 `conversation` 词典。 ## 附件栏 @@ -10,7 +10,11 @@ ## 消息图片与灯箱 -`MessageImage` 渲染一张持久化历史图片,长边收敛到 240px,经持有方的 `ImageLoader` 加载会话授权 URL;加载失败渲染显式重试按钮,加载完成后单击打开 `ImageLightbox`(加载中的点击被忽略)。`ImageGallery` 将一条消息的图片包为一个对齐的弹性分组(用户消息 `end`,助手消息 `start`),空列表不渲染。`ImageLightbox` 是文档级模态预览,按 Escape、按下遮罩或点关闭按钮均可关闭,卸载时将焦点还给打开者。 +`MessageImage` 渲染一张持久化历史图片,经持有方的 `ImageLoader` 加载会话授权 URL;加载失败渲染显式重试按钮,加载完成后单击打开 `ImageLightbox`(加载中的点击被忽略)。尺寸规则对齐 DeepSeek Chat:一条消息仅有的一张图(`variant="single"`)长边 240px、展示宽高比钳制在 [0.25, 4] 之间——超出部分由 `object-fit: cover` 裁切,特别高的图锚定顶部、特别宽的图锚定左侧——且从不放大超过原始尺寸;多图中的一张(`variant="tile"`)为固定 64px 方块。`ImageGallery` 将一条消息的图片包为一个对齐的可换行弹性分组(用户消息 `end`,助手消息 `start`),按图片数量选择 variant,空列表不渲染。`ImageLightbox` 是文档级模态预览,铺在共享的对话框遮罩上(`--dsw-alias-bg-mask-1` 加 `--dsw-mask-blur`,画在独立图层上,模糊不会波及预览图本身),按 Escape、按下遮罩或点关闭按钮均可关闭,卸载时将焦点还给打开者。 + +## 拖放遮罩 + +`DropOverlay` 是文件拖拽悬停页面时的全视口邀请层:插画、标题,接受拖放时再加一行上限说明(`disabled` 换为禁用插画并隐藏上限行)。该层不接收指针事件——持有方的 document 级拖拽监听器负责 enter/leave 计数和接受与否的判定;遮罩只呈现状态。与灯箱一样经 body portal 渲染。 ## 模型体验 diff --git a/packages/client/ui-attachment/src/DropOverlay.module.css b/packages/client/ui-attachment/src/DropOverlay.module.css new file mode 100644 index 0000000000..cacf9a7f5a --- /dev/null +++ b/packages/client/ui-attachment/src/DropOverlay.module.css @@ -0,0 +1,54 @@ +/* Full-viewport drop invitation (DeepSeek Chat DragMask). pointer-events: + none — the layer is decoration; drag events must keep hitting the page so + the owner's enter/leave count stays balanced. The frosted sheet color is + the theme's drop-mask alias (dark override lives with the theme owner). */ +.mask { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + background-color: var(--dsw-alias-bg-mask-drop); + backdrop-filter: blur(10px); + animation: fade-in 160ms ease-out; +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@media (prefers-reduced-motion: reduce) { + .mask { + animation: none; + } +} + +.wrap { + display: flex; + flex-direction: column; + align-items: center; + margin-top: -3%; + padding: 0 40px; + color: var(--dsw-alias-label-primary); + text-align: center; +} + +.illustration { + width: 115px; + height: 84px; +} + +.title { + margin-top: 16px; + font: var(--dsw-font-l-20); +} + +.desc { + margin-top: 16px; + font: var(--dsw-font-s-14); + color: var(--dsw-alias-label-tertiary); + white-space: pre-wrap; +} diff --git a/packages/client/ui-attachment/src/DropOverlay.tsx b/packages/client/ui-attachment/src/DropOverlay.tsx new file mode 100644 index 0000000000..52b6264edd --- /dev/null +++ b/packages/client/ui-attachment/src/DropOverlay.tsx @@ -0,0 +1,77 @@ +import { createPortal } from 'react-dom' +import css from './DropOverlay.module.css' + +/** Drop-overlay strings the owner resolves from its own locale namespace. */ +export interface DropOverlayLabels { + /** Headline inviting the drop, or naming why it is unavailable. */ + title: string + /** Limits line under the title; shown only while drops are accepted. */ + desc?: string | undefined +} + +/** + * Full-viewport invitation shown while a file drag is over the page + * (DeepSeek Chat's DragMask). Decoration only: `pointer-events: none` keeps + * drag targeting on the page below, so the owner's document-level listeners + * keep an accurate enter/leave count and own accept/reject. Rendered through + * a body portal for the same transformed-ancestor reason as the lightbox. + * + * @param props.disabled - drops are currently refused; renders the blocked + * illustration and drops the desc line. + * @param props.labels - resolved title and limits strings. + * @returns the overlay layer. + */ +export function DropOverlay({ disabled, labels }: { + disabled: boolean + labels: DropOverlayLabels +}) { + return createPortal( +
+
+ +
{labels.title}
+ {!disabled && labels.desc !== undefined &&
{labels.desc}
} +
+
, + document.body, + ) +} + +/** Tilted photo-and-note cards (DeepSeek Chat upload illustration). */ +const UploadIllustration = () => ( + + + + + + + + + + + + + + + + + + +) + +/** Greyed cards with a blocked badge (DeepSeek Chat disabled illustration). */ +const UploadDisabledIllustration = () => ( + + + + + + + + + + + +) diff --git a/packages/client/ui-attachment/src/ImageLightbox.module.css b/packages/client/ui-attachment/src/ImageLightbox.module.css index d6b96f9fd7..a74bd1c103 100644 --- a/packages/client/ui-attachment/src/ImageLightbox.module.css +++ b/packages/client/ui-attachment/src/ImageLightbox.module.css @@ -5,10 +5,20 @@ display: grid; place-items: center; padding: 40px; - background: color-mix(in srgb, var(--dsw-alias-label-primary) 74%, transparent); +} + +/* Same mask recipe as the Modal primitive and the settings dialog. A separate + layer, not a background on .backdrop: backdrop-filter there would blur the + previewed image and the close control along with the page. */ +.mask { + position: absolute; + inset: 0; + background: var(--dsw-alias-bg-mask-1); + backdrop-filter: var(--dsw-mask-blur); } .image { + position: relative; max-width: min(100%, 1600px); max-height: calc(100vh - 80px); object-fit: contain; @@ -21,6 +31,7 @@ position: fixed; top: 20px; right: 20px; + z-index: 1; display: grid; place-items: center; width: 36px; @@ -29,6 +40,5 @@ border-radius: 999px; background: var(--dsw-specific-input-major); color: var(--dsw-alias-label-primary); - font-size: 24px; cursor: pointer; } diff --git a/packages/client/ui-attachment/src/ImageLightbox.tsx b/packages/client/ui-attachment/src/ImageLightbox.tsx index 0207ee5a53..1f1207e026 100644 --- a/packages/client/ui-attachment/src/ImageLightbox.tsx +++ b/packages/client/ui-attachment/src/ImageLightbox.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from 'react' import { createPortal } from 'react-dom' +import { IconCloseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import css from './ImageLightbox.module.css' /** Lightbox strings the owner resolves from its own locale namespace. */ @@ -51,10 +52,12 @@ export function ImageLightbox({ src, alt, labels, onClose }: { role="dialog" aria-modal="true" aria-label={labels.dialog} - onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }} > + , document.body, ) diff --git a/packages/client/ui-attachment/src/MessageImage.module.css b/packages/client/ui-attachment/src/MessageImage.module.css index 17ac423640..e59640df92 100644 --- a/packages/client/ui-attachment/src/MessageImage.module.css +++ b/packages/client/ui-attachment/src/MessageImage.module.css @@ -1,8 +1,8 @@ .gallery { display: flex; flex-wrap: wrap; - gap: 8px; - width: min(240px, 100%); + gap: 10px; + max-width: 100%; } .gallery[data-align='end'] { @@ -29,11 +29,18 @@ cursor: zoom-in; } +.frame[data-variant='tile'] { + width: 64px; + height: 64px; + min-width: 64px; + min-height: 64px; +} + .frame img { display: block; width: 100%; height: 100%; - object-fit: contain; + object-fit: cover; } .loading, @@ -51,3 +58,12 @@ background: var(--dsw-alias-interactive-bg-hover-danger); cursor: pointer; } + +/* A failed tile keeps the 64px grid cell instead of growing to its copy. */ +.error[data-variant='tile'] { + width: 64px; + height: 64px; + padding: 4px; + overflow: hidden; + border-radius: 16px; +} diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index 15420c9569..c4de8f73a7 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -23,18 +23,38 @@ export interface MessageImageLabels { lightbox: ImageLightboxLabels } +/** Display box for a lone image (DeepSeek Chat rule): long edge 240px with + * the rendered aspect ratio clamped to [0.25, 4] — the overflow is cropped by + * `object-fit: cover` — and never upscaled past the image's natural size. The + * crop anchor keeps the top of very tall images and the left of very wide + * ones, where the informative content usually starts. */ +function singleFit(attachment: ImageAttachmentRef): { width: number; height: number; objectPosition: string } { + const natural = attachment.width / attachment.height + const ratio = Math.min(4, Math.max(0.25, natural)) + const box = ratio >= 1 ? { width: 240, height: 240 / ratio } : { width: 240 * ratio, height: 240 } + const scale = Math.min(1, attachment.width / box.width, attachment.height / box.height) + return { + width: Math.max(1, Math.round(box.width * scale)), + height: Math.max(1, Math.round(box.height * scale)), + objectPosition: natural < 0.25 ? 'center top' : natural > 4 ? 'left center' : 'center', + } +} + /** * Compact history renderer with retryable loading and click-to-open original - * preview. + * preview. A lone image renders at its `singleFit` size; an image among + * several renders as a fixed 64px square tile. * * @param props.attachment - the durable image reference to load and bound. * @param props.load - session-authorized URL loader. + * @param props.variant - `single` for a message's lone image, `tile` otherwise. * @param props.labels - resolved strings (tooltip, loading, retry, lightbox). * @returns the bounded thumbnail button, or the retry control on failure. */ -export function MessageImage({ attachment, load, labels }: { +export function MessageImage({ attachment, load, variant, labels }: { attachment: ImageAttachmentRef load: ImageLoader + variant: 'single' | 'tile' labels: MessageImageLabels }) { const [src, setSrc] = useState(null) @@ -45,10 +65,10 @@ export function MessageImage({ attachment, load, labels }: { const [attempt, setAttempt] = useState(0) const request = useCallback(() => { setAttempt(a => a + 1) }, []) const close = useCallback(() => { setOpen(false) }, []) - const size = useMemo(() => { - const scale = Math.min(1, 240 / attachment.width, 240 / attachment.height) - return { width: Math.max(1, Math.round(attachment.width * scale)), height: Math.max(1, Math.round(attachment.height * scale)) } - }, [attachment.height, attachment.width]) + const fit = useMemo( + () => (variant === 'single' ? singleFit(attachment) : undefined), + [attachment, variant], + ) useEffect(() => { let live = true @@ -59,25 +79,29 @@ export function MessageImage({ attachment, load, labels }: { }, [attachment, load, attempt]) const label = attachment.name ?? labels.image - if (error) return + if (error) return return ( <> {open && src !== null && } ) } -/** Wrapping image group shared by user and assistant history. */ +/** Wrapping image group shared by user and assistant history: a lone image + * renders large, several render as 64px square tiles (DeepSeek Chat rule). */ export function ImageGallery({ images, load, align, labels }: { images: readonly { attachment: ImageAttachmentRef }[] load: ImageLoader @@ -85,10 +109,11 @@ export function ImageGallery({ images, load, align, labels }: { labels: MessageImageLabels }) { if (images.length === 0) return null + const variant = images.length === 1 ? 'single' : 'tile' return (
{images.map((image, index) => ( - + ))}
) diff --git a/packages/client/ui-attachment/src/index.ts b/packages/client/ui-attachment/src/index.ts index 8757915fee..bef6c900a6 100644 --- a/packages/client/ui-attachment/src/index.ts +++ b/packages/client/ui-attachment/src/index.ts @@ -1,13 +1,15 @@ /** * Pure React attachment atoms (zero cordis): the composer draft-image rail, - * the chat-history image gallery, and the original-image lightbox. Owners - * resolve every string through their own locale namespace and pass it down; - * nothing here reads application state. + * the chat-history image gallery, the original-image lightbox, and the + * full-page drop overlay. Owners resolve every string through their own + * locale namespace and pass it down; nothing here reads application state. * @module @deepseek-ai/dsh-client-ui-attachment */ export { AttachmentRail } from './AttachmentRail.tsx' export type { AttachmentRailItem, AttachmentRailLabels } from './AttachmentRail.tsx' +export { DropOverlay } from './DropOverlay.tsx' +export type { DropOverlayLabels } from './DropOverlay.tsx' export { ImageLightbox } from './ImageLightbox.tsx' export type { ImageLightboxLabels } from './ImageLightbox.tsx' export { ImageGallery, MessageImage } from './MessageImage.tsx' diff --git a/packages/client/ui-attachment/tests/drop-overlay.client.spec.tsx b/packages/client/ui-attachment/tests/drop-overlay.client.spec.tsx new file mode 100644 index 0000000000..8db1bfc0f4 --- /dev/null +++ b/packages/client/ui-attachment/tests/drop-overlay.client.spec.tsx @@ -0,0 +1,38 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, render } from '@testing-library/react' +import { DropOverlay } from '../src/DropOverlay.tsx' + +afterEach(cleanup) + +describe('DropOverlay', () => { + it('portals the invitation with its title and limits desc to the body', () => { + const view = render( + , + ) + const overlay = view.getByRole('status') + expect(overlay.parentElement).toBe(document.body) + expect(overlay.textContent).toContain('图片拖动到此处即可添加') + expect(overlay.textContent).toContain('最多 20 张,每张 5MB') + }) + + it('omits the desc line when none is resolved', () => { + const view = render() + expect(view.getByRole('status').textContent).toBe('图片拖动到此处即可添加') + }) + + it('drops the desc and switches the illustration while disabled', () => { + const enabled = render( + , + ) + const enabledSvg = enabled.getByRole('status').querySelector('svg')!.innerHTML + enabled.unmount() + const disabled = render( + , + ) + const overlay = disabled.getByRole('status') + expect(overlay.textContent).toBe('当前无法添加图片') + expect(overlay.querySelector('svg')!.innerHTML).not.toBe(enabledSvg) + }) +}) diff --git a/packages/client/ui-attachment/tests/image-lightbox.client.spec.tsx b/packages/client/ui-attachment/tests/image-lightbox.client.spec.tsx index 6152fc5ec2..5959fad3d4 100644 --- a/packages/client/ui-attachment/tests/image-lightbox.client.spec.tsx +++ b/packages/client/ui-attachment/tests/image-lightbox.client.spec.tsx @@ -39,12 +39,13 @@ describe('ImageLightbox', () => { } }) - it('closes on a backdrop press but not on a press over the image', () => { + it('closes on a mask press but not on a press over the image', () => { const onClose = vi.fn() const view = render() fireEvent.mouseDown(view.getByRole('img')) expect(onClose).not.toHaveBeenCalled() - fireEvent.mouseDown(view.getByRole('dialog', { name: '原图预览' })) + const mask = document.querySelector('[aria-hidden="true"]') as HTMLElement + fireEvent.mouseDown(mask) expect(onClose).toHaveBeenCalledTimes(1) }) }) diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index 6dbf4cd746..9430bbbbef 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -29,7 +29,7 @@ const attachment = { describe('MessageImage', () => { it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => { const load = vi.fn().mockResolvedValue('blob:history') - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) expect(frame.getAttribute('style')).toContain('width: 240px') expect(frame.getAttribute('style')).toContain('height: 120px') @@ -44,7 +44,7 @@ describe('MessageImage', () => { it('ignores a click while the thumbnail is still loading', () => { const load = vi.fn(() => new Promise(() => {})) - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) expect(view.getByText('图片加载中…')).toBeTruthy() fireEvent.click(frame) @@ -54,7 +54,7 @@ describe('MessageImage', () => { it('falls back to the image label for an unnamed attachment', async () => { const { name: _named, ...unnamed } = attachment const load = vi.fn().mockResolvedValue('blob:unnamed') - const view = render() + const view = render() await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() }) expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy() }) @@ -64,7 +64,7 @@ describe('MessageImage', () => { .mockRejectedValueOnce(new Error('offline')) .mockRejectedValueOnce(new Error('still offline')) .mockResolvedValueOnce('blob:retry') - const view = render() + const view = render() const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) fireEvent.click(retry) const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' }) @@ -73,16 +73,59 @@ describe('MessageImage', () => { expect(load).toHaveBeenCalledTimes(3) }) + it('clamps extreme aspect ratios and anchors the crop toward the informative edge', async () => { + const load = vi.fn().mockResolvedValue('blob:ratio') + const tall = render( + , + ) + const tallFrame = tall.getByRole('button', { name: 'history.png,点击查看原图' }) + expect(tallFrame.getAttribute('style')).toContain('width: 60px') + expect(tallFrame.getAttribute('style')).toContain('height: 240px') + await waitFor(() => { expect(tall.getByAltText('history.png')).toBeTruthy() }) + expect(tall.getByAltText('history.png').style.objectPosition).toBe('center top') + tall.unmount() + const wide = render( + , + ) + const wideFrame = wide.getByRole('button', { name: 'history.png,点击查看原图' }) + expect(wideFrame.getAttribute('style')).toContain('width: 240px') + expect(wideFrame.getAttribute('style')).toContain('height: 60px') + await waitFor(() => { expect(wide.getByAltText('history.png')).toBeTruthy() }) + expect(wide.getByAltText('history.png').style.objectPosition).toBe('left center') + wide.unmount() + const small = render( + , + ) + const smallFrame = small.getByRole('button', { name: 'history.png,点击查看原图' }) + expect(smallFrame.getAttribute('style')).toContain('width: 100px') + expect(smallFrame.getAttribute('style')).toContain('height: 100px') + }) + + it('renders a tile at the fixed square without inline sizing', () => { + const load = vi.fn(() => new Promise(() => {})) + const view = render() + const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) + expect(frame.getAttribute('data-variant')).toBe('tile') + expect(frame.getAttribute('style')).toBeNull() + }) + + it('keeps the tile variant on the failed-load retry control', async () => { + const load = vi.fn().mockRejectedValue(new Error('offline')) + const view = render() + const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) + expect(retry.getAttribute('data-variant')).toBe('tile') + }) + it('ignores a load settling after unmount', async () => { let resolve: ((url: string) => void) | undefined const load = vi.fn(() => new Promise((r) => { resolve = r })) - const view = render() + const view = render() view.unmount() resolve?.('blob:late') await Promise.resolve() let reject: ((error: Error) => void) | undefined const failing = vi.fn(() => new Promise((_r, rej) => { reject = rej })) - const second = render() + const second = render() second.unmount() reject?.(new Error('late failure')) await Promise.resolve() @@ -100,4 +143,15 @@ describe('ImageGallery', () => { expect(view.container.querySelector('[data-align="end"]')).not.toBeNull() await waitFor(() => { expect(view.getAllByAltText('history.png')).toHaveLength(2) }) }) + + it('renders a lone image large and several images as square tiles', () => { + const load = vi.fn(() => new Promise(() => {})) + const lone = render() + expect(lone.container.querySelectorAll('[data-variant="single"]')).toHaveLength(1) + lone.unmount() + const several = render( + , + ) + expect(several.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(3) + }) }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 16c73041b2..aaf4558410 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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/client/ui-conversation/README.md -README.md: ed8f888d35693ecaa2667ea432b462f4bb3369cf -README.zh.md: e2a42ee20f6724c2a8f09908aee17abe52e6f585 +README.md: e2db456f92151c3602fce0bf40a86e4019cda1cb +README.zh.md: c5f3152006ea37848b21942d886cbfb74af15943 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index ed8f888d35..e2db456f92 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -36,6 +36,8 @@ Keyboard message submission resolves delivery from the addressed session's runni Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. +Image intake accepts paste and whole-page drop: the bar binds document-level drag listeners (the composer-bar slot is `kind: 'single'`, so at most one bar binds them) and shows the `DropOverlay` atom while a file drag is over the window — text drags pass through untouched, and a locked or busy composer shows the blocked overlay and refuses the drop. Both gestures feed one intake pre-check against the host's `imageLimits` projection (count, per-image bytes, aggregate bytes): an addition that would break a limit is refused as a whole batch with an immediate banner naming the limit, and never enters the rail. Host-side rejections that arrive anyway surface as product copy mapped from the `attachment-error` reason (`image-labels.ts` `attachmentErrorText`); reasons the user cannot act on fold into one send-failed line carrying the reason code, and non-attachment error codes keep their developer-facing message plus code. + The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop controls), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar keeps message actions inert (machine faces absent, `disabled` owner prop), while the whole dashed card opens the existing Workspace picker by pointer and the read-only textarea opens it through Enter or Space. Disabled controls release pointer events to the card, and the card contains `pointerdown` so the open picker's outside-close cannot race a reopen. The bar never swaps in a parallel tree, so the textarea DOM survives Workspace selection; strict-session control seats stay empty until a session exists. The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. The same window fold averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy renders as the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index e2a42ee20f..c5f3152006 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -36,6 +36,8 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu 逐会话 UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 +图片经粘贴与整页拖放进入:输入栏绑定 document 级拖拽监听(composer-bar slot 为 `kind: 'single'`,同一时刻至多一个 bar 绑定),文件拖拽悬停窗口时显示 `DropOverlay` 原子组件——纯文本拖拽不受影响,锁定或忙碌的 composer 显示禁用遮罩并拒绝 drop。两种手势共用一条对宿主 `imageLimits` 投影的加入预检(数量、单图字节、总字节):会突破上限的加入整批拒收,立刻弹出点名上限的横幅,完全不进入附件栏。仍然到达的宿主侧拒绝按 `attachment-error` 原因映射为产品文案(`image-labels.ts` 的 `attachmentErrorText`);用户无法解决的原因折叠为一条带原因码的发送失败文案,非附件错误码保留开发者可读的原文加错误码。 + 输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止控件之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `SlashController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 会让消息操作保持不可交互(machine face 均缺席、`disabled` owner prop),整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。禁用控件会把指针事件交给卡片,卡片也会拦下 `pointerdown`,避免已打开 picker 的外点关闭与重新打开发生竞态。它不会换入一棵平行树,因此选择 Workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。可见节点只提供轮次与步骤计数,以及 LLM(大语言模型)和工具的墙钟时间:这些是关于「屏幕上有什么」的窗口作用域事实,而非账目;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。同一次窗口折算还会把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率渲染为 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 4298328b33..0f7d8acb88 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -313,9 +313,9 @@ export function apply(ctx: Context): void { return null } catch (error: unknown) { if (error instanceof UnsupportedImageMediaTypeError) { - return t('image.unsupportedType', { - type: error.mediaType || t('image.unknownType'), - }) + // Positive copy: the supported list is fixed in imageMediaType, + // and naming it beats echoing the rejected MIME type back. + return t('image.unsupportedType') } return error instanceof Error ? error.message : String(error) } diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 4365f4c453..bb766da778 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -10,6 +10,7 @@ // turn's transcript tail. Think / tool-head-only nodes stay chrome-free. import { memo, useMemo } from 'react' +import type { ReactNode } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' @@ -48,34 +49,60 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ || interrupted === true || blocks.some(block => block.kind !== 'tool-call') if (!hasVisible) return null + const rendered: ReactNode[] = [] + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i] + if (block === undefined) continue + switch (block.kind) { + case 'text': + rendered.push( + , + ) + break + case 'reasoning': + rendered.push() + break + case 'image': { + // Consecutive image blocks share one gallery so several images tile + // into rows instead of each opening a one-image group of its own. + // Keyed by the group's FIRST block index: a streaming append that + // extends the group then only grows `images` instead of remounting + // the gallery under a shifted key. + const start = i + const group = [block] + while (i + 1 < blocks.length) { + const next = blocks[i + 1] + if (next === undefined || next.kind !== 'image') break + group.push(next) + i += 1 + } + rendered.push() + break + } + // Grouped into tool rows by ChatView; hasVisible above skips an empty shell. + case 'tool-call': + break + default: + rendered.push( + t('json.truncated', { total })} + />, + ) + } + } return (
- {blocks.map((block, i) => { - switch (block.kind) { - case 'text': return ( - - ) - case 'reasoning': return - case 'image': return - // Grouped into tool rows by ChatView; hasVisible above skips an empty shell. - case 'tool-call': return null - default: return ( - t('json.truncated', { total })} - /> - ) - } - })} + {rendered} {interrupted && {t('message.stopped')}}
diff --git a/packages/client/ui-conversation/src/client/image-labels.ts b/packages/client/ui-conversation/src/client/image-labels.ts index 493ddbbbbe..bed6f5c89a 100644 --- a/packages/client/ui-conversation/src/client/image-labels.ts +++ b/packages/client/ui-conversation/src/client/image-labels.ts @@ -3,11 +3,60 @@ * application state; owners resolve every string). */ import type { - AttachmentRailLabels, ImageLightboxLabels, MessageImageLabels, + AttachmentRailLabels, DropOverlayLabels, ImageLightboxLabels, MessageImageLabels, } from '@deepseek-ai/dsh-client-ui-attachment' +import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' import type { Translate } from '@deepseek-ai/dsh-client-ui-slots' import type { ConversationKey } from './locales.ts' +/** + * Byte count as user-facing megabytes (`10MB`, `2.5MB`). + * @param bytes - the byte count. + * @returns the rounded megabyte text. + */ +export function imageSizeText(bytes: number): string { + const mb = bytes / (1024 * 1024) + return `${Number.isInteger(mb) ? String(mb) : mb.toFixed(1)}MB` +} + +/** + * Product copy for a host attachment rejection (the `attachment-error` + * `details.reason`). User-solvable reasons name the limit and the way out; + * reasons the user cannot act on fold into one send-failed line carrying the + * reason code for a bug report. + * @param t - the conversation-namespace translate. + * @param reason - the wire `details.reason` code. + * @param limits - projected limits interpolated into count/size copy, when known. + * @returns the banner text. + */ +export function attachmentErrorText( + t: Translate, + reason: string, + limits?: ImageAttachmentLimits, +): string { + switch (reason) { + case 'MODEL_DOES_NOT_SUPPORT_IMAGES': return t('image.modelUnsupported') + case 'SUBAGENT_IMAGE_UNSUPPORTED': return t('image.subagentUnsupported') + case 'IMAGE_TOO_MANY_PIXELS': return t('image.tooManyPixels') + // Undecodable bytes or a declared type its bytes contradict: solvable by + // replacing or re-exporting the file, so it reads as a format problem. + case 'INVALID_IMAGE': + case 'IMAGE_TYPE_MISMATCH': + return t('image.unsupportedType') + case 'TOO_MANY_IMAGES': + if (limits !== undefined) return t('image.tooMany', { count: limits.maxImagesPerMessage }) + break + case 'IMAGE_TOO_LARGE': + if (limits !== undefined) return t('image.fileTooLarge', { size: imageSizeText(limits.maxImageBytes) }) + break + case 'IMAGES_TOO_LARGE': + if (limits !== undefined) return t('image.totalTooLarge', { size: imageSizeText(limits.maxMessageImageBytes) }) + break + default: break + } + return t('image.sendFailed', { reason }) +} + /** * Resolve the original-image lightbox strings. * @param t - the conversation-namespace translate. @@ -33,6 +82,25 @@ export function messageImageLabels(t: Translate): MessageImageL } } +/** + * Resolve the full-page drop overlay strings. + * @param t - the conversation-namespace translate. + * @param accepting - whether drops are currently accepted. + * @param limits - per-message limits for the desc line, when known. + * @returns the overlay title, with the limits desc while accepting. + */ +export function dropOverlayLabels( + t: Translate, + accepting: boolean, + limits?: { count: number; size: string }, +): DropOverlayLabels { + if (!accepting) return { title: t('image.dropBlocked') } + return { + title: t('image.dropTitle'), + desc: limits === undefined ? undefined : t('image.dropDesc', { count: limits.count, size: limits.size }), + } +} + /** * Resolve the composer draft-image rail strings. * @param t - the conversation-namespace translate. diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 080bb9b67b..a2c92d3002 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -25,7 +25,9 @@ export const zh = { 'input.send': '发送消息', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息', 'input.accessMode': '访问模式,当前:{name}', - 'image.dropHint': '松开以添加图片', + 'image.dropTitle': '图片拖动到此处即可添加', + 'image.dropDesc': '最多 {count} 张,每张 {size}', + 'image.dropBlocked': '当前无法添加图片', 'image.pending': '待发送图片', 'image.openOriginal': '查看原图', 'image.openOriginalLabel': '{label},点击查看原图', @@ -39,8 +41,14 @@ export const zh = { 'image.preview': '原图预览', 'image.closePreview': '关闭原图预览', 'image.serviceUnavailable': '图片读取服务不可用', - 'image.unsupportedType': '不支持的图片格式:{type}', - 'image.unknownType': '未知格式', + 'image.unsupportedType': '仅支持 PNG、JPG、WebP、GIF 格式的图片', + 'image.tooMany': '一条消息最多添加 {count} 张图片', + 'image.fileTooLarge': '单张图片不能超过 {size}', + 'image.totalTooLarge': '图片总大小超过 {size},请移除部分图片', + 'image.tooManyPixels': '图片分辨率过大,请压缩后重试', + 'image.modelUnsupported': '当前模型不支持图片,请切换支持图片的模型', + 'image.subagentUnsupported': '子智能体会话暂不支持图片', + 'image.sendFailed': '图片发送失败({reason}),请重新添加图片后再试', 'context.aria': '上下文已用 {percent}', 'context.used': '上下文已用', 'context.system': '系统提示词', @@ -184,7 +192,9 @@ export const en = { 'input.send': 'Send message', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages', 'input.accessMode': 'Access mode, current: {name}', - 'image.dropHint': 'Drop to add images', + 'image.dropTitle': 'Drag images here to add them', + 'image.dropDesc': 'Up to {count} images, {size} each', + 'image.dropBlocked': 'Images cannot be added right now', 'image.pending': 'Pending images', 'image.openOriginal': 'View original', 'image.openOriginalLabel': '{label}, click to view original', @@ -198,8 +208,14 @@ export const en = { 'image.preview': 'Original image preview', 'image.closePreview': 'Close original image preview', 'image.serviceUnavailable': 'Image loading service unavailable', - 'image.unsupportedType': 'Unsupported image format: {type}', - 'image.unknownType': 'unknown format', + 'image.unsupportedType': 'Only PNG, JPG, WebP, and GIF images are supported', + 'image.tooMany': 'A message can include up to {count} images', + 'image.fileTooLarge': 'Each image must be smaller than {size}', + 'image.totalTooLarge': 'Images exceed {size} in total; remove some and try again', + 'image.tooManyPixels': 'Image resolution is too high; compress it and try again', + 'image.modelUnsupported': 'The current model does not support images; switch to a model that does', + 'image.subagentUnsupported': 'Subagent sessions do not support images yet', + 'image.sendFailed': 'Sending images failed ({reason}); re-add them and try again', 'context.aria': '{percent} of context used', 'context.used': 'of context used', 'context.system': 'System prompt', diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 751e253e22..6a2eb5fdf4 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -115,25 +115,6 @@ background: var(--dsw-alias-state-business-primary); } -.dragActive { - border-color: var(--dsw-alias-state-business-primary); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 24%, transparent), var(--dsw-shadow-lv2); -} - -.dropHint { - position: absolute; - z-index: 2; - inset: 4px; - display: grid; - place-items: center; - border-radius: 16px; - background: color-mix(in srgb, var(--dsw-specific-input-major) 88%, var(--dsw-alias-state-business-primary)); - color: var(--dsw-alias-state-business-primary); - font-size: 14px; - font-weight: 600; - pointer-events: none; -} - .accessory { display: flex; align-items: center; diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 901bdc4682..9585677ae3 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -7,23 +7,28 @@ * (running/removed/promptError) are self-selected via useSession. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { ChangeEvent, DragEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' +import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' import { IconPlusOutline16, IconWarningOutline16, Toast, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' -import { AttachmentRail, ImageLightbox } from '@deepseek-ai/dsh-client-ui-attachment' +import { AttachmentRail, DropOverlay, ImageLightbox } from '@deepseek-ai/dsh-client-ui-attachment' import type { AttachmentRailItem } from '@deepseek-ai/dsh-client-ui-attachment' // Type-only: the `plan` projection key merge (the TodoDock posture — the // composer reads a host-computed value; the domain owns the key). import type {} from '@deepseek-ai/dsh-plan-mode/client' // Type-only: the `goal` projection key merge (hint disambiguation). import type {} from '@deepseek-ai/dsh-goal/client' +// The `imageLimits` projection key merge (intake pre-check) arrives with the +// wire types: apiproxy's sessions contract declares it, and client-runtime's +// api-remotes import already places it in every client program. import type { Translate } from '@deepseek-ai/dsh-client-ui-slots' import type { ComposerAttachment, ComposerBarProps } from '../contract/slots.ts' import { deriveDecorations } from '../input/decorations.ts' import type { DraftDecorations } from '../input/decorations.ts' -import { attachmentRailLabels, lightboxLabels } from '../image-labels.ts' +import { + attachmentErrorText, attachmentRailLabels, dropOverlayLabels, imageSizeText, lightboxLabels, +} from '../image-labels.ts' import { ContextMeter } from './ContextMeter.tsx' import { PermissionSelect } from './PermissionSelect.tsx' import css from './InputBar.module.css' @@ -80,14 +85,22 @@ export function InputBar({ setToast({ seq: toastSeq.current, text }) }, []) const dismissToast = useCallback(() => { setToast(null) }, []) + // The deployment's image-intake limits (absent while no attachment service + // is composed — the pre-check below then defers entirely to the host). + const imageLimits = useProjection('imageLimits') // Prompt failures are ordinary failures (no create/attach transaction exists // anymore): the toast announces promptError, the draft stays in the machine, // and the user resubmits. A remount over a session whose machine still holds // an unresolved promptError deliberately re-announces it once — the failure - // is still pending, and a transient banner is its only surface. + // is still pending, and a transient banner is its only surface. Attachment + // rejections show product copy keyed by the wire reason; other codes are + // developer-facing and keep the raw message plus code. useEffect(() => { - if (promptError !== null) showToast(`${promptError.error.message} (${promptError.error.code})`) - }, [promptError, showToast]) + if (promptError === null) return + showToast(promptError.error.code === 'attachment-error' + ? attachmentErrorText(t, promptError.error.details.reason, imageLimits) + : `${promptError.error.message} (${promptError.error.code})`) + }, [promptError, showToast, t, imageLimits]) const inputRef = useRef(null) const cardRef = useRef(null) const dragDepthRef = useRef(0) @@ -384,10 +397,7 @@ export function InputBar({ .filter(item => item.kind === 'file') .map(item => item.getAsFile()) .filter((file): file is File => file !== null) - if (files.length > 0 && addImages !== undefined) { - const rejected = addImages(files) - if (rejected !== null) showToast(rejected) - } + if (files.length > 0) intakeImages(files) const text = e.clipboardData.getData('text/plain') if (text === '') { if (files.length > 0) e.preventDefault() @@ -406,38 +416,94 @@ export function InputBar({ keyboard.track(keyboard.snapshot.draft, caret) } - const onDragEnter = (event: DragEvent): void => { - if (!event.dataTransfer.types.includes('Files')) return - event.preventDefault() - if (locked || machineBusy || addImages === undefined) return - dragDepthRef.current += 1 - setDragActive(true) - } + // Intake pre-check (DeepSeek Chat semantics): an addition that would break + // a projected limit is refused as a whole batch, announced immediately, and + // never enters the rail — no more submit-time failure rolling the rail + // back. The host enforces the same limits at submit for callers that bypass + // this composer. + const intakeImages = useCallback((files: readonly File[]): void => { + if (addImages === undefined || files.length === 0) return + const rejected = ((): string | null => { + if (imageLimits !== undefined) { + // Format precedes limits (DeepSeek Chat's filter order): a batch with + // a non-image must announce the format problem, not a count or size + // it could never pass anyway — addImages rejects it authoritatively. + if (files.some(file => !(imageLimits.mediaTypes as readonly string[]).includes(file.type))) { + return addImages(files) + } + if (attachments.length + files.length > imageLimits.maxImagesPerMessage) { + return t('image.tooMany', { count: imageLimits.maxImagesPerMessage }) + } + if (files.some(file => file.size > imageLimits.maxImageBytes)) { + return t('image.fileTooLarge', { size: imageSizeText(imageLimits.maxImageBytes) }) + } + const total = attachments.reduce((sum, attachment) => sum + attachment.file.size, 0) + + files.reduce((sum, file) => sum + file.size, 0) + if (total > imageLimits.maxMessageImageBytes) { + return t('image.totalTooLarge', { size: imageSizeText(imageLimits.maxMessageImageBytes) }) + } + } + return addImages(files) + })() + if (rejected !== null) showToast(rejected) + }, [addImages, attachments, imageLimits, showToast, t]) - const onDragOver = (event: DragEvent): void => { - if (!event.dataTransfer.types.includes('Files')) return - event.preventDefault() - event.dataTransfer.dropEffect = locked || machineBusy || addImages === undefined ? 'none' : 'copy' - } - - const onDragLeave = (event: DragEvent): void => { - if (!event.dataTransfer.types.includes('Files') || locked || machineBusy) return - dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) - if (dragDepthRef.current === 0) setDragActive(false) - } - - const onDrop = (event: DragEvent): void => { - if (!event.dataTransfer.types.includes('Files')) return - event.preventDefault() - dragDepthRef.current = 0 - setDragActive(false) - if (locked || machineBusy || addImages === undefined) return - const dropped = [...event.dataTransfer.files] - if (dropped.length > 0) { - const rejected = addImages(dropped) - if (rejected !== null) showToast(rejected) + // Whole-page file-drop intake (DeepSeek Chat behavior): the listeners live + // on the document so a drop anywhere over the window adds images, not only + // over the composer card. Safe as document-level state: the composer-bar + // slot is `kind: 'single'`, so at most one bar is mounted to bind these. + // Text drags carry no 'Files' type and pass through untouched, keeping the + // native drop-text-into-textarea path. The overlay layer itself is + // pointer-inert, so it never disturbs the enter/leave count. + const canAcceptDrop = !locked && !machineBusy && addImages !== undefined + useEffect(() => { + const hasFiles = (event: globalThis.DragEvent): boolean => + event.dataTransfer?.types.includes('Files') ?? false + const reset = (): void => { + dragDepthRef.current = 0 + setDragActive(false) } - } + const onDragEnter = (event: globalThis.DragEvent): void => { + if (!hasFiles(event)) return + event.preventDefault() + dragDepthRef.current += 1 + setDragActive(true) + } + const onDragOver = (event: globalThis.DragEvent): void => { + if (!hasFiles(event) || event.dataTransfer === null) return + event.preventDefault() + event.dataTransfer.dropEffect = canAcceptDrop ? 'copy' : 'none' + } + const onDragLeave = (event: globalThis.DragEvent): void => { + if (!hasFiles(event)) return + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) + if (dragDepthRef.current === 0) setDragActive(false) + // Leaving through the viewport edge does not balance the count on every + // engine; a page-root leave at the border means the drag left the window. + const leavingViewport = event.clientX <= 0 || event.clientY <= 0 + || event.clientX >= window.innerWidth || event.clientY >= window.innerHeight + if ((event.target === document.documentElement || event.target === document.body) && leavingViewport) reset() + } + const onDrop = (event: globalThis.DragEvent): void => { + if (!hasFiles(event)) return + event.preventDefault() + reset() + if (!canAcceptDrop) return + intakeImages([...(event.dataTransfer?.files ?? [])]) + } + document.addEventListener('dragenter', onDragEnter) + document.addEventListener('dragover', onDragOver) + document.addEventListener('dragleave', onDragLeave) + document.addEventListener('drop', onDrop) + window.addEventListener('dragend', reset) + return () => { + document.removeEventListener('dragenter', onDragEnter) + document.removeEventListener('dragover', onDragOver) + document.removeEventListener('dragleave', onDragLeave) + document.removeEventListener('drop', onDrop) + window.removeEventListener('dragend', reset) + } + }, [canAcceptDrop, intakeImages]) const closePreview = useCallback(() => { setPreview(null) }, []) @@ -573,6 +639,15 @@ export function InputBar({ return (
+ {dragActive && ( + + )} {toast !== null && ( { e.stopPropagation() } : undefined} - onDragEnter={onDragEnter} - onDragOver={onDragOver} - onDragLeave={onDragLeave} - onDrop={onDrop} > - {dragActive &&
{t('image.dropHint')}
} {overlay !== undefined &&
{overlay}
} {accessory !== undefined &&
{accessory}
} {railItems.length > 0 && ( diff --git a/packages/client/ui-conversation/tests/image-labels.client.spec.tsx b/packages/client/ui-conversation/tests/image-labels.client.spec.tsx index 6aa5810e38..bec1fa8ebe 100644 --- a/packages/client/ui-conversation/tests/image-labels.client.spec.tsx +++ b/packages/client/ui-conversation/tests/image-labels.client.spec.tsx @@ -9,6 +9,7 @@ import { AttachmentId } from '@deepseek-ai/dsh-attachment' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' +import { attachmentErrorText, imageSizeText } from '../src/client/image-labels.ts' import { en, zh } from '../src/client/locales.ts' afterEach(cleanup) @@ -25,6 +26,40 @@ const attachment = { name: 'history.png', } +describe('attachment rejection copy', () => { + const limits = { + maxImageBytes: 5 * 1024 * 1024, + maxImagesPerMessage: 20, + maxMessageImageBytes: 100 * 1024 * 1024, + maxImagePixels: 40_000_000, + mediaTypes: ['image/png'] as const, + } + + it('renders megabytes without a trailing fraction unless one exists', () => { + expect(imageSizeText(10 * 1024 * 1024)).toBe('10MB') + expect(imageSizeText(2.5 * 1024 * 1024)).toBe('2.5MB') + }) + + it('maps user-solvable reasons to limit-naming copy', () => { + expect(attachmentErrorText(t, 'MODEL_DOES_NOT_SUPPORT_IMAGES')).toBe('当前模型不支持图片,请切换支持图片的模型') + expect(attachmentErrorText(t, 'SUBAGENT_IMAGE_UNSUPPORTED')).toBe('子智能体会话暂不支持图片') + expect(attachmentErrorText(t, 'IMAGE_TOO_MANY_PIXELS')).toBe('图片分辨率过大,请压缩后重试') + expect(attachmentErrorText(t, 'INVALID_IMAGE')).toBe('仅支持 PNG、JPG、WebP、GIF 格式的图片') + expect(attachmentErrorText(t, 'IMAGE_TYPE_MISMATCH')).toBe('仅支持 PNG、JPG、WebP、GIF 格式的图片') + expect(attachmentErrorText(t, 'TOO_MANY_IMAGES', limits)).toBe('一条消息最多添加 20 张图片') + expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE', limits)).toBe('单张图片不能超过 5MB') + expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE', limits)).toBe('图片总大小超过 100MB,请移除部分图片') + expect(attachmentErrorText(enT, 'TOO_MANY_IMAGES', limits)).toBe('A message can include up to 20 images') + }) + + it('folds unknown reasons and limit reasons without projected limits into the send-failed line', () => { + expect(attachmentErrorText(t, 'INVALID_IMAGE_BASE64')).toBe('图片发送失败(INVALID_IMAGE_BASE64),请重新添加图片后再试') + expect(attachmentErrorText(t, 'TOO_MANY_IMAGES')).toBe('图片发送失败(TOO_MANY_IMAGES),请重新添加图片后再试') + expect(attachmentErrorText(t, 'IMAGE_TOO_LARGE')).toBe('图片发送失败(IMAGE_TOO_LARGE),请重新添加图片后再试') + expect(attachmentErrorText(t, 'IMAGES_TOO_LARGE')).toBe('图片发送失败(IMAGES_TOO_LARGE),请重新添加图片后再试') + }) +}) + describe('assistant images through the label bridge', () => { it('resolves zh dictionary strings and opens the lightbox on a single click', async () => { const view = render( @@ -60,6 +95,27 @@ describe('assistant images through the label bridge', () => { expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy() }) + it('merges consecutive image blocks into one tiled gallery, split by text', async () => { + const view = render( + Promise.resolve('blob:grouped')} + />, + ) + await view.findAllByAltText('history.png') + const galleries = view.container.querySelectorAll('[data-align="start"]') + expect(galleries).toHaveLength(2) + expect(galleries[0]?.querySelectorAll('[data-variant="tile"]')).toHaveLength(2) + expect(galleries[1]?.querySelectorAll('[data-variant="single"]')).toHaveLength(1) + }) + it('keeps assistant images at their original position between text blocks', async () => { const view = render( permissions?: { options: { value: string; name: string; description?: string }[]; currentValue: string } + /** The `imageLimits` projection value (absent = no attachment service). */ + imageLimits?: { + maxImageBytes: number + maxImagesPerMessage: number + maxMessageImageBytes: number + maxImagePixels: number + mediaTypes: readonly ('image/png' | 'image/jpeg' | 'image/webp' | 'image/gif')[] + } draft?: string running?: boolean subagent?: Exclude @@ -146,7 +154,9 @@ function bench(over?: BenchOptions) { baselinesReady: true, recentWorkspaceId: undefined, })), useProjection: ((key: string, selector?: (v: unknown) => unknown) => - (selector ?? (v => v))(key === 'permissions' ? over?.permissions : key === 'plan' ? over?.plan : undefined)), + (selector ?? (v => v))(key === 'permissions' + ? over?.permissions + : key === 'plan' ? over?.plan : key === 'imageLimits' ? over?.imageLimits : undefined)), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, @@ -212,18 +222,147 @@ describe('image draft rail', () => { expect(shell.snapshot.draft).toBe('同时粘贴的文字') }) - it('accepts file drops and prevents browser navigation', () => { + it('accepts a drop anywhere on the page under the full-page overlay', () => { const addImages = vi.fn(() => null) const { view } = bench({ addImages }) - const card = view.container.querySelector('[class*="card"]')! const image = new File([Uint8Array.of(1)], 'dropped.png', { type: 'image/png' }) const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' } - expect(fireEvent.dragEnter(card, { dataTransfer })).toBe(false) - expect(view.getByRole('status').textContent).toContain('松开以添加图片') - expect(fireEvent.dragOver(card, { dataTransfer })).toBe(false) + // The drag never touches the composer card: the listeners are page-wide. + expect(fireEvent.dragEnter(document.body, { dataTransfer })).toBe(false) + expect(view.getByRole('status').textContent).toContain('图片拖动到此处即可添加') + expect(fireEvent.dragOver(document.body, { dataTransfer })).toBe(false) expect(dataTransfer.dropEffect).toBe('copy') - expect(fireEvent.drop(card, { dataTransfer })).toBe(false) + expect(fireEvent.drop(document.body, { dataTransfer })).toBe(false) expect(addImages).toHaveBeenCalledWith([image]) + expect(view.queryByRole('status')).toBeNull() + }) + + it('keeps text drags native and hides the overlay when the drag leaves or ends', () => { + const addImages = vi.fn(() => null) + const { view } = bench({ addImages }) + // A text drag carries no Files type: no overlay, native behavior stays. + fireEvent.dragEnter(document.body, { dataTransfer: { types: ['text/plain'], files: [], dropEffect: 'none' } }) + expect(view.queryByRole('status')).toBeNull() + const dataTransfer = { types: ['Files'], files: [], dropEffect: 'none' } + fireEvent.dragEnter(document.body, { dataTransfer }) + expect(view.getByRole('status')).toBeTruthy() + fireEvent.dragLeave(document.body, { dataTransfer }) + expect(view.queryByRole('status')).toBeNull() + // An aborted drag (Escape) fires dragend without a balancing leave. + fireEvent.dragEnter(document.body, { dataTransfer }) + fireEvent.dragEnter(document.querySelector('textarea')!, { dataTransfer }) + expect(view.getByRole('status')).toBeTruthy() + fireEvent.dragEnd(window, { dataTransfer }) + expect(view.queryByRole('status')).toBeNull() + expect(addImages).not.toHaveBeenCalled() + }) + + it('pre-checks projected limits at intake: whole-batch refusal with product copy, none added', () => { + const limits = { + maxImageBytes: 1024 * 1024, + maxImagesPerMessage: 2, + maxMessageImageBytes: 2 * 1024 * 1024, + maxImagePixels: 40_000_000, + mediaTypes: ['image/png'] as const, + } + const png = (bytes: number, name: string) => new File([new ArrayBuffer(bytes)], name, { type: 'image/png' }) + const drop = (files: File[]) => { + fireEvent.drop(document.body, { dataTransfer: { types: ['Files'], files, dropEffect: 'none' } }) + } + // Count: three at once over a two-image limit → the whole batch refused. + const overCount = bench({ addImages: vi.fn(() => null), imageLimits: limits }) + drop([png(8, 'a.png'), png(8, 'b.png'), png(8, 'c.png')]) + expect(overCount.view.getByRole('alert').textContent).toContain('一条消息最多添加 2 张图片') + expect(overCount.props.addImages).not.toHaveBeenCalled() + cleanup() + // Per-file bytes. + const overFile = bench({ addImages: vi.fn(() => null), imageLimits: limits }) + drop([png(1024 * 1024 + 1, 'big.png')]) + expect(overFile.view.getByRole('alert').textContent).toContain('单张图片不能超过 1MB') + expect(overFile.props.addImages).not.toHaveBeenCalled() + cleanup() + // Aggregate bytes across the existing rail plus the new batch. + const held = new File([new ArrayBuffer(1024 * 1024 * 1.5)], 'held.png', { type: 'image/png' }) + const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file: held, previewUrl: 'blob:held' } + const overTotal = bench({ addImages: vi.fn(() => null), imageLimits: limits, attachments: [attachment] }) + drop([png(1024 * 1024, 'more.png')]) + expect(overTotal.view.getByRole('alert').textContent).toContain('图片总大小超过 2MB') + expect(overTotal.props.addImages).not.toHaveBeenCalled() + cleanup() + // Within every limit: the batch passes through to addImages. + const within = bench({ addImages: vi.fn(() => null), imageLimits: limits }) + const fits = png(16, 'fits.png') + drop([fits]) + expect(within.props.addImages).toHaveBeenCalledWith([fits]) + expect(within.view.queryByRole('alert')).toBeNull() + }) + + it('announces the format problem before any limit when the batch holds a non-image', () => { + const addImages = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片') + const { view } = bench({ + addImages, + imageLimits: { + maxImageBytes: 8, + maxImagesPerMessage: 1, + maxMessageImageBytes: 8, + maxImagePixels: 40_000_000, + mediaTypes: ['image/png'] as const, + }, + }) + // Oversized AND over-count AND wrong type: the format rejection wins. + const files = [ + new File([new ArrayBuffer(64)], 'a.pdf', { type: 'application/pdf' }), + new File([new ArrayBuffer(64)], 'b.pdf', { type: 'application/pdf' }), + ] + fireEvent.drop(document.body, { dataTransfer: { types: ['Files'], files, dropEffect: 'none' } }) + expect(addImages).toHaveBeenCalledWith(files) + expect(view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片') + }) + + it('shows the projected limits in the drop overlay desc line', () => { + const { view } = bench({ + addImages: vi.fn(() => null), + imageLimits: { + maxImageBytes: 5 * 1024 * 1024, + maxImagesPerMessage: 20, + maxMessageImageBytes: 100 * 1024 * 1024, + maxImagePixels: 40_000_000, + mediaTypes: ['image/png'] as const, + }, + }) + fireEvent.dragEnter(document.body, { dataTransfer: { types: ['Files'], files: [], dropEffect: 'none' } }) + expect(view.getByRole('status').textContent).toContain('最多 20 张,每张 5MB') + }) + + it('announces server attachment rejections as product copy, other codes as developer text', () => { + const attachmentError = (reason: string): ConversationSnapshot['promptError'] => ({ + op: 'send', + error: { code: 'attachment-error', message: 'raw wire text', details: { reason } }, + }) + const model = bench({ promptError: attachmentError('MODEL_DOES_NOT_SUPPORT_IMAGES') }) + expect(model.view.getByRole('alert').textContent).toContain('当前模型不支持图片,请切换支持图片的模型') + cleanup() + const unknown = bench({ promptError: attachmentError('ATTACHMENT_NOT_REFERENCED') }) + expect(unknown.view.getByRole('alert').textContent).toContain('图片发送失败(ATTACHMENT_NOT_REFERENCED)') + cleanup() + const other = bench({ + promptError: { op: 'send', error: { code: 'internal', message: 'boom', details: {} } }, + }) + expect(other.view.getByRole('alert').textContent).toContain('boom (internal)') + }) + + it('shows the blocked overlay and refuses the drop while the composer is locked', () => { + const addImages = vi.fn(() => null) + const { view } = bench({ addImages, inert: true }) + const image = new File([Uint8Array.of(1)], 'dropped.png', { type: 'image/png' }) + const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'copy' } + fireEvent.dragEnter(document.body, { dataTransfer }) + expect(view.getByRole('status').textContent).toContain('当前无法添加图片') + fireEvent.dragOver(document.body, { dataTransfer }) + expect(dataTransfer.dropEffect).toBe('none') + fireEvent.drop(document.body, { dataTransfer }) + expect(addImages).not.toHaveBeenCalled() + expect(view.queryByRole('status')).toBeNull() }) it('sends an image-only draft and removes its thumbnail', () => { @@ -250,7 +389,7 @@ describe('image draft rail', () => { it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => { vi.useFakeTimers() try { - const addImages = vi.fn(() => '不支持的图片格式:text/plain') + const addImages = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片') const { view, textarea } = bench({ addImages }) const paste = () => { fireEvent.paste(textarea, { @@ -261,12 +400,12 @@ describe('image draft rail', () => { }) } paste() - expect(view.getByRole('alert').textContent).toContain('不支持的图片格式:text/plain') + expect(view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片') act(() => { vi.advanceTimersByTime(4000) }) expect(view.queryByRole('alert')).toBeNull() // The identical rejection re-announces: the toast is keyed per show. paste() - expect(view.getByRole('alert').textContent).toContain('不支持的图片格式:text/plain') + expect(view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片') } finally { vi.useRealTimers() } diff --git a/packages/client/ui-plugins/README.i18n.yaml b/packages/client/ui-plugins/README.i18n.yaml new file mode 100644 index 0000000000..62085c3d6b --- /dev/null +++ b/packages/client/ui-plugins/README.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 packages/client/ui-plugins/README.md +README.md: bb487d5e2cbd34406d83867997ede4d70b190d70 +README.zh.md: 48a11911509ea260aa9727d55c0b4df6efbfb1c9 diff --git a/packages/client/ui-plugins/README.md b/packages/client/ui-plugins/README.md new file mode 100644 index 0000000000..bb487d5e2c --- /dev/null +++ b/packages/client/ui-plugins/README.md @@ -0,0 +1,20 @@ +# @deepseek-ai/dsh-client-ui-plugins + +English | [中文](README.zh.md) + +Read-only Plugins section for Web Settings. The browser plugin registers one localized `settings.section` contribution with id `plugin-inventory`, after Models, and lets the Settings shell supply its ordinary fallback icon. It performs no Remote read during plugin activation; mounting the section lazily calls `ctx.remote.pluginInventory.list()` through [`api-remotes`](../../api/remotes/README.md). + +The page renders a searchable two-column catalog of compact disclosure cards. Each collapsed card uses the local Loader id as its title, a colored root-Fiber status dot, and a small effective-enablement tag. Expanding one card reveals its Loader-tree entry value without a redundant field label, followed by the effective configuration and Cordis status. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details. The registration uses `ctx.slots.inject()`, so it follows late Settings declaration, redeclaration, locale changes, and teardown without owning another global store. + +## Model Experience + +None, as this package only visualizes a Host-owned deployment snapshot in browser Settings and registers nothing model-facing. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **One snapshot per mount or retry** — the page does not subscribe to Loader changes or automatically refetch after reconnect; reopening the section obtains a new snapshot. +- **Read-only Loader view** — local search does not add provenance, current-browser activation diagnosis, grouping by source, or plugin mutation controls. diff --git a/packages/client/ui-plugins/README.zh.md b/packages/client/ui-plugins/README.zh.md new file mode 100644 index 0000000000..48a1191150 --- /dev/null +++ b/packages/client/ui-plugins/README.zh.md @@ -0,0 +1,20 @@ +# @deepseek-ai/dsh-client-ui-plugins + +[English](README.md) | 中文 + +Web 设置中的只读“插件”分区。浏览器插件在“模型”之后注册一个 id 为 `plugin-inventory` 的本地化 `settings.section` 贡献,并由 Settings shell 提供常规的回退图标。插件激活期间不会读取 Remote;挂载该分区时,组件才通过 [`api-remotes`](../../api/remotes/README.md) 懒调用 `ctx.remote.pluginInventory.list()`。 + +页面以可搜索的双列紧凑折叠卡片展示清单。每张收起的卡片使用 Loader 本地 id 作为标题,以彩色圆点表示根 Fiber 状态,以小标签表示有效启停状态。展开卡片后会直接展示 Loader 树条目值,不附加重复的字段标题,并列出有效配置状态与 Cordis 状态。加载、空结果、无匹配结果与通用失败状态只属于已挂载组件;读取失败后可以重试,且不会暴露传输细节。注册使用 `ctx.slots.inject()`,因此能跟随 Settings 的延迟声明、重新声明、本地化变化与 teardown,而不拥有另一份全局 store。 + +## 模型体验 + +无,因为本包只在浏览器设置中展示 Host 拥有的部署快照,不注册任何模型接口。 + +#### KV Cache 影响 + +无;本包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **每次挂载或重试只读取一份快照** —— 页面不订阅 Loader 变化,也不会在重连后自动重新读取;重新打开分区会取得新快照。 +- **只读 Loader 视图** —— 本地搜索不会额外引入来源、按来源分组、当前浏览器激活诊断或插件修改控件。 diff --git a/packages/client/ui-plugins/package.json b/packages/client/ui-plugins/package.json new file mode 100644 index 0000000000..07fb9d162c --- /dev/null +++ b/packages/client/ui-plugins/package.json @@ -0,0 +1,80 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-plugins", + "description": "Read-only Cordis Loader plugin inventory in Web settings", + "version": "0.0.1-rc.2", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-plugins" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-locale" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@testing-library/react": "^16.1.0", + "@types/react": "~18.3.1", + "@deepseek-ai/cordis": "workspace:^", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css b/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css new file mode 100644 index 0000000000..9429b60bb5 --- /dev/null +++ b/packages/client/ui-plugins/src/client/PluginSettingsSection.module.css @@ -0,0 +1,286 @@ +.section { + display: flex; + flex-direction: column; + gap: 14px; + width: 100%; + max-width: 760px; + color: var(--dsw-alias-label-primary); +} + +.heading h2, +.catalogHeading h3, +.status, +.failure p { + margin: 0; +} + +.heading h2 { + font-size: 16px; + line-height: 24px; + font-weight: 600; +} + +.status, +.failure { + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-tertiary); +} + +.failure { + display: flex; + align-items: center; + gap: 10px; + color: var(--dsw-alias-state-error-primary); +} + +.failure button { + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 6px; + padding: 4px 10px; + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + cursor: pointer; +} + +.catalog { + display: flex; + flex-direction: column; + gap: 12px; +} + +.search { + position: relative; + display: flex; + align-items: center; + width: 100%; + color: var(--dsw-alias-label-tertiary); +} + +.search > svg { + position: absolute; + left: 12px; + pointer-events: none; +} + +.search input { + width: 100%; + height: 36px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + padding: 0 34px 0 36px; + outline: none; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 13px; +} + +.search input::placeholder { + color: var(--dsw-alias-label-tertiary); +} + +.search input:focus-visible { + border-color: var(--dsw-alias-state-business-primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 18%, transparent); +} + +.catalogHeading { + display: flex; + align-items: baseline; + gap: 7px; + padding: 0 2px; +} + +.catalogHeading h3 { + font-size: 13px; + line-height: 20px; + font-weight: 600; +} + +.catalogHeading span { + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); + font-variant-numeric: tabular-nums; +} + +.cards { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.card { + min-width: 0; + overflow: hidden; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 10px; + background: var(--dsw-alias-bg-layer-3); +} + +.card[data-open='true'] { + border-color: var(--dsw-alias-border-l1); + box-shadow: var(--dsw-shadow-lv1); +} + +.cardContent { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + min-height: 52px; + border: 0; + padding: 12px 14px; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.cardContent:hover, +.card[data-open='true'] > .cardContent { + background: var(--dsw-alias-interactive-bg-hover); +} + +.cardContent:focus-visible { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: -2px; +} + +.cardTitle { + min-width: 0; + overflow: hidden; + font-size: 14px; + line-height: 20px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cardTrailing { + display: inline-flex; + flex: none; + align-items: center; + gap: 7px; + color: var(--dsw-alias-label-tertiary); +} + +.statusDot { + display: inline-block; + width: 7px; + height: 7px; + flex: none; + border-radius: 999px; + background: var(--dsw-alias-label-tertiary); +} + +.statusDot[data-phase='active'] { + background: var(--dsw-alias-state-success-primary); +} + +.statusDot[data-phase='failed'] { + background: var(--dsw-alias-state-error-primary); +} + +.statusDot[data-phase='loading'] { + background: var(--dsw-alias-state-business-primary); +} + +.configTag { + display: inline-flex; + align-items: center; + min-height: 20px; + border-radius: 5px; + padding: 1px 6px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 16px; + white-space: nowrap; +} + +.configTag[data-enabled='true'] { + background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); + color: var(--dsw-alias-state-success-primary); +} + +.chevron { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.card[data-open='true'] .chevron { + transform: rotate(180deg); +} + +.cardDetails { + border-top: 1px solid var(--dsw-alias-border-l2); + padding: 10px 14px 12px; + background: var(--dsw-alias-bg-module-platform); +} + +.entryValue { + display: block; + overflow-wrap: anywhere; + color: var(--dsw-alias-label-primary); + font-family: var(--ds-font-family-code); + font-size: 12px; + line-height: 18px; +} + +.details { + display: grid; + grid-template-columns: 76px minmax(0, 1fr); + gap: 6px 10px; + margin: 8px 0 0; +} + +.details div { + display: contents; +} + +.details dt { + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 17px; +} + +.details dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 17px; +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; +} + +@media (prefers-reduced-motion: no-preference) { + .chevron { + transition: transform 140ms var(--ds-ease-in-out); + } +} + +@media (max-width: 680px) { + .cards { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx b/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx new file mode 100644 index 0000000000..87d6486000 --- /dev/null +++ b/packages/client/ui-plugins/src/client/PluginSettingsSection.tsx @@ -0,0 +1,195 @@ +import { useEffect, useId, useMemo, useState, type ReactNode } from 'react' +import type { PluginInventorySnapshot } from '@deepseek-ai/dsh-api-remotes/client' +import { + IconChevronDownOutline14, + IconSearchOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PluginsKey } from './locales.ts' +import css from './PluginSettingsSection.module.css' + +/** Registration-side Remote face used by the section. */ +export interface PluginSettingsSectionInjected { + /** Read a current Host inventory snapshot. */ + list: () => Promise +} + +type PluginInventoryEntry = PluginInventorySnapshot['entries'][number] +type PluginFiberPhase = PluginInventoryEntry['fiberPhase'] + +/** Full component props assembled by the Settings slot renderer. */ +export type PluginSettingsSectionProps = + PropsRuntime<'settings.section'> + & PropsLocale<'settings.plugins'> + & InjectFace + +type ViewState = + | { readonly status: 'loading' } + | { readonly status: 'error' } + | { readonly status: 'ready'; readonly snapshot: PluginInventorySnapshot } + +const PHASE_KEYS = { + pending: 'pending', + loading: 'loadingPhase', + active: 'active', + failed: 'failed', + unloading: 'unloading', +} satisfies Record, PluginsKey> + +/** Localized accessible label for one root Fiber phase. */ +function phaseLabel( + phase: PluginFiberPhase, + t: PluginSettingsSectionProps['t'], +): string { + return phase === null ? t('unobserved') : t(PHASE_KEYS[phase]) +} + +/** Compact a module specifier without guessing whether its Loader id was generated. */ +function moduleShortName(moduleName: string): string { + const unscoped = moduleName.startsWith('@') ? moduleName.slice(moduleName.indexOf('/') + 1) : moduleName + return unscoped + .replace(/^cordis:/, '') + .replace(/^cordis-plugin-/, '') + .replace(/^dsh-(?:host-|client-)?/, '') +} + +/** Whether an inventory row matches the local catalog query. */ +function matches(entry: PluginInventoryEntry, normalizedQuery: string): boolean { + if (normalizedQuery.length === 0) return true + return [entry.moduleName, entry.entryId] + .some(value => value.toLocaleLowerCase().includes(normalizedQuery)) +} + +/** Render the read-only current Loader inventory. */ +export function PluginSettingsSection({ list, t }: PluginSettingsSectionProps): ReactNode { + const titleId = useId() + const [request, setRequest] = useState(0) + const [query, setQuery] = useState('') + const [expanded, setExpanded] = useState(null) + const [state, setState] = useState({ status: 'loading' }) + + useEffect(() => { + let current = true + void Promise.resolve().then(() => list()).then( + (snapshot) => { if (current) setState({ status: 'ready', snapshot }) }, + () => { if (current) setState({ status: 'error' }) }, + ) + return () => { current = false } + }, [list, request]) + + const normalizedQuery = query.trim().toLocaleLowerCase() + const filteredEntries = useMemo( + () => state.status === 'ready' + ? state.snapshot.entries.filter(entry => matches(entry, normalizedQuery)) + : [], + [normalizedQuery, state], + ) + + useEffect(() => { + if (expanded !== null && !filteredEntries.some(entry => entry.entryId === expanded)) { + setExpanded(null) + } + }, [expanded, filteredEntries]) + + const retry = (): void => { + setState({ status: 'loading' }) + setRequest(value => value + 1) + } + + return ( +
+
+

{t('title')}

+
+ {state.status === 'loading' ?

{t('loading')}

: null} + {state.status === 'error' ? ( +
+

{t('error')}

+ +
+ ) : null} + {state.status === 'ready' ? ( +
+ +
+

{t('catalog')}

+ {filteredEntries.length} +
+ {state.snapshot.entries.length === 0 ?

{t('empty')}

: null} + {state.snapshot.entries.length > 0 && filteredEntries.length === 0 + ?

{t('emptySearch')}

+ : null} + {filteredEntries.length > 0 ? ( +
    + {filteredEntries.map((entry) => { + const status = phaseLabel(entry.fiberPhase, t) + const title = moduleShortName(entry.moduleName) + const open = expanded === entry.entryId + const detailId = `${titleId}-details-${encodeURIComponent(entry.entryId)}` + return ( +
  • + + {open ? ( +
    + {entry.entryId} +
    +
    +
    {t('configuration')}
    +
    {t(entry.enabled ? 'enabledTag' : 'disabledTag')}
    +
    +
    +
    {t('cordis')}
    +
    {status}
    +
    +
    +
    + ) : null} +
  • + ) + })} +
+ ) : null} +
+ ) : null} +
+ ) +} diff --git a/packages/client/ui-plugins/src/client/index.ts b/packages/client/ui-plugins/src/client/index.ts new file mode 100644 index 0000000000..ccf12ab989 --- /dev/null +++ b/packages/client/ui-plugins/src/client/index.ts @@ -0,0 +1,47 @@ +/** Read-only Host plugin inventory registered into Web Settings. */ + +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import { PluginSettingsSection, type PluginSettingsSectionInjected } from './PluginSettingsSection.tsx' +import { en, zh, type PluginsKey } from './locales.ts' + +export type { PluginSettingsSectionInjected, PluginSettingsSectionProps } from './PluginSettingsSection.tsx' +export type { PluginsKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Read-only Host plugin inventory copy. */ + 'settings.plugins': PluginsKey + } +} + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'settings.plugins' + +/** Services required by the Settings registration and generated Remote face. */ +export const inject = ['slots', 'locale', 'remote', 'remote.pluginInventory'] + +/** Register the lazy plugin inventory page below Models in Settings. */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugins: dictionaries') + + const t = ctx.locale.bind(NS) + const list: PluginSettingsSectionInjected['list'] = async () => { + const result = await ctx.remote.pluginInventory.list() + if (!result.ok) { + throw new Error(`pluginInventory.list failed: ${result.error.code}: ${result.error.message}`) + } + return result.value + } + const injected = (): PluginSettingsSectionInjected => ({ list }) + + ctx.slots.inject('settings.section', () => ctx.slots.register({ + name: 'settings.section', + id: 'plugin-inventory', + order: 15, + label: () => t('nav'), + locale: NS, + inject: injected, + }, PluginSettingsSection)) +} diff --git a/packages/client/ui-plugins/src/client/locales.ts b/packages/client/ui-plugins/src/client/locales.ts new file mode 100644 index 0000000000..c505296f38 --- /dev/null +++ b/packages/client/ui-plugins/src/client/locales.ts @@ -0,0 +1,50 @@ +/** Copy dictionaries for the plugin inventory Settings section. */ + +/** Simplified Chinese dictionary and key source of truth. */ +export const zh = { + nav: '插件', + title: '插件', + loading: '正在读取插件…', + error: '暂时无法读取插件。', + retry: '重试', + search: '搜索插件', + catalog: '插件列表', + empty: '暂无插件。', + emptySearch: '没有匹配的插件。', + enabledTag: '已启用', + disabledTag: '已停用', + configuration: '配置状态', + cordis: 'Cordis 状态', + unobserved: '未挂载', + pending: '等待依赖', + loadingPhase: '加载中', + active: '已挂载', + failed: '挂载失败', + unloading: '卸载中', +} satisfies Record + +/** Plugin inventory locale key union. */ +export type PluginsKey = keyof typeof zh + +/** English dictionary checked against the Chinese key set. */ +export const en = { + nav: 'Plugins', + title: 'Plugins', + loading: 'Reading plugins…', + error: 'Plugins are temporarily unavailable.', + retry: 'Retry', + search: 'Search plugins', + catalog: 'Plugin list', + empty: 'No plugins are available.', + emptySearch: 'No matching plugins.', + enabledTag: 'Enabled', + disabledTag: 'Disabled', + configuration: 'Configuration', + cordis: 'Cordis status', + unobserved: 'Not mounted', + pending: 'Waiting for dependencies', + loadingPhase: 'Loading', + active: 'Mounted', + failed: 'Mount failed', + unloading: 'Unloading', +} satisfies Record diff --git a/packages/client/ui-plugins/src/css-modules.d.ts b/packages/client/ui-plugins/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-plugins/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-plugins/src/index.ts b/packages/client/ui-plugins/src/index.ts new file mode 100644 index 0000000000..489544a421 --- /dev/null +++ b/packages/client/ui-plugins/src/index.ts @@ -0,0 +1,4 @@ +/** Host loader entry for the browser implementation exported from `./client`. */ + +/** Host plugin body — no host-side behavior for the plugin settings section. */ +export function apply(): void {} diff --git a/packages/client/ui-plugins/src/invariant.ts b/packages/client/ui-plugins/src/invariant.ts new file mode 100644 index 0000000000..2d001d4312 --- /dev/null +++ b/packages/client/ui-plugins/src/invariant.ts @@ -0,0 +1,20 @@ +/** Package-owned invariant companion. @module @deepseek-ai/dsh-client-ui-plugins/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-plugins' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-plugins-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: this package owns a read-only Settings contribution. */ +const install: InvariantInstaller = () => {} + +/** Register this package's invariant companion. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-plugins/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugins/tests/browser-plugin.client.spec.tsx new file mode 100644 index 0000000000..d9d8a43cd8 --- /dev/null +++ b/packages/client/ui-plugins/tests/browser-plugin.client.spec.tsx @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +import { Context, Service } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup } from '@testing-library/react' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { apply, inject, NS } from '../src/client/index.ts' +import { PluginSettingsSection } from '../src/client/PluginSettingsSection.tsx' +import type { PluginSettingsSectionInjected } from '../src/client/PluginSettingsSection.tsx' + +usePinnedBrowserLanguages('zh-CN') +afterEach(cleanup) + +const EMPTY = { entries: [] } +type ListResult = + | { readonly ok: true; readonly value: typeof EMPTY } + | { readonly ok: false; readonly error: { readonly code: string; readonly message: string } } + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + const locale = new LocaleService(ctx) + ctx.provide('locale', locale) + class RemoteService extends Service { + constructor(serviceCtx: Context) { + super(serviceCtx, 'remote') + } + } + new RemoteService(ctx) + const list = vi.fn<() => Promise>() + .mockResolvedValue({ ok: true, value: EMPTY }) + ctx.provide('remote.pluginInventory', { list }) + return { ctx, slots: ctx.get('slots') as SlotsService, locale, list } +} + +function declare(slots: SlotsService): () => void { + return slots.register({ + name: 'root', + children: { 'settings.section': { kind: 'list', scope: 'root' } }, + } as never, () => null) +} + +describe('ui-plugins browser plugin', () => { + it('declares only the services used by the Settings Remote contribution', () => { + expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginInventory']) + }) + + it('registers a localized section without reading the Remote eagerly', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + + const entry = b.slots.entries('settings.section')[0]! + expect(entry.component).toBe(PluginSettingsSection) + expect(entry.options).toMatchObject({ id: 'plugin-inventory', order: 15 }) + expect(entry.locale).toBe(NS) + expect(resolveSlotLabel(entry.options.label)).toBe('插件') + expect(b.list).not.toHaveBeenCalled() + + const injected = (entry.inject as unknown as () => PluginSettingsSectionInjected)() + await expect(injected.list()).resolves.toEqual(EMPTY) + expect(b.list).toHaveBeenCalledOnce() + b.list.mockResolvedValueOnce({ ok: false, error: { code: 'REMOTE_ERROR', message: 'unavailable' } }) + await expect(injected.list()).rejects.toThrow('pluginInventory.list failed: REMOTE_ERROR: unavailable') + await b.ctx.fiber.dispose() + }) + + it('follows locale and recovers across late declaration and declarer reload', async () => { + const b = await bench() + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(b.slots.entries('settings.section')).toHaveLength(0) + + const stop = declare(b.slots) + await vi.waitFor(() => { expect(b.slots.entries('settings.section')).toHaveLength(1) }) + b.locale.setLocale('en') + expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Plugins') + + stop() + expect(b.slots.entries('settings.section')).toHaveLength(0) + declare(b.slots) + await vi.waitFor(() => { + expect(b.slots.entries('settings.section')[0]?.component).toBe(PluginSettingsSection) + }) + + await fiber.dispose() + expect(b.slots.entries('settings.section')).toHaveLength(0) + expect(() => b.locale.register(NS, 'zh', {})).not.toThrow() + await b.ctx.fiber.dispose() + }) +}) diff --git a/packages/client/ui-plugins/tests/components.client.spec.tsx b/packages/client/ui-plugins/tests/components.client.spec.tsx new file mode 100644 index 0000000000..9da8a79b0d --- /dev/null +++ b/packages/client/ui-plugins/tests/components.client.spec.tsx @@ -0,0 +1,128 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PluginSettingsSection } from '../src/client/PluginSettingsSection.tsx' +import type { + PluginSettingsSectionInjected, + PluginSettingsSectionProps, +} from '../src/client/PluginSettingsSection.tsx' +import { en, type PluginsKey } from '../src/client/locales.ts' + +afterEach(cleanup) + +type Snapshot = Awaited> +const t = ((key: PluginsKey): string => en[key]) as PluginSettingsSectionProps['t'] +const unusedHook = (() => { throw new Error('unused by plugin inventory') }) as never + +function props(list: PluginSettingsSectionInjected['list']): PluginSettingsSectionProps { + return { + close: vi.fn(), + useSessions: unusedHook, + useWorkspaces: unusedHook, + t, + list, + } +} + +const SNAPSHOT = { + entries: [ + { entryId: '8a1b2c3d', moduleName: '@deepseek-ai/cordis-plugin-hmr', enabled: true, fiberPhase: 'active' }, + { entryId: 'pending', moduleName: 'cordis:pending-name', enabled: true, fiberPhase: 'pending' }, + { entryId: 'loading', moduleName: '@fixture/loading-name', enabled: true, fiberPhase: 'loading' }, + { entryId: 'failed', moduleName: '@fixture/failed-name', enabled: true, fiberPhase: 'failed' }, + { entryId: 'unloading', moduleName: '@fixture/unloading-name', enabled: true, fiberPhase: 'unloading' }, + { entryId: 'disabled-entry', moduleName: '@deepseek-ai/dsh-host-directory-picker-native', enabled: false, fiberPhase: null }, + ], +} as unknown as Snapshot + +describe('PluginSettingsSection', () => { + it('renders searchable two-column-card semantics with dots and tags', async () => { + const deferred = Promise.withResolvers() + const list = vi.fn(() => deferred.promise) + const view = render() + expect(screen.getByText(en.loading)).toBeTruthy() + + await act(async () => { deferred.resolve(SNAPSHOT) }) + expect(list).toHaveBeenCalledOnce() + expect(screen.getByRole('searchbox', { name: en.search })).toBeTruthy() + expect(screen.getByRole('heading', { name: en.catalog })).toBeTruthy() + expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('6') + expect(screen.getAllByRole('listitem')).toHaveLength(6) + expect(screen.getAllByText(en.enabledTag)).toHaveLength(5) + expect(screen.getByText(en.disabledTag)).toBeTruthy() + for (const value of [ + 'Mounted', + 'Waiting for dependencies', + 'Loading', + 'Mount failed', + 'Unloading', + 'Not mounted', + ]) { + expect(screen.getByRole('img', { name: value })).toBeTruthy() + } + const active = screen.getByRole('button', { name: 'hmr, Mounted, Enabled' }) + expect(active.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(active) + expect(active.getAttribute('aria-expanded')).toBe('true') + expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('8a1b2c3d') + expect(screen.getByText(en.configuration)).toBeTruthy() + expect(screen.getByText(en.cordis)).toBeTruthy() + fireEvent.click(active) + expect(view.container.querySelector('[data-loader-entry]')).toBeNull() + + fireEvent.click(active) + fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { + target: { value: 'disabled-entry' }, + }) + expect(view.container.querySelector('[data-loader-entry]')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'directory-picker-native, Not mounted, Disabled' })) + expect(screen.getAllByText(en.disabledTag)).toHaveLength(2) + }) + + it('filters by module name or Loader entry id', async () => { + render( SNAPSHOT)} />) + const search = await screen.findByRole('searchbox', { name: en.search }) + + fireEvent.change(search, { target: { value: 'disabled-entry' } }) + expect(screen.getAllByRole('listitem')).toHaveLength(1) + expect(screen.getByText('directory-picker-native')).toBeTruthy() + + fireEvent.change(search, { target: { value: 'cordis-plugin-hmr' } }) + expect(screen.getAllByRole('listitem')).toHaveLength(1) + expect(screen.getByText('hmr')).toBeTruthy() + + fireEvent.change(search, { target: { value: 'not-a-plugin' } }) + expect(screen.queryAllByRole('listitem')).toHaveLength(0) + expect(screen.getByText(en.emptySearch)).toBeTruthy() + }) + + it('shows a generic failure and retries into the empty state', async () => { + const list = vi.fn() + .mockRejectedValueOnce(new Error('private transport detail')) + .mockResolvedValueOnce({ entries: [] }) + render() + + expect((await screen.findByRole('alert')).textContent).toBe(en.error) + expect(screen.queryByText('private transport detail')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en.retry })) + await waitFor(() => { expect(list).toHaveBeenCalledTimes(2) }) + expect(await screen.findByText(en.empty)).toBeTruthy() + }) + + it('contains a synchronous Remote failure and ignores a result after unmount', async () => { + const syncFailure = vi.fn(() => { throw new Error('namespace unavailable') }) as PluginSettingsSectionInjected['list'] + const failed = render() + expect((await screen.findByRole('alert')).textContent).toBe(en.error) + failed.unmount() + + const deferred = Promise.withResolvers() + const pending = render( deferred.promise)} />) + pending.unmount() + await act(async () => { deferred.resolve(SNAPSHOT) }) + + const deferredFailure = Promise.withResolvers() + const pendingFailure = render( deferredFailure.promise)} />) + pendingFailure.unmount() + await act(async () => { deferredFailure.reject(new Error('late failure')) }) + }) +}) diff --git a/packages/client/ui-plugins/tests/invariant.client.spec.ts b/packages/client/ui-plugins/tests/invariant.client.spec.ts new file mode 100644 index 0000000000..df4161cb13 --- /dev/null +++ b/packages/client/ui-plugins/tests/invariant.client.spec.ts @@ -0,0 +1,15 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as PluginsInvariant from '../src/invariant.ts' + +describe('ui-plugins invariant companion', () => { + it('registers the empty installer and keeps the node half inert', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(PluginsInvariant).await()).resolves.toBeDefined() + const { apply } = await import('../src/index.ts') + apply() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/client/ui-plugins/tsconfig.json b/packages/client/ui-plugins/tsconfig.json new file mode 100644 index 0000000000..2019585ff7 --- /dev/null +++ b/packages/client/ui-plugins/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../locale" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-settings" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-plugins/tsdown.config.ts b/packages/client/ui-plugins/tsdown.config.ts new file mode 100644 index 0000000000..a85ab4569f --- /dev/null +++ b/packages/client/ui-plugins/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-plugins', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-theme/src/styles/design-platform.css b/packages/client/ui-theme/src/styles/design-platform.css index 00d9d7106b..5dd0948769 100644 --- a/packages/client/ui-theme/src/styles/design-platform.css +++ b/packages/client/ui-theme/src/styles/design-platform.css @@ -162,6 +162,7 @@ body { --dsw-alias-bg-mask-2: rgba(0, 0, 0, 0.12); --dsw-alias-bg-mask-3: rgba(0, 0, 0, 0.48); --dsw-alias-bg-mask-photo: rgba(0, 0, 0, 0.88); + --dsw-alias-bg-mask-drop: rgba(255, 255, 255, 0.7); --dsw-alias-bg-module-platform: var(--dsw-static-neutral-bluish-60); --dsw-alias-bg-multi-select: var(--dsw-static-neutral-bluish-60); --dsw-alias-bg-overlay: var(--dsw-static-neutral-bluish-150); @@ -253,6 +254,7 @@ body[data-ds-dark-theme] { --dsw-alias-bg-mask-2: rgba(0, 0, 0, 0.2); --dsw-alias-bg-mask-3: rgba(0, 0, 0, 0.48); --dsw-alias-bg-mask-photo: rgba(0, 0, 0, 0.88); + --dsw-alias-bg-mask-drop: rgba(39, 39, 48, 0.7); --dsw-alias-bg-module-platform: var(--dsw-static-neutral-bluish-800); --dsw-alias-bg-multi-select: var(--dsw-static-neutral-850); --dsw-alias-bg-overlay: var(--dsw-static-neutral-bluish-700); diff --git a/packages/examples/acp-demo/README.i18n.yaml b/packages/examples/acp-demo/README.i18n.yaml index 4e2d02b336..b86a3b30ad 100644 --- a/packages/examples/acp-demo/README.i18n.yaml +++ b/packages/examples/acp-demo/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/examples/acp-demo/README.md -README.md: edc45c9857a631cef72eb41b1a98c390f112291e -README.zh.md: 3798f4bf1e349c27b3fb3a32434e4f25905eddf5 +README.md: c1a15a424d9d66b90bbec451e220198bfe0a45df +README.zh.md: 6928590f6483b95312400adc11b9f7b7112ece4a diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index edc45c9857..c1a15a424d 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -34,6 +34,7 @@ The app does not install commands, user interaction, session navigation, configu | `workspaceContext` | required | Workspace-instruction byte budget/config, or `false`. | | `skills` | owner defaults | Skill registry, local provider, and model-facing skill tool. | | `toolBash` | owner defaults | Model-facing bash tool config. | +| `tasks` | `{ maxConcurrentTasksPerOwner: 10 }` | Process-local per-owner active-task admission. | | `toolTasks` | owner defaults | Generic background-task control config, or `false`. | | `goals` | owner defaults | Persisted same-session goal domain and model tools, or `false`. | diff --git a/packages/examples/acp-demo/README.zh.md b/packages/examples/acp-demo/README.zh.md index 3798f4bf1e..6928590f64 100644 --- a/packages/examples/acp-demo/README.zh.md +++ b/packages/examples/acp-demo/README.zh.md @@ -34,6 +34,7 @@ ACP(Agent Client Protocol)自动化服务器应用:默认 agent(智能 | `workspaceContext` | 必填 | 工作区指令字节预算/配置,或 `false`。 | | `skills` | 拥有者默认值 | skill 注册表、本地提供方和面向模型的 skill 工具。 | | `toolBash` | 拥有者默认值 | 面向模型的 bash 工具配置。 | +| `tasks` | `{ maxConcurrentTasksPerOwner: 10 }` | 进程内按 owner 限制活动任务的准入配置。 | | `toolTasks` | 拥有者默认值 | 通用后台任务控制配置,或 `false`。 | | `goals` | 拥有者默认值 | 持久化的同会话目标领域与模型工具,或 `false`。 | diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index 91ce6ef5b6..894cd41710 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -65,6 +65,8 @@ export interface Config { skills?: agentCore.SkillConfig /** Model-facing bash tool config forwarded through agent-core. */ toolBash?: NonNullable + /** Process-local background-task admission config forwarded through agent-core. */ + tasks?: NonNullable /** Generic background-task controls forwarded through agent-core; set false to omit their tools. */ toolTasks?: NonNullable /** Persisted same-session goals; owner defaults enable them, or false disables the stack and tools. */ @@ -92,6 +94,7 @@ export const Config: z = z.object({ workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), skills: agentCore.SkillConfigSchema, toolBash: agentCore.ToolBashConfigSchema, + tasks: agentCore.TasksConfigSchema, toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), goals: z.union([z.const(false), agentCore.GoalConfigSchema]), }) diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index af467660da..3a64404704 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -182,6 +182,31 @@ describe('dsh-acp-demo composition', () => { await ctx.fiber.dispose() }) + it('forwards task admission config to the bundled task provider', async () => { + const ctx = await mount({ + provider: 'mock', + model: 'mock', + tasks: { maxConcurrentTasksPerOwner: 1 }, + skills: await isolatedSkillsConfig(), + workspaceContext: false, + }) + let settle!: (outcome: { status: 'killed' }) => void + ctx.tasks.start({ + kind: 'bash', + label: 'hold configured slot', + run: () => ({ + cancel: () => { settle({ status: 'killed' }) }, + done: new Promise((resolve) => { settle = resolve }), + }), + }) + expect(() => ctx.tasks.start({ + kind: 'bash', + label: 'blocked configured task', + run: () => ({ cancel: () => {}, done: Promise.resolve({ status: 'completed' }) }), + })).toThrow('(limit: 1)') + await ctx.fiber.dispose() + }) + it('forwards bundled tool config into agent-core', async () => { const ctx = await mount({ provider: 'mock', diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index c13e1cb93d..150ddae9ff 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/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/examples/agent-spine-demo/README.md -README.md: 5957d9a8e9218e18d5d7d0f620b6be811f2c230f -README.zh.md: a47727561808e02f663155bddf1d8cb206bad948 +README.md: 789715e53038f610d1e2db79cf56f9aabd681fac +README.zh.md: 7a861297d76d18d5e55539334ca8e7ee5ffef640 diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 5957d9a8e9..789715e530 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -55,11 +55,11 @@ This applies the [Service Definition / Service provider / Consumer separation](. ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? } +// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, tasks?, toolTasks?, goals?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle forwards each field to the child that owns it. App packages supply any pre-created agents: headless and JSON-RPC compositions create `main`, while the ACP app creates agents on demand at `session/new`. Prompt, tool, title, skill, workspace-context, invariant, goal, and task settings retain the schemas and defaults documented by their owning packages. `pickSpineConfig()` copies only fields owned by this bundle, and conflicting `dshHome` values fail during composition. +The bundle forwards each field to the child that owns it. App packages supply any pre-created agents: headless and JSON-RPC compositions create `main`, while the ACP app creates agents on demand at `session/new`. Prompt, tool, title, skill, workspace-context, invariant, goal, and task settings retain the schemas and defaults documented by their owning packages; `tasks.maxConcurrentTasksPerOwner` configures the local provider independently of the model-facing `toolTasks` controls. `pickSpineConfig()` copies only fields owned by this bundle, and conflicting `dshHome` values fail during composition. For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules. diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index a477275618..7a861297d7 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -55,11 +55,11 @@ ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants? } +// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, tasks?, toolTasks?, goals?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -组合包将每个字段转发给拥有它的子节点。应用包提供预创建的 agent:无头和 JSON-RPC 组合会创建 `main`,ACP 应用则在 `session/new` 按需创建 agent。提示词、工具、标题、skill、工作区上下文、不变式、目标和任务设置沿用其所属包记录的 schema 与默认值。`pickSpineConfig()` 只复制该组合包拥有的字段,`dshHome` 值冲突会在组合时失败。 +组合包将每个字段转发给拥有它的子节点。应用包提供预创建的 agent:无头和 JSON-RPC 组合会创建 `main`,ACP 应用则在 `session/new` 按需创建 agent。提示词、工具、标题、skill、工作区上下文、不变式、目标和任务设置沿用其所属包记录的 schema 与默认值;`tasks.maxConcurrentTasksPerOwner` 配置本地 Service provider,并与面向模型的 `toolTasks` 控制工具相互独立。`pickSpineConfig()` 只复制该组合包拥有的字段,`dshHome` 值冲突会在组合时失败。 例如,`{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` 会让包拥有的配套插件保持挂载,但抑制被阻止的拥有者。Blocklist 匹配优先于 allowlist 匹配;正则表达式与生命周期规则见 [`dsh-invariants`](../../support/invariants/README.md)。 diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 383fc8c212..2b7644e3cc 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -22,7 +22,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import GoalService, { type Config as GoalDomainConfig } from '@deepseek-ai/dsh-goal' import * as goalSession from '@deepseek-ai/dsh-goal-session' import * as toolGoal from '@deepseek-ai/dsh-tool-goal' -import LocalTaskService from '@deepseek-ai/dsh-tasks-local' +import LocalTaskService, { type Config as TasksConfig } from '@deepseek-ai/dsh-tasks-local' import InvariantService, { type Config as InvariantConfig } from '@deepseek-ai/dsh-invariants' import * as sessionInvariant from '@deepseek-ai/dsh-session/invariant' import * as agentInvariant from '@deepseek-ai/dsh-agent/invariant' @@ -75,9 +75,10 @@ export interface GoalConfig { * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the * skill registry/local provider/tool consumer, `workspaceContext` to the - * workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool - * plugins this bundle owns. Provider adapters own their `retryPolicy`; this - * bundle always mounts its executor. + * workspace-context loader, `tasks` to the process-local task provider, and + * `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns. + * Provider adapters own their `retryPolicy`; this bundle always mounts its + * executor. * `goals` opts into and configures the persisted goal domain plus its model tool * and same-session driver; `invariants` configures global and package-filtered * relational checks. Owner schemas supply defaults for optional input; @@ -114,6 +115,8 @@ export interface Config { skills?: SkillConfig /** Model-facing bash tool config, or false when another plugin owns `bash`. */ toolBash?: toolBash.Config | false + /** Process-local background-task admission config. */ + tasks?: TasksConfig /** Generic background-task controls; set false to keep the task service without model-facing task tools. */ toolTasks?: toolTasks.Config | false /** Global enablement and package-name filters for invariant companions. */ @@ -138,6 +141,9 @@ export const SessionTitleConfigSchema: z = SessionTitleServi export const ToolBashConfigSchema: z = z.union([z.const(false), toolBash.Config]) +/** The process-local task registry schema exported for app packages that forward `tasks`. */ +export const TasksConfigSchema: z = LocalTaskService.Config + /** The task-control-tool config schema exported for app packages that forward `toolTasks`. */ export const ToolTasksConfigSchema: z = toolTasks.Config @@ -158,10 +164,11 @@ export const Config = z.intersect([ skills: SkillConfigSchema, workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), toolBash: ToolBashConfigSchema, + tasks: TasksConfigSchema, toolTasks: z.union([z.const(false), ToolTasksConfigSchema]), invariants: InvariantService.Config, goals: z.union([z.const(false), GoalConfigSchema]), - }) as unknown as z>, + }) as unknown as z>, ]) as unknown as z /** @@ -181,6 +188,7 @@ export function pickSpineConfig(config: Omit): Omit { await ctx.fiber.dispose() }) + it('forwards task admission config to the process-local provider', async () => { + const ctx = await mount({ + tasks: { maxConcurrentTasksPerOwner: 1 }, + workspaceContext: false, + }) + let settle!: (outcome: { status: 'killed' }) => void + ctx.tasks.start({ + kind: 'probe', + label: 'hold configured slot', + run: () => ({ + cancel: () => { settle({ status: 'killed' }) }, + done: new Promise((resolve) => { settle = resolve }), + }), + }) + expect(() => ctx.tasks.start({ + kind: 'probe', + label: 'blocked configured task', + run: () => ({ cancel: () => {}, done: Promise.resolve({ status: 'completed' }) }), + })).toThrow('(limit: 1)') + await ctx.fiber.dispose() + }) + it('tolerates a schema-bypassing direct apply (the ?? fallbacks fire)', async () => { // ctx.plugin validates + defaults the bundle config first; a direct apply // skips the schema, so the forwarding `?? []` / `?? ''` are what fire. @@ -716,6 +738,7 @@ describe('dsh-agent-spine-demo bundle', () => { workspaceContext: false as const, skills: { enabled: false }, toolBash: { enableRunInBackground: false }, + tasks: { maxConcurrentTasksPerOwner: 4 }, toolTasks: false as const, invariants: { enabled: false }, } @@ -730,6 +753,7 @@ describe('dsh-agent-spine-demo bundle', () => { workspaceContext: false, skills: appConfig.skills, toolBash: appConfig.toolBash, + tasks: appConfig.tasks, toolTasks: appConfig.toolTasks, invariants: appConfig.invariants, }) diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml index 3eb8fe7eb8..84e471c7fb 100644 --- a/packages/host/README.i18n.yaml +++ b/packages/host/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/README.md -README.md: 926cb0b6b87a8ee76cb2dab745a31f620f4e7f5c -README.zh.md: 7ef057ee56e56ddc2baa7092ccbe44fb161b7448 +README.md: 1c3b6ab3192fe35a5532183414e45d1b02325e57 +README.zh.md: a062d5fce055e3266953993d532a86bec1375377 diff --git a/packages/host/README.md b/packages/host/README.md index 926cb0b6b8..1c3b6ab319 100644 --- a/packages/host/README.md +++ b/packages/host/README.md @@ -13,6 +13,7 @@ The host side of the dsh web GUI: the API gateway every client shape shares, and | [`directory-picker-native/`](directory-picker-native/README.md) | Native directory-picker backend and browser interaction | registers `ctx.directoryPicker` | | [`directory-picker-browse/`](directory-picker-browse/README.md) | In-app directory-browser backend and interaction | registers `ctx.directoryPicker` | | [`directory-picker-auto/`](directory-picker-auto/README.md) | Host-adaptive picker composition | mounts a backend | +| [`plugin-inventory/`](plugin-inventory/README.md) | Read-only projection of current Loader entries | Remote `pluginInventory/list` | `apiproxy` remains transport-independent; [`client/connection`](../client/connection/README.md) supplies the browser/HTTP carrier. Picker implementations replace one another behind the shared seam. diff --git a/packages/host/README.zh.md b/packages/host/README.zh.md index 7ef057ee56..a062d5fce0 100644 --- a/packages/host/README.zh.md +++ b/packages/host/README.zh.md @@ -13,6 +13,7 @@ dsh Web GUI 的宿主侧:所有客户端形态共享的 API 网关,以及承 | [`directory-picker-native/`](directory-picker-native/README.md) | 原生目录选择器后端和浏览器交互 | 注册 `ctx.directoryPicker` | | [`directory-picker-browse/`](directory-picker-browse/README.md) | 应用内目录浏览器后端和交互 | 注册 `ctx.directoryPicker` | | [`directory-picker-auto/`](directory-picker-auto/README.md) | 宿主自适应选择器组合 | 挂载一个后端 | +| [`plugin-inventory/`](plugin-inventory/README.md) | 当前 Loader 条目的只读投影 | Remote `pluginInventory/list` | `apiproxy` 保持传输无关;[`client/connection`](../client/connection/README.md) 提供浏览器/HTTP 载体。选择器实现可在共享 seam 后互相替换。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 9bc5f65bd9..b3024bb3a1 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: 5915d20b176ed6eccdb2c939bdf58b0a122271c5 -README.zh.md: 54fcccc3fef46e717aaf05e3a7ace732a0f4b74a +README.md: 059c3eacbcd47bfc39820ab3db5545dbc2e2ccb8 +README.zh.md: 4c7e97233a9ebf766ff75daf5cb71ff9d2d22d88 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 5915d20b17..059c3eacbc 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -26,7 +26,7 @@ Question responses are validated against their pending request before the first `session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only `compact/summary` record on the same page as the replacement that cites it. -`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. +`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds no other domain's knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. The gateway registers exactly one unit of its own: `imageLimits`, the attachments config it enforces at prompt admission, published as a per-boot constant (`apply` keeps the state reference, so baselines alone carry it — no change frames) so clients can refuse an over-limit intake before submit and label upload affordances; the unit activates only while both the registry and the attachments service are composed. Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents//`, and every image any included log references under `media/.` (read and verified from the attachment store; a shared image appears once). Each live root or descendant crosses the authoritative `SessionStore.flush` durability barrier immediately before its raw artifact read; cold sessions have no in-memory work to flush. Compression runs on the host with fflate's streaming Zip API at validated `sessionExportCompressionLevel` 0–9 (default 6), so deployments can trade CPU and latency against archive size; the response is chunked as it is produced and the host never holds the whole archive in one buffer. Once the response queue reaches its 64 KiB byte high-water mark, production waits until consumer pull restores positive capacity; fflate's synchronous callback can overshoot that bound only by the output of one bounded input push. Request abort and response-body cancellation stop lineage and artifact work, terminate the active compressor, and propagate as cancellation rather than an HTTP 500. It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 54fcccc3fe..4c7e97233a 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -26,7 +26,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 `session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent,然后按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志 `compact/summary` 记录与引用它的替换留在同一页。 -`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 +`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有其他领域的知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。网关唯一自己注册的单元是 `imageLimits`:它在 prompt 准入时执行的 attachments 配置,以每次启动恒定的值发布(`apply` 保持状态引用不变,因此只靠基线携带、绝不产生变更帧),供客户端在提交前拒绝超限的加入并给上传入口标注上限;该单元仅在注册表与 attachments 服务同时组合时激活。 会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents//` 下,每个被任何包含的日志引用的图片放在 `media/.` 下(从附件存储读取并校验;共享图片只出现一次)。每个实时根会话或后代都会在读取原始工件前立即通过权威的 `SessionStore.flush` 持久性屏障;冷会话没有需要 flush 的内存工作。压缩在宿主侧使用 fflate 流式 Zip API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区。响应队列达到 64 KiB 字节高水位后,生产会等待 Consumer pull 恢复正容量;fflate 的同步回调最多只会让该界限多出一次有界输入 push 的输出。请求中止或响应 body 取消会停止血缘与工件工作、终止活跃压缩器,并继续按取消传播,而不会变成 HTTP 500。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,持久化后端不提供每会话原始工件时应答 501,根会话缺失时应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index c9567256db..9f4114c811 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -85,6 +85,7 @@ import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-a // `ctx.get('approval')` without a value dependency on the seam (optional composition). import type {} from '@deepseek-ai/dsh-user-approval' import { approvalResponsePayloadSchema } from './api/approvals.schema.ts' +import { imageLimitsProjectionSchema } from './api/sessions.schema.ts' import { questionResponsePayloadSchema } from './api/questions.schema.ts' import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts' import { RpcId } from './api/rpc.ts' @@ -1227,6 +1228,30 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) }) + // The imageLimits projection unit: the attachments config this proxy + // enforces at prompt admission, constant per host boot. `apply` keeps the + // same state reference for every event, so no change frames are ever + // pushed — baselines alone carry the value — and clients pre-check intake + // and label upload affordances from it. Registered here, not in the + // attachment Service Definition: dsh-llm depends on dsh-attachment, so the + // seam package cannot reference the projection registry without a cycle, + // and the per-message rules the value describes are this proxy's own + // admission checks. The child activates only while both seams are composed. + // `view` reading the live service instead of the (null) state is sanctioned + // exactly for boot-constant units: the value cannot change within a process + // lifetime, so the fold stays observationally pure, and a stale persisted + // cache row re-viewing to the current config is the correct outcome. + ctx.inject(['sessionProjections', 'attachments'], (projectionCtx) => { + projectionCtx.sessionProjections.register<'imageLimits', null>({ + key: 'imageLimits', + schema: imageLimitsProjectionSchema, + init: () => null, + apply: state => state, + view: () => projectionCtx.attachments.imageLimits, + stateVersion: 1, + }) + }) + /** Project both durable inbox lists, optionally including the splice currently being emitted. */ const queueItems = ( agent: Agent, diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index f449132027..e62087c63a 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -15,7 +15,7 @@ import type { ModelReasoningEffort, ModelSelection, SessionProjectionsBlock, SessionSearchItem, SessionSummary, } from './sessions.ts' import type { ToolEventView } from './events.ts' -import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { WorkspaceId } from './workspace.ts' import { SESSION_SEARCH_RESULT_LIMIT, @@ -213,7 +213,20 @@ export const sessionProjectionsBlockSchema = z.object({ // -1 = empty log (the lastSeq convention of session/subscribed). asOfSeq: z.number().int().min(-1), values: z.record(z.string(), z.unknown()), -}) as unknown as z.ZodType +}) as unknown as z.ZodType> + +/** + * imageLimits projection unit schema (host-side view validation). zod widens + * `readonly ImageMediaType[]` to `string[]`; on the JSON wire the two + * serialize identically, so the cast records exactly that widening. + */ +export const imageLimitsProjectionSchema = z.object({ + maxImageBytes: z.number().int().positive(), + maxImagesPerMessage: z.number().int().positive(), + maxMessageImageBytes: z.number().int().positive(), + maxImagePixels: z.number().int().positive(), + mediaTypes: z.array(z.string()), +}) as unknown as z.ZodType /** session.history response value (projections rides the tail page only). */ export const sessionHistoryValueSchema: z.ZodType>> = z.object({ diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index dc59405283..0be4bad62e 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -5,7 +5,7 @@ */ import type { MessageId } from '@deepseek-ai/dsh-llm/brand' -import type { AttachmentIdType, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' +import type { AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' // The pure-type outlet: api/ is browser-importable, and the package root's @@ -15,6 +15,19 @@ import type { RpcId, RpcRequest, RpcResponse } from './rpc.ts' import type { ToolEventView } from './events.ts' import type { WorkspaceId } from './workspace.ts' +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + /** + * The deployment's image-intake limits: the attachments service's config + * as this proxy enforces it at prompt admission, constant per host boot. + * Clients pre-check count and bytes at intake and show the limits in + * upload affordances. Key absence means no attachment service is + * composed — clients skip the pre-check and let the host answer. + */ + imageLimits: ImageAttachmentLimits + } +} + declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { /** diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts index a3a8867291..021fbc3da8 100644 --- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts @@ -11,6 +11,7 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -86,6 +87,51 @@ describe('session.history projections block', () => { expect(events.at(-1)?.event.seq).toBe(projections?.asOfSeq) }) + it('publishes the attachments imageLimits as a constant unit while both seams are composed', async () => { + const { ctx, session } = await harness(true) + const limits = { + maxImageBytes: 5 * 1024 * 1024, + maxImagesPerMessage: 20, + maxMessageImageBytes: 100 * 1024 * 1024, + maxImagePixels: 40_000_000, + mediaTypes: ['image/png'] as const, + } + await ctx.plugin(class extends AttachmentStore { + readonly imageLimits = limits + validateImage(): Promise { return Promise.resolve() } + saveImage(): Promise { return Promise.reject(new Error('unused')) } + readImage(): Promise { return Promise.reject(new Error('unused')) } + }) + const gateway = api(ctx) + seedMessages(session, 2) + const response = await gateway.sessions.history(request({ sessionId: session.id })) + if (!response.result.ok) throw new Error('history failed') + expect(response.result.value.projections?.values['imageLimits']).toEqual(limits) + // Constant unit: appending events must never broadcast an imageLimits frame. + await new Promise(resolve => setTimeout(resolve, 0)) + const abort = new AbortController() + const stream = gateway.events.mux({ rpcId: RpcId('t-limits-mux'), payload: {} }, abort.signal) + const frames: MuxFrame[] = [] + const drained = (async () => { + for await (const envelope of stream) { + frames.push(envelope.payload) + if (frames.some(f => f.type === 'session/event')) abort.abort() + } + })().catch(() => {}) + seedMessages(session, 1) + await drained + expect(frames.some(f => f.type === 'session/projection' && f.key === 'imageLimits')).toBe(false) + }) + + it('leaves the imageLimits key absent while no attachment service is composed', async () => { + const { ctx, session } = await harness(true) + seedMessages(session, 1) + const response = await api(ctx).sessions.history(request({ sessionId: session.id })) + if (!response.result.ok) throw new Error('history failed') + expect(response.result.value.projections).toBeDefined() + expect('imageLimits' in (response.result.value.projections?.values ?? {})).toBe(false) + }) + it('never carries the block on loadOlder pages (beforeSeq present)', async () => { const { ctx, session } = await harness(true) ctx.sessionProjections.register(lastUserUnit()) diff --git a/packages/host/plugin-inventory/README.i18n.yaml b/packages/host/plugin-inventory/README.i18n.yaml new file mode 100644 index 0000000000..e9fc3f9a09 --- /dev/null +++ b/packages/host/plugin-inventory/README.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 packages/host/plugin-inventory/README.md +README.md: 23fbf07d7900ecc881f81b5da3f8cbe6a45669de +README.zh.md: 87058cde595b83e980b8f3cec4192e6099b8d9ea diff --git a/packages/host/plugin-inventory/README.md b/packages/host/plugin-inventory/README.md new file mode 100644 index 0000000000..23fbf07d79 --- /dev/null +++ b/packages/host/plugin-inventory/README.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-host-plugin-inventory + +English | [中文](README.zh.md) + +Read-only Host projection of the current Cordis Loader tree. `PluginInventoryService` registers the `pluginInventory` service and publishes one generated direct Remote, `pluginInventory/list`. Every call reads `ctx.loader.entries()` directly, skips structural group rows, and returns the remaining entries in Loader order with only their Loader entry id, module specifier, effective enablement, and current root Fiber phase. + +The phase is `pending`, `loading`, `active`, `failed`, or `unloading`; it is `null` when the entry has no live root Fiber. The snapshot is intentionally point-in-time: Loader remains the sole lifecycle authority, while this package owns no cache, history, provenance model, event stream, or mutation path. Its public payload types live under `./types`, and TypeRT generates the Host and Client Remote artifacts exposed by `./typert` and `./remote`. + +The service is Remote-only and deliberately declares no same-process Cordis `Context` merge. Client packages consume it through the explicit [`api-remotes`](../../api/remotes/README.md) assembly rather than importing the Host implementation. + +## Model Experience + +None, as this Host-only inventory projection registers no prompt, tool, message, or provider request. + +#### KV Cache effect + +None; this package never assembles model input. + +## Known Limitations and Deferred Work + +- **Point-in-time state only** — the result contains no durable failure history or subscription; a missing root Fiber is reported as `null`, regardless of why no live root exists. +- **No provenance or mutation** — the service does not identify which bundle, profile, or override introduced an entry, and it cannot enable, disable, add, or remove plugins. diff --git a/packages/host/plugin-inventory/README.zh.md b/packages/host/plugin-inventory/README.zh.md new file mode 100644 index 0000000000..87058cde59 --- /dev/null +++ b/packages/host/plugin-inventory/README.zh.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-host-plugin-inventory + +[English](README.md) | 中文 + +当前 Cordis Loader 树的只读 Host 投影。`PluginInventoryService` 注册 `pluginInventory` 服务,并发布一个由 TypeRT 生成的直接 Remote:`pluginInventory/list`。每次调用都直接读取 `ctx.loader.entries()`,跳过结构性的 group 行,再按 Loader 顺序返回其余条目,并且只包含 Loader 条目 id、模块标识、有效启用状态与当前根 Fiber 阶段。 + +阶段为 `pending`、`loading`、`active`、`failed` 或 `unloading`;条目没有存活的根 Fiber 时则为 `null`。该快照刻意只表示调用当下:Loader 仍是唯一的生命周期权威,本包不拥有缓存、历史、来源模型、事件流或修改路径。公开 payload 类型位于 `./types`,TypeRT 生成由 `./typert` 与 `./remote` 导出的 Host 和 Client Remote 产物。 + +该服务仅供 Remote 使用,刻意不声明同进程 Cordis `Context` merge。Client 包通过显式的 [`api-remotes`](../../api/remotes/README.md) 组合消费它,而不导入 Host 实现。 + +## 模型体验 + +无,因为这个仅限 Host 的清单投影不注册提示词、工具、消息或提供方请求。 + +#### KV Cache 影响 + +无;本包从不组装模型输入。 + +## 已知限制与暂缓事项 + +- **仅表示调用当下** —— 结果不包含持久的失败历史或订阅;只要不存在存活的根 Fiber,就会报告 `null`,而不区分其原因。 +- **无来源与修改能力** —— 服务不识别条目由哪个 bundle、profile 或 override 引入,也不能启用、停用、添加或移除插件。 diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json new file mode 100644 index 0000000000..ac51ce4aa8 --- /dev/null +++ b/packages/host/plugin-inventory/package.json @@ -0,0 +1,68 @@ +{ + "name": "@deepseek-ai/dsh-host-plugin-inventory", + "description": "Read-only Remote projection of current Cordis Loader plugin state", + "version": "0.0.1-rc.2", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/host/plugin-inventory" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ], + "license": "BSD-3-Clause", + "dependencies": { + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/host/plugin-inventory/src/index.ts b/packages/host/plugin-inventory/src/index.ts new file mode 100644 index 0000000000..5bc4db936a --- /dev/null +++ b/packages/host/plugin-inventory/src/index.ts @@ -0,0 +1,72 @@ +/** Read-only projection of the current Cordis Loader plugin entries. */ + +import type { Context, FiberState } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/cordis-plugin-loader' +import { GatewayService, Remote } from '@deepseek-ai/dsh-type-meta' +// TypeRT-generated ./typert and ./remote artifacts import Zod at runtime. +import type {} from 'zod' +import type { + PluginEntryId, + PluginFiberPhase, + PluginInventoryEntry, + PluginInventorySnapshot, +} from './types.ts' + +export type * from './types.ts' + +/** Brand an existing Loader-tree entry id at the owning boundary. */ +function pluginEntryId(value: string): PluginEntryId { + return value as PluginEntryId +} + +/** Runtime mirror: FiberState is a cross-package const enum. */ +const FIBER_STATE = { + PENDING: 0 as FiberState.PENDING, + LOADING: 1 as FiberState.LOADING, + ACTIVE: 2 as FiberState.ACTIVE, + FAILED: 3 as FiberState.FAILED, + DISPOSED: 4 as FiberState.DISPOSED, + UNLOADING: 5 as FiberState.UNLOADING, +} as const + +/** Complete public projection of Cordis Fiber states. */ +const FIBER_PHASE = { + [FIBER_STATE.PENDING]: 'pending', + [FIBER_STATE.LOADING]: 'loading', + [FIBER_STATE.ACTIVE]: 'active', + [FIBER_STATE.FAILED]: 'failed', + [FIBER_STATE.DISPOSED]: null, + [FIBER_STATE.UNLOADING]: 'unloading', +} as const satisfies Record + +/** Remote-only service exposing the Loader's current non-group entry state. */ +export class PluginInventoryService extends GatewayService { + static inject = ['loader'] + + constructor(ctx: Context) { + super(ctx, 'pluginInventory') + } + + /** + * Read the Loader directly on every call. Cordis's internal plugin/status + * events already maintain Entry.fiber and Fiber.state, so a second cache + * would only add another lifecycle truth to keep synchronized. + * @returns Current non-group Loader entries in Loader order. + */ + @Remote('list') + list(): PluginInventorySnapshot { + const entries: PluginInventoryEntry[] = [] + for (const entry of this.ctx.loader.entries()) { + if (entry.options.group) continue + entries.push({ + entryId: pluginEntryId(entry.id), + moduleName: entry.options.name, + enabled: !entry.disabled, + fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state], + }) + } + return { entries } + } +} + +export default PluginInventoryService diff --git a/packages/host/plugin-inventory/src/invariant.ts b/packages/host/plugin-inventory/src/invariant.ts new file mode 100644 index 0000000000..34acc058aa --- /dev/null +++ b/packages/host/plugin-inventory/src/invariant.ts @@ -0,0 +1,20 @@ +/** Package-owned invariant companion. @module @deepseek-ai/dsh-host-plugin-inventory/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-host-plugin-inventory' + +/** Cordis companion plugin name. */ +export const name = 'host-plugin-inventory-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: every snapshot is projected directly from Loader-owned state. */ +const install: InvariantInstaller = () => {} + +/** Register this package's invariant companion. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/host/plugin-inventory/src/types.ts b/packages/host/plugin-inventory/src/types.ts new file mode 100644 index 0000000000..f5678fc3c2 --- /dev/null +++ b/packages/host/plugin-inventory/src/types.ts @@ -0,0 +1,28 @@ +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Stable Loader-tree identity of one configured plugin entry. */ +export type PluginEntryId = Branded<'PluginEntryId'> + +/** Lifecycle state of an entry's root Fiber, or null when it has no live root Fiber. */ +export type PluginFiberPhase = + | 'pending' + | 'loading' + | 'active' + | 'failed' + | 'unloading' + | null + +/** One non-group Loader entry exposed to trusted clients. */ +export interface PluginInventoryEntry { + readonly entryId: PluginEntryId + /** Exact module specifier imported by the Loader entry. */ + readonly moduleName: string + /** Effective Loader enablement, including disabled ancestor groups. */ + readonly enabled: boolean + readonly fiberPhase: PluginFiberPhase +} + +/** Point-in-time inventory returned by the plugin inventory Remote. */ +export interface PluginInventorySnapshot { + readonly entries: readonly PluginInventoryEntry[] +} diff --git a/packages/host/plugin-inventory/tests/invariant.spec.ts b/packages/host/plugin-inventory/tests/invariant.spec.ts new file mode 100644 index 0000000000..d7e3b99fd8 --- /dev/null +++ b/packages/host/plugin-inventory/tests/invariant.spec.ts @@ -0,0 +1,16 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as PluginInventoryInvariant from '../src/invariant.ts' + +describe('plugin-inventory invariant companion', () => { + it('registers the package-owned empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + const fiber = ctx.plugin(PluginInventoryInvariant) + await expect(fiber.await()).resolves.toBeDefined() + await fiber.dispose() + await expect(ctx.plugin(PluginInventoryInvariant).await()).resolves.toBeDefined() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/host/plugin-inventory/tests/inventory.spec.ts b/packages/host/plugin-inventory/tests/inventory.spec.ts new file mode 100644 index 0000000000..e979d34306 --- /dev/null +++ b/packages/host/plugin-inventory/tests/inventory.spec.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context, type Plugin } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import { remoteMethods } from '@deepseek-ai/dsh-type-meta' +import PluginInventoryService from '../src/index.ts' + +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +const activePlugin: Plugin.Function = () => {} +const pendingPlugin: Plugin.Object = { + inject: ['neverReady'], + apply() {}, +} + +async function harness(): Promise<{ + ctx: Context + inventory: PluginInventoryService +}> { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Loader) + ctx.loader.builtins.active = activePlugin + ctx.loader.builtins.pending = pendingPlugin + await ctx.plugin(PluginInventoryService) + const inventory = ctx.get('pluginInventory') as PluginInventoryService + return { ctx, inventory } +} + +describe('PluginInventoryService', () => { + it('publishes one direct list method under the pluginInventory namespace', async () => { + const { inventory } = await harness() + expect(inventory.typertGateway).toMatchObject({ + serviceKey: 'pluginInventory', + namespace: 'pluginInventory', + }) + expect(remoteMethods(inventory)).toEqual([ + { method: 'list', invocation: { kind: 'direct' } }, + ]) + }) + + it('projects current non-group Loader entries without a second cache', async () => { + const { ctx, inventory } = await harness() + const activeId = await ctx.loader.create({ name: 'cordis:active' }) + const pendingId = await ctx.loader.create({ name: 'cordis:pending' }) + const disabledId = await ctx.loader.create({ + name: 'cordis:not-installed', + disabled: true, + }) + await ctx.loader.create({ name: 'cordis:active', group: true }) + + expect(inventory.list()).toEqual({ + entries: [ + { + entryId: activeId, + moduleName: 'cordis:active', + enabled: true, + fiberPhase: 'active', + }, + { + entryId: pendingId, + moduleName: 'cordis:pending', + enabled: true, + fiberPhase: 'pending', + }, + { + entryId: disabledId, + moduleName: 'cordis:not-installed', + enabled: false, + fiberPhase: null, + }, + ], + }) + + await ctx.loader.update(activeId, { disabled: true }) + expect(inventory.list().entries.find(entry => entry.entryId === activeId)).toEqual({ + entryId: activeId, + moduleName: 'cordis:active', + enabled: false, + fiberPhase: null, + }) + + await ctx.loader.remove(pendingId) + expect(inventory.list().entries.some(entry => entry.entryId === pendingId)).toBe(false) + }) +}) diff --git a/packages/host/plugin-inventory/tsconfig.json b/packages/host/plugin-inventory/tsconfig.json new file mode 100644 index 0000000000..524783f8b8 --- /dev/null +++ b/packages/host/plugin-inventory/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../typert/type-meta" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 000a4c3db8..6170c9a196 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -1150,7 +1150,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'abstract start(spec: TaskStart): TaskId', - jsDoc: '/**\n * Preflight access, validation, and owner cleanup before starting and\n * atomically registering work. A throwing starter leaves nothing registered;\n * after it returns, registration cannot fail. Settlement records the outcome,\n * notifies listeners, and releases waiters.\n * @param spec - task identity, owner, and synchronous starter.\n * @returns the registry-issued `-N` id.\n */', + jsDoc: '/**\n * Preflight access, validation, owner cleanup, and implementation-owned\n * admission before starting and atomically registering work. Any preflight\n * rejection leaves no task id or execution resource. A throwing starter\n * leaves nothing registered; after it returns, registration cannot fail.\n * Settlement records the outcome, notifies listeners, and releases waiters.\n * @param spec - task identity, owner, and synchronous starter.\n * @returns the registry-issued `-N` id.\n */', }, { signature: 'abstract list(caller?: Agent): TaskSnapshot[]', diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index d2726fa86a..5b67d23f68 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/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/subprocess/subprocess-local/README.md -README.md: 2817e02861db6caad89cad258d14a90c34afcbaf -README.zh.md: 47f06ec2902bf823ea47751a806b5e2cb9789752 +README.md: bf0af8779f0cc3e2c20382db40be4715814e78f4 +README.zh.md: 6d0e102d9b4c55049106f937b6f04ec26a458cb6 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 2817e02861..bf0af8779f 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -12,7 +12,8 @@ Local Service provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. - **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd. - **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. -- **Terminate-and-join disposal** — the service retains live handles only so its own disposal can escalate every running tree and await its exit; settled and spawn-failed handles leave the live set on settlement. +- **Terminate-and-join disposal** — the service retains live handles so its own disposal can escalate every running tree and await its exit; quiescent and spawn-failed handles leave the live set after whole-tree or terminal-session cleanup finishes. +- **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener force-terminates every ordinary tree and observable terminal session still in the same live sets. The local-only operations send POSIX SIGKILL to the managed group, run Windows `taskkill /T /F`, and synchronously signal captured/current terminal identities around the PTY root kill; they create no promise or timer, preserve the host's exit code and diagnostic, contain each target's failure, and do not claim quiescence. Normal disposal keeps the awaited graceful path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md). ## Model Experience @@ -27,6 +28,7 @@ No direct invalidation; the named consumers own any request-prefix changes. - **Windows tree support is best-effort** — termination routes through `taskkill /PID /T /F` with all outcomes contained (absent tree, races, missing binary), and liveness falls back to the direct-child boundary. - **Terminal process inspection is Linux/macOS only** — the terminal primitive fails when its inspector has no supported platform implementation; Linux exact probes cover x64 and arm64, while macOS uses `ps` snapshots. - **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the `node-pty` root; on Linux, a child that calls `setsid` leaves both the tree and owned terminal session. The local provider does not add a continuous process-table monitor. +- **In-process cleanup requires a JavaScript-observable exit** — direct `process.exit()`, default uncaught exceptions, and default unhandled rejections emit Node's synchronous `exit` event. The default OS disposition for an unhandled `SIGTERM`, `SIGINT`, or `SIGHUP` bypasses that event; an application covers those signals only by installing a handler that performs normal disposal or calls `process.exit()`. `SIGKILL`, fatal OOM, `process.abort()`, native crashes, power loss, and any failure that cannot run JavaScript require an external supervisor, container init, or equivalent OS owner. - **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSPHRASE*`) pass through, and a whitelist for over-scrubbed vars is noted future work. - **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 47f06ec290..6d0e102d9b 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -12,7 +12,8 @@ - **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 - **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该 seam 处被拒绝,相对 PATH 条目从宿主进程 cwd 解析。 - **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 -- **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,只为让自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。 +- **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,使自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;完全停稳与 spawn 失败的句柄会在整棵进程树或 terminal session 清理完成后离开存活集合。 +- **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会强制终止同一组存活集合中仍存在的每棵普通进程树和可观察 terminal session。这些仅供本地实现使用的操作会向受管 POSIX 进程组发送 SIGKILL、在 Windows 运行 `taskkill /T /F`,并在终止 PTY root 前后同步向已捕获及当前可观察的 terminal 身份发送信号;它们不会创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待温和路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md)。 ## 模型体验 @@ -27,6 +28,7 @@ - **Windows 进程树支持仅为尽力而为**:终止经由 `taskkill /PID /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界。 - **终端进程检查仅支持 Linux/macOS**:检查器没有受支持的平台实现时,终端原语会失败;Linux 精确探针覆盖 x64 与 arm64,macOS 则使用 `ps` 快照。 - **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 `node-pty` 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。 +- **进程内清理要求退出阶段仍能执行 JavaScript**:直接 `process.exit()`、默认未捕获异常和默认未处理 rejection 会发出 Node 同步 `exit` 事件。未安装 handler 时,`SIGTERM`、`SIGINT` 或 `SIGHUP` 的默认 OS 处置不会发出该事件;应用只有安装执行正常 dispose 或调用 `process.exit()` 的 handler 才能覆盖这些信号。`SIGKILL`、fatal OOM、`process.abort()`、native crash、断电,以及任何无法运行 JavaScript 的故障,都需要外部 supervisor、容器 init 或等价的 OS 所有者负责。 - **凭据清除依赖名称启发式规则**:只匹配 `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 - **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。 diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index bc67b9369c..bd041db5f6 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -46,6 +46,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index 5242986b3b..751653e8e8 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -1,7 +1,8 @@ /** * Local Service provider for the subprocess capability seam. Each spawn is a detached - * process tree with the spec's per-stream stdio dispositions; disposal - * terminates and joins live trees. It has no config: every disposition and + * process tree with the spec's per-stream stdio dispositions. Normal disposal + * terminates and joins live trees; Node's synchronous exit phase force-stops + * any trees the service still owns. It has no config: every disposition and * limit arrives on the spec, so the deployment-varying choices stay with the * caller's config (the bash executor's, the LSP host's, …). * @module @deepseek-ai/dsh-subprocess-local @@ -21,7 +22,7 @@ import type { SubprocessTerminalSpawnSpec, } from '@deepseek-ai/dsh-subprocess' import { childEnv, spawnSubprocess } from './spawn.ts' -import type { SpawnInternals } from './spawn.ts' +import type { LocalSubprocessHandle, SpawnInternals } from './spawn.ts' import { createProcessInspector } from './process-inspector.ts' import type { ProcessInspector } from './process-inspector.ts' import { LocalTerminalHandle } from './terminal.ts' @@ -30,13 +31,14 @@ import { LocalTerminalHandle } from './terminal.ts' * Local subprocess service: detached process trees, Node-shaped stdio * dispositions (raw pipes, inherit, bounded tail-keep collection with spill * files), credential-scrubbed environment, and tree-scoped signalling with - * SIGTERM→grace→SIGKILL escalation. + * SIGTERM→grace→SIGKILL escalation, plus synchronous final termination during + * JavaScript-observable host exit. */ export class LocalSubprocessService extends SubprocessService { - /** Live handles retained only so disposal can terminate and join them. */ - private live = new Set() - /** Live terminal sessions retained through whole-session quiescence. */ - private terminals = new Set() + /** Live handles retained for normal disposal and synchronous host-exit finalization. */ + private live = new Set() + /** Live terminals retained through normal quiescence or host-exit finalization. */ + private terminals = new Set() /** Test hook: spill and platform knobs forwarded to spawnSubprocess. */ internals: SpawnInternals = {} /** Test hook for platform process inspection; production resolves lazily on terminal spawn. */ @@ -44,30 +46,61 @@ export class LocalSubprocessService extends SubprocessService { constructor(ctx: Context) { super(ctx) - ctx.effect(() => async () => { - // Terminate (escalating), then await WHOLE-TREE exit — not just the - // direct child's settlement — so even a TERM-trapping descendant cannot - // outlive the fiber. - const pending: Promise[] = [] - for (const handle of this.live) { - handle.terminate() - // Spawn-failure rejections already settled and left the live set. - pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit())) + ctx.effect(() => { + const onHostExit = (): void => { this.terminateForHostExit() } + process.prependListener('exit', onHostExit) + return async () => { + try { + await this.disposeManagedProcesses() + } finally { + process.off('exit', onHostExit) + } } - for (const terminal of this.terminals) { - pending.push(terminal.terminate()) - } - this.live.clear() - this.terminals.clear() - const outcomes = await Promise.allSettled(pending) - const failures = outcomes.flatMap(outcome => outcome.status === 'rejected' - ? [outcome.reason as unknown] - : []) - if (failures.length === 1) throw failures[0] - if (failures.length > 1) throw new AggregateError(failures, 'local subprocess teardown failed') }, 'local subprocess teardown') } + private terminateForHostExit(): void { + for (const handle of this.live) { + try { + handle.terminateForHostExit() + } catch (_ordinaryTreeTerminationFailed) { + // Host exit cannot await or report one target; continue with the rest. + } + } + for (const terminal of this.terminals) { + try { + terminal.terminateForHostExit() + } catch (_terminalTerminationFailed) { + // One terminal must not prevent final termination of another target. + } + } + } + + private async disposeManagedProcesses(): Promise { + // Terminate (escalating), then await WHOLE-TREE exit — not just the + // direct child's settlement — so even a TERM-trapping descendant cannot + // outlive the fiber. Keep both sets authoritative while these waits are + // pending so a shorter process-level exit bound can still force-kill them. + const pending: Promise[] = [] + for (const handle of this.live) { + handle.terminate() + // Spawn-failure rejections already settled and left the live set. + pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit())) + } + for (const terminal of this.terminals) { + pending.push(terminal.terminate()) + } + const outcomes = await Promise.allSettled(pending) + const failures = outcomes.flatMap(outcome => outcome.status === 'rejected' + ? [outcome.reason as unknown] + : []) + if (failures.length > 0) this.terminateForHostExit() + this.live.clear() + this.terminals.clear() + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'local subprocess teardown failed') + } + async resolveExecutable( command: string, env?: Readonly>, diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 5b977cacc1..433ba01791 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -58,6 +58,16 @@ export interface SpawnInternals { linuxProcessGroupHasLiveMembers?: (processGroupId: number) => boolean | undefined } +/** + * Local-only synchronous final termination used by the owning service during + * host exit and as the last fallback after failed normal disposal. It is + * intentionally absent from the public subprocess seam. + */ +export interface LocalSubprocessHandle extends SubprocessHandle { + /** Force-terminate the current tree synchronously without starting timers or waits. */ + terminateForHostExit(): void +} + /** * Liveness-poll cadence for tree-exit waits. The timer stays ref'd: an * awaited teardown must keep the event loop alive until the tree really @@ -313,7 +323,7 @@ function signalTree( * @returns live subprocess handle. * @throws when `graceMs` cannot be represented by one Node timer. */ -export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): SubprocessHandle { +export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): LocalSubprocessHandle { if (!Number.isFinite(spec.graceMs) || spec.graceMs <= 0 || spec.graceMs > MAX_TIMER_DELAY_MS) { throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) } @@ -442,6 +452,10 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter graceTimer = setTimeout(() => { kill('SIGKILL') }, spec.graceMs) } + const terminateForHostExit = (): void => { + kill('SIGKILL') + } + // The caller owns timeout classification; this layer only reacts to abort. const onAbort = (): void => { terminate() } spec.signal?.addEventListener('abort', onAbort, { once: true }) @@ -523,6 +537,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter }, done, terminate, + terminateForHostExit, waitForExit, } } diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index 11d13a405a..6d818c8a7f 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/packages/subprocess/subprocess-local/src/terminal.ts @@ -110,6 +110,33 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { return cleanup } + /** + * Force-terminate the observable session synchronously during Node's exit + * event. This does not claim quiescence and does not replace terminate(). + */ + terminateForHostExit(): void { + this.forceStopDescendants() + this.forceStopShell() + this.forceStopDescendants() + } + + private forceStopShell(): void { + if (this.exited) return + if (this.rootIdentity !== undefined) { + try { + this.inspector.signalProcess(this.rootIdentity, 'SIGKILL') + } catch (_rootExitedDuringHostExit) { + // Exact identity signalling contains both exit races and PID reuse. + } + return + } + try { + this.terminal.kill('SIGKILL') + } catch (_unidentifiedShellExitedDuringHostExit) { + // Without a captured identity, node-pty is the only root kill primitive. + } + } + private survivors(members: ProcessIdentity[]): ProcessIdentity[] { return members.filter(member => this.inspector.isAlive(member)) } @@ -152,6 +179,16 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { } } + private forceStopDescendants(): void { + let members = this.trackedDescendants + try { + members = this.descendants() + } catch (_processTableUnavailableDuringHostExit) { + // Preserve already-captured identities when a final process-table scan fails. + } + this.signalMembers(members, 'SIGKILL') + } + private unionMembers(...groups: ProcessIdentity[][]): ProcessIdentity[] { const members: ProcessIdentity[] = [] const seen = new Set() diff --git a/packages/subprocess/subprocess-local/tests/fixtures/managed-tree.ts b/packages/subprocess/subprocess-local/tests/fixtures/managed-tree.ts new file mode 100644 index 0000000000..31d26b9e39 --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/fixtures/managed-tree.ts @@ -0,0 +1,16 @@ +import { spawn } from 'node:child_process' +import { writeFile } from 'node:fs/promises' + +const [statePath] = process.argv.slice(2) +if (statePath === undefined) throw new Error('usage: managed-tree.ts ') + +process.on('SIGTERM', () => {}) +process.on('SIGHUP', () => {}) +const descendant = spawn(process.execPath, [ + '-e', + 'process.on("SIGTERM",()=>{});process.on("SIGHUP",()=>{});setInterval(()=>{},60_000)', +], { stdio: 'ignore' }) +if (descendant.pid === undefined) throw new Error('managed descendant did not publish a pid') + +await writeFile(statePath, JSON.stringify({ root: process.pid, descendant: descendant.pid })) +setInterval(() => {}, 60_000) diff --git a/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts new file mode 100644 index 0000000000..83b4664cae --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts @@ -0,0 +1,79 @@ +import { access, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' + +const [kind, trigger, root] = process.argv.slice(2) +if ((kind !== 'ordinary' && kind !== 'terminal') + || (trigger !== 'direct' && trigger !== 'uncaught-exception' + && trigger !== 'unhandled-rejection' && trigger !== 'dispose') + || root === undefined) { + throw new Error('usage: process-exit-host.ts ') +} + +const treeState = join(root, 'tree.json') +const ready = join(root, 'ready') +const proceed = join(root, 'proceed') +const managedTree = fileURLToPath(new URL('./managed-tree.ts', import.meta.url)) + +async function waitForFile(path: string): Promise { + for (;;) { + try { + await access(path) + return + } catch (_notReady) { + await new Promise(resolve => setTimeout(resolve, 10)) + } + } +} + +const listenersBefore = process.listenerCount('exit') +const ctx = new Context() +const fiber = await ctx.plugin(LocalSubprocessService) +const listenersAfterLoad = process.listenerCount('exit') +if (kind === 'ordinary') { + ctx.subprocess.spawn({ + argv: [process.execPath, managedTree, treeState], + cwd: process.cwd(), + stdio: { + stdin: 'ignore', + stdout: { maxBytes: 1024 }, + stderr: { maxBytes: 1024 }, + }, + graceMs: trigger === 'dispose' ? 100 : 30_000, + }) +} else { + await ctx.subprocess.spawnTerminal({ + argv: [process.execPath, managedTree, treeState], + cwd: process.cwd(), + rows: 24, + cols: 80, + graceMs: 30_000, + }) +} + +await waitForFile(treeState) +const published = JSON.parse(await readFile(treeState, 'utf8')) as { root?: unknown; descendant?: unknown } +if (!Number.isSafeInteger(published.root) || !Number.isSafeInteger(published.descendant)) { + throw new Error('managed tree published invalid process ids') +} +await writeFile(ready, 'ready') +await waitForFile(proceed) + +if (trigger === 'dispose') { + await fiber.dispose() + await writeFile(join(root, 'dispose.json'), JSON.stringify({ + listenersBefore, + listenersAfterLoad, + listenersAfterDispose: process.listenerCount('exit'), + })) +} else if (trigger === 'direct') { + process.exit(23) +} else if (trigger === 'uncaught-exception') { + setImmediate(() => { throw new Error('host-exit-uncaught-exception') }) + await new Promise(() => {}) +} else { + void Promise.reject(new Error('host-exit-unhandled-rejection')) + await new Promise(() => {}) +} diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index e3131543f4..412f55a49c 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -21,6 +21,94 @@ function spec(command: string, overrides: Partial = {}): Su } describe('LocalSubprocessService', () => { + it('places the host-exit finalizer before listeners that predate the service', async () => { + const baseline = new Set(process.listeners('exit')) + const prior = vi.fn() + process.on('exit', prior) + const ctx = new Context() + const fiber = await ctx.plugin(LocalSubprocessService) + try { + const listeners = process.listeners('exit') + const finalizer = listeners.find(candidate => !baseline.has(candidate) && candidate !== prior) + expect(finalizer).toBeTypeOf('function') + expect(listeners.indexOf(finalizer!)).toBeLessThan(listeners.indexOf(prior)) + } finally { + process.off('exit', prior) + await fiber.dispose() + } + }) + + it('keeps the host-exit finalizer active until normal disposal reaches quiescence', async () => { + const before = new Set(process.listeners('exit')) + const ctx = new Context() + const fiber = await ctx.plugin(LocalSubprocessService) + const listener = process.listeners('exit').find(candidate => !before.has(candidate)) + expect(listener).toBeTypeOf('function') + + let finishExit!: () => void + const exited = new Promise((resolve) => { finishExit = resolve }) + const terminate = vi.fn() + const terminateForHostExit = vi.fn() + const live = (ctx.subprocess as unknown as { + live: Set<{ + done: Promise<{ exitCode: number; signal: null }> + terminate(): void + terminateForHostExit(): void + waitForExit(): Promise + }> + }).live + live.add({ + done: Promise.resolve({ exitCode: 0, signal: null }), + terminate, + terminateForHostExit, + waitForExit: async () => { await exited; return true }, + }) + + let disposed = false + const disposing = fiber.dispose().then(() => { disposed = true }) + await new Promise(resolve => setImmediate(resolve)) + expect(disposed).toBe(false) + expect(live.size).toBe(1) + listener?.(0) + expect(terminate).toHaveBeenCalledOnce() + expect(terminateForHostExit).toHaveBeenCalledOnce() + + finishExit() + await disposing + expect(live.size).toBe(0) + expect(process.listeners('exit')).not.toContain(listener) + }) + + it('contains each host-exit termination failure and continues with the other targets', async () => { + const before = new Set(process.listeners('exit')) + const ctx = new Context() + const fiber = await ctx.plugin(LocalSubprocessService) + const listener = process.listeners('exit').find(candidate => !before.has(candidate)) + expect(listener).toBeTypeOf('function') + const ordinaryFailure = vi.fn(() => { throw new Error('ordinary failed') }) + const ordinarySuccess = vi.fn() + const terminalFailure = vi.fn(() => { throw new Error('terminal failed') }) + const terminalSuccess = vi.fn() + const service = ctx.subprocess as unknown as { + live: Set<{ terminateForHostExit(): void }> + terminals: Set<{ terminateForHostExit(): void }> + } + service.live.add({ terminateForHostExit: ordinaryFailure }) + service.live.add({ terminateForHostExit: ordinarySuccess }) + service.terminals.add({ terminateForHostExit: terminalFailure }) + service.terminals.add({ terminateForHostExit: terminalSuccess }) + + expect(() => { listener?.(0) }).not.toThrow() + expect(ordinaryFailure).toHaveBeenCalledOnce() + expect(ordinarySuccess).toHaveBeenCalledOnce() + expect(terminalFailure).toHaveBeenCalledOnce() + expect(terminalSuccess).toHaveBeenCalledOnce() + + service.live.clear() + service.terminals.clear() + await fiber.dispose() + }) + it('resolves absolute and PATH executables and honors lookup cancellation', async () => { const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessService) @@ -177,6 +265,30 @@ describe('LocalSubprocessService', () => { expect(disposalErrors).toEqual([failure]) }) + it('force-terminates remaining targets before releasing a failed disposal', async () => { + const before = new Set(process.listeners('exit')) + const ctx = new Context() + const fiber = await ctx.plugin(LocalSubprocessService) + const listener = process.listeners('exit').find(candidate => !before.has(candidate)) + expect(listener).toBeTypeOf('function') + const failure = new Error('cleanup failed') + const terminateForHostExit = vi.fn(() => { + expect(process.listeners('exit')).toContain(listener) + }) + const terminal = { + terminate: vi.fn(async () => { throw failure }), + terminateForHostExit, + } + const terminals = (ctx.subprocess as unknown as { terminals: Set }).terminals + terminals.add(terminal) + + await fiber.dispose() + + expect(terminateForHostExit).toHaveBeenCalledOnce() + expect(terminals.size).toBe(0) + expect(process.listeners('exit')).not.toContain(listener) + }) + it('releases a terminal after top-level exit reaches quiescence', async () => { let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined const inspector = { diff --git a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts new file mode 100644 index 0000000000..217338fa1e --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts @@ -0,0 +1,173 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { execa } from 'execa' +import { describe, expect, it, vi } from 'vitest' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +import { createProcessInspector } from '../src/process-inspector.ts' +import type { ProcessIdentity, ProcessInspector } from '../src/process-inspector.ts' +import { taskkillProcessTree } from '../src/spawn.ts' + +type ExitTrigger = 'direct' | 'uncaught-exception' | 'unhandled-rejection' | 'dispose' +type ManagedKind = 'ordinary' | 'terminal' +interface TreeState { root: number; descendant: number } + +const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) +const hostScript = fileURLToPath(new URL('./fixtures/process-exit-host.ts', import.meta.url)) +const scenarioTimeoutMs = 30_000 + +function processExists(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false + throw error + } +} + +async function readTree(path: string): Promise { + return vi.waitFor(async () => { + const text = await readFile(path, 'utf8') + const state = JSON.parse(text) as Partial + if (!Number.isSafeInteger(state.root) || !Number.isSafeInteger(state.descendant) + || (state.root ?? 0) <= 0 || (state.descendant ?? 0) <= 0 || state.root === state.descendant) { + throw new Error(`invalid managed-tree state: ${text}`) + } + return state as TreeState + }, { interval: 10, timeout: scenarioTimeoutMs }) +} + +async function captureIdentities(inspector: ProcessInspector, state: TreeState): Promise { + return vi.waitFor(() => { + const expected = new Set([state.root, state.descendant]) + const identities = inspector.processTree(state.root).filter(identity => expected.has(identity.pid)) + if (identities.length !== expected.size) throw new Error('managed tree is not fully observable yet') + return identities + }, { interval: 10, timeout: scenarioTimeoutMs }) +} + +async function waitForGone(state: TreeState): Promise { + await Promise.all([state.root, state.descendant].map(pid => vi.waitFor(() => { + if (processExists(pid)) throw new Error(`managed pid ${pid} is still alive`) + }, { interval: 25, timeout: 10_000 }))) +} + +function cleanupTree(state: TreeState | undefined, identities: ProcessIdentity[]): void { + if (state === undefined) return + if (process.platform === 'win32') { + taskkillProcessTree(state.root) + for (const pid of [state.descendant, state.root]) { + try { + process.kill(pid, 'SIGKILL') + } catch (_alreadyGone) { + // The exact recorded process already exited. + } + } + return + } + const inspector = createProcessInspector() + for (const identity of identities) { + try { + inspector.signalProcess(identity, 'SIGKILL') + } catch (_alreadyGone) { + // Exact start identity prevents PID-reuse cleanup from reaching another process. + } + } + if (identities.length === 0) { + for (const pid of [state.descendant, state.root]) { + try { + process.kill(pid, 'SIGKILL') + } catch (_alreadyGone) { + // The scenario failed before process identities became observable. + } + } + } +} + +async function runScenario(kind: ManagedKind, trigger: ExitTrigger) { + const root = await mkdtemp(join(tmpdir(), `dsh-subprocess-host-exit-${kind}-${trigger}-`)) + const launch = resolveExampleLaunch({ + srcBin: hostScript, + mode: 'src', + tsconfigPath: join(repoRoot, 'tsconfig.json'), + configArgs: [kind, trigger, root], + }) + const child = execa(launch.command, launch.args, { + cwd: repoRoot, + env: launch.env, + stdin: 'ignore', + reject: false, + timeout: scenarioTimeoutMs, + }) + let state: TreeState | undefined + let identities: ProcessIdentity[] = [] + let settled = false + let treeGone = false + try { + state = await readTree(join(root, 'tree.json')) + await vi.waitFor(() => readFile(join(root, 'ready'), 'utf8'), { + interval: 10, + timeout: scenarioTimeoutMs, + }) + if (process.platform !== 'win32') identities = await captureIdentities(createProcessInspector(), state) + await writeFile(join(root, 'proceed'), 'proceed') + const outcome = await child + settled = true + await waitForGone(state) + treeGone = true + const disposeCounts = trigger === 'dispose' + ? JSON.parse(await readFile(join(root, 'dispose.json'), 'utf8')) as { + listenersBefore: number + listenersAfterLoad: number + listenersAfterDispose: number + } + : undefined + return { outcome, disposeCounts } + } finally { + if (!settled) { + child.kill('SIGKILL') + await child.catch(() => {}) + } + if (!treeGone) { + cleanupTree(state, identities) + if (state !== undefined) await waitForGone(state).catch(() => {}) + } + await rm(root, { recursive: true, force: true }) + } +} + +describe('synchronous cleanup on host exit', () => { + it.each([ + { trigger: 'direct' as const, expectedCode: 23, diagnostic: undefined }, + { trigger: 'uncaught-exception' as const, expectedCode: 1, diagnostic: 'host-exit-uncaught-exception' }, + { trigger: 'unhandled-rejection' as const, expectedCode: 1, diagnostic: 'host-exit-unhandled-rejection' }, + ])('removes an ordinary managed tree after $trigger', { timeout: 45_000 }, async ({ + trigger, + expectedCode, + diagnostic, + }) => { + const { outcome } = await runScenario('ordinary', trigger) + expect(outcome.exitCode).toBe(expectedCode) + expect(outcome.signal).toBeUndefined() + if (diagnostic !== undefined) expect(outcome.stderr).toContain(diagnostic) + }) + + it.skipIf(process.platform === 'win32')( + 'removes a terminal root and descendant after direct exit', + { timeout: 45_000 }, + async () => { + const { outcome } = await runScenario('terminal', 'direct') + expect(outcome.exitCode).toBe(23) + expect(outcome.signal).toBeUndefined() + }, + ) + + it('preserves normal terminate-and-join disposal and removes the exit listener', { timeout: 45_000 }, async () => { + const { outcome, disposeCounts } = await runScenario('ordinary', 'dispose') + expect(outcome.exitCode).toBe(0) + expect(disposeCounts?.listenersAfterLoad).toBe((disposeCounts?.listenersBefore ?? 0) + 1) + expect(disposeCounts?.listenersAfterDispose).toBe(disposeCounts?.listenersBefore) + }) +}) diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 4cffde6432..568a331a28 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -582,9 +582,28 @@ describe('stdio dispositions', () => { }) describe('windows tree semantics (injected platform)', () => { + it('host-exit termination routes through taskkill immediately', async () => { + const killed: number[] = [] + const running = spawnSubprocess(spec('exec sleep 60', { graceMs: 60_000 }), { + spillDir, + platform: 'win32', + taskkill: (pid) => { + killed.push(pid) + try { + process.kill(pid, 'SIGKILL') + } catch { + // Already gone — matches taskkill's tolerated not-found status. + } + }, + }) + running.terminateForHostExit() + await running.done + expect(killed).toEqual([running.pid]) + }) + it('terminate routes through taskkill by root pid', async () => { const killed: number[] = [] - const running = spawnSubprocess(spec('sleep 60', { graceMs: 100 }), { + const running = spawnSubprocess(spec('exec sleep 60', { graceMs: 100 }), { spillDir, platform: 'win32', taskkill: (pid) => { @@ -631,6 +650,23 @@ describe('waitForExit', () => { }) }) +describe('synchronous host-exit termination', () => { + it('force-kills the current process tree without waiting for the normal grace', async () => { + const running = spawnSubprocess(spec('trap "" TERM; sleep 60', { graceMs: 60_000 })) + running.terminateForHostExit() + await expect(running.done).resolves.toMatchObject({ exitCode: null, signal: 'SIGKILL' }) + await expect(running.waitForExit()).resolves.toBe(true) + + const kill = vi.spyOn(process, 'kill') + try { + running.terminateForHostExit() + expect(kill).not.toHaveBeenCalled() + } finally { + kill.mockRestore() + } + }) +}) + describe('tree-survivor escalation (terminate and bounded waits reach helpers the leader left behind)', () => { it('terminate() SIGKILLs a TERM-trapping descendant after the direct child settles', async () => { // The leader spawns a TERM-trapping helper with all stdio detached from diff --git a/packages/subprocess/subprocess-local/tests/terminal.spec.ts b/packages/subprocess/subprocess-local/tests/terminal.spec.ts index 79501c7dc4..4bfd9f1025 100644 --- a/packages/subprocess/subprocess-local/tests/terminal.spec.ts +++ b/packages/subprocess/subprocess-local/tests/terminal.spec.ts @@ -74,6 +74,7 @@ class FakeInspector implements ProcessInspector { } signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL') { if (this.throwProcess) throw new Error('process raced') + if (!this.isAlive(identity)) return this.processes.push([identity.pid, signal]) if (this.removeOnSignal) this.alive.delete(identity.pid) } @@ -82,6 +83,85 @@ class FakeInspector implements ProcessInspector { afterEach(() => { vi.useRealTimers() }) describe('LocalTerminalHandle', () => { + it('force-kills descendants around the shell during synchronous host exit', () => { + const pty = new FakePty() + const inspector = new FakeInspector() + const first = { pid: 124, started: 'first' } + const late = { pid: 125, started: 'late' } + inspector.members = [first] + inspector.alive.add(pty.pid) + inspector.alive.add(first.pid) + const signalProcess = inspector.signalProcess.bind(inspector) + inspector.signalProcess = (identity, signal) => { + signalProcess(identity, signal) + if (identity.pid === pty.pid) { + inspector.members = [first, late] + inspector.alive.add(late.pid) + } + } + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + + handle.terminateForHostExit() + expect(inspector.processes).toEqual([ + [first.pid, 'SIGKILL'], + [pty.pid, 'SIGKILL'], + [late.pid, 'SIGKILL'], + ]) + expect(pty.kills).toEqual([]) + + pty.emitExit() + handle.terminateForHostExit() + expect(pty.kills).toEqual([]) + }) + + it('uses captured identities and contains shell races when final inspection fails', async () => { + const pty = new FakePty() + const inspector = new FakeInspector() + const captured = { pid: 124, started: 'captured' } + inspector.members = [captured] + inspector.alive.add(pty.pid) + inspector.alive.add(captured.pid) + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + await handle.inspectForeground() + inspector.processTree = () => { throw new Error('process table unavailable') } + inspector.throwProcess = true + + expect(() => { handle.terminateForHostExit() }).not.toThrow() + expect(inspector.processes).toEqual([]) + expect(pty.kills).toEqual([]) + }) + + it('uses node-pty only when the shell start identity was unavailable', () => { + const pty = new FakePty() + const inspector = new FakeInspector() + inspector.root = undefined + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + + handle.terminateForHostExit() + expect(pty.kills).toEqual(['SIGKILL']) + + const racingPty = new FakePty() + const racingInspector = new FakeInspector() + racingInspector.root = undefined + racingPty.throwKill = true + const racingHandle = new LocalTerminalHandle(racingPty.asPty(), racingInspector, 10) + expect(() => { racingHandle.terminateForHostExit() }).not.toThrow() + }) + + it('does not signal a recycled terminal root before its delayed exit callback', () => { + const pty = new FakePty() + const inspector = new FakeInspector() + inspector.alive.add(pty.pid) + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + inspector.root = { pid: pty.pid, started: 'recycled' } + inspector.isAlive = identity => identity.started === 'recycled' + + handle.terminateForHostExit() + + expect(inspector.processes).toEqual([]) + expect(pty.kills).toEqual([]) + }) + it('bridges terminal bytes, foreground control, and signalled exit facts', async () => { const pty = new FakePty() const inspector = new FakeInspector() diff --git a/packages/tasks/tasks-local/README.i18n.yaml b/packages/tasks/tasks-local/README.i18n.yaml index 2ded395a05..e426b7afa5 100644 --- a/packages/tasks/tasks-local/README.i18n.yaml +++ b/packages/tasks/tasks-local/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/tasks/tasks-local/README.md -README.md: cc2e8422c367eeacfc5fc504298ecd6bfeae4c67 -README.zh.md: f4a88b9549dd967f0d8e3c3c329a975bbad83e85 +README.md: f558676b36bb5462453bde553eac27b458e1268e +README.zh.md: 1f1d47ffc806eb7c9471929796ea792def4ddd59 diff --git a/packages/tasks/tasks-local/README.md b/packages/tasks/tasks-local/README.md index cc2e8422c3..f558676b36 100644 --- a/packages/tasks/tasks-local/README.md +++ b/packages/tasks/tasks-local/README.md @@ -2,7 +2,13 @@ English | [中文](README.zh.md) -Process-local implementation of the [`@deepseek-ai/dsh-tasks`](../tasks/README.md) registry contract: `LocalTaskService` keeps every record in memory, issues per-kind `-N` ids, and hands out fresh snapshots, never live state. It has no config; load it as a plugin and it registers as `ctx.tasks`. +Process-local implementation of the [`@deepseek-ai/dsh-tasks`](../tasks/README.md) registry contract: `LocalTaskService` keeps every record in memory, issues per-kind `-N` ids, and hands out fresh snapshots, never live state. Load it as a plugin and it registers as `ctx.tasks`. + +## Admission + +`maxConcurrentTasksPerOwner` is a positive safe integer and defaults to `10`. Before invoking a producer, `start()` counts the exact owner's `running` and `stopping` records; all unowned tasks share one separate service bucket. Terminal history does not occupy capacity, and only producer `done` settlement releases a stopping task's place. + +At capacity, `start()` fails before producer execution and id allocation with an error that names the limit and tells the model to use `task_kill`, wait for the task to finish stopping, and retry. The registry does not queue, preempt, or maintain a second mutable counter. ## Lifecycle @@ -25,4 +31,4 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work - **Tasks are process-local** — records die with the harness process; durable or cross-restart execution needs a separate backend implementing the seam. -- **A silently ineffective cancel can stall teardown** — only an explicit throw can be force-failed safely. +- **A silently ineffective cancel can stall teardown and hold capacity** — if `cancel` returns without settling `done`, the registry cannot distinguish it from a slow stop; the task keeps one bucket slot for the rest of the service lifetime, and only an explicit throw can be force-failed safely. diff --git a/packages/tasks/tasks-local/README.zh.md b/packages/tasks/tasks-local/README.zh.md index f4a88b9549..1f1d47ffc8 100644 --- a/packages/tasks/tasks-local/README.zh.md +++ b/packages/tasks/tasks-local/README.zh.md @@ -2,7 +2,13 @@ [English](README.md) | 中文 -[`@deepseek-ai/dsh-tasks`](../tasks/README.md) 注册表约定的进程本地实现:`LocalTaskService` 把每条记录保存在内存中,按 kind 签发 `-N` id,并且只交出全新快照,从不交出实时状态。它没有配置;作为插件加载后即注册为 `ctx.tasks`。 +[`@deepseek-ai/dsh-tasks`](../tasks/README.md) 注册表约定的进程本地实现:`LocalTaskService` 把每条记录保存在内存中,按 kind 签发 `-N` id,并且只交出全新快照,从不交出实时状态。作为插件加载后即注册为 `ctx.tasks`。 + +## 准入 + +`maxConcurrentTasksPerOwner` 必须是正的安全整数,默认值为 `10`。调用生产方之前,`start()` 会统计确切 owner 的 `running` 与 `stopping` 记录;所有无 owner 任务共享另一个独立的服务级桶。终止历史不占用容量,处于 `stopping` 的任务只有在生产方 `done` 结算后才释放名额。 + +达到容量时,`start()` 会在生产方执行和 id 分配前失败;错误会给出上限,并告诉模型使用 `task_kill`、等待任务完全停稳后再重试。注册表不会排队或抢占任务,也不会维护第二份可变计数。 ## 生命周期 @@ -25,4 +31,4 @@ ## 已知限制与暂缓事项 - **任务只存在于进程本地**:记录会随 harness 进程终止而消失;持久或跨重启执行需要一个单独实现该 seam 的后端。 -- **静默无效的取消可能使销毁过程停滞**:只有显式抛出异常才能安全地强制标为失败。 +- **静默无效的取消可能使销毁过程停滞并持续占用容量**:如果 `cancel` 返回后始终未结算 `done`,注册表就无法将其与缓慢停止区分开;该任务会在服务剩余生命周期内持续占用一个桶名额,只有显式抛出异常才能安全地强制标为失败。 diff --git a/packages/tasks/tasks-local/package.json b/packages/tasks/tasks-local/package.json index 67c851607e..5f57f001ec 100644 --- a/packages/tasks/tasks-local/package.json +++ b/packages/tasks/tasks-local/package.json @@ -39,7 +39,12 @@ "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, "devDependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts index 73c56b9ca6..3d75f8dca4 100644 --- a/packages/tasks/tasks-local/src/index.ts +++ b/packages/tasks/tasks-local/src/index.ts @@ -10,6 +10,7 @@ */ import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import { AnonymousEntries, ScopedLayers, scopeOf } from '@deepseek-ai/dsh-scope' import type { ScopeLayer } from '@deepseek-ai/dsh-scope' @@ -23,6 +24,18 @@ import type { /** Timeout code that distinguishes a bounded wait from caller cancellation. */ export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT' +/** Default maximum number of active tasks in one exact-owner bucket. */ +const DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER = 10 + +/** Configuration for the process-local task registry. */ +export interface Config { + /** + * Maximum `running` plus `stopping` tasks per exact owner or in the shared unowned bucket; + * omission defaults to 10. + */ + maxConcurrentTasksPerOwner?: number +} + /** The registry's mutable per-task record (never handed out — see {@link LocalTaskService.snapshot}). */ interface TrackedTask { id: TaskId @@ -76,6 +89,16 @@ class TaskLayer implements ScopeLayer { * semantics this implementation honors. */ export class LocalTaskService extends TaskService { + static Config: z = z.object({ + maxConcurrentTasksPerOwner: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER), + }) + + /** Schemastery-defaulted active-task limit. */ + private readonly maxConcurrentTasksPerOwner: number private store = new Map() private counters = new Map() /** @@ -97,8 +120,10 @@ export class LocalTaskService extends TaskService { /** Service context used by detached settlement continuations and teardown. */ private readonly selfCtx: Context - constructor(ctx: Context) { + constructor(ctx: Context, config: Config) { super(ctx) + // Schemastery validates and fills the default before constructing the service. + this.maxConcurrentTasksPerOwner = (config as Required).maxConcurrentTasksPerOwner this.selfCtx = ctx ctx.effect(() => () => this.disposeAll(), 'tasks teardown') } @@ -115,6 +140,13 @@ export class LocalTaskService extends TaskService { } if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner) + const active = this.activeTaskCount(spec.owner) + if (active >= this.maxConcurrentTasksPerOwner) { + throw new Error( + `background task limit reached for this owner (limit: ${this.maxConcurrentTasksPerOwner}); use task_kill to stop an unneeded task, wait for it to finish, then retry`, + ) + } + const hooks = spec.run() const count = (this.counters.get(spec.kind) ?? 0) + 1 this.counters.set(spec.kind, count) @@ -286,6 +318,15 @@ export class LocalTaskService extends TaskService { .some(layer => !layer.controllers.isEmpty()) } + /** Count authoritative active records for one exact owner or the shared unowned bucket. */ + private activeTaskCount(owner: Agent | undefined): number { + let count = 0 + for (const task of this.store.values()) { + if (task.owner === owner && (task.status === 'running' || task.status === 'stopping')) count += 1 + } + return count + } + /** * The completion listeners that own `owner`'s notices: the global layer's * first, then each scoped layer along the owner's chain. A listener outside diff --git a/packages/tasks/tasks-local/src/invariant.ts b/packages/tasks/tasks-local/src/invariant.ts index 21d00ed155..aa9cb98673 100644 --- a/packages/tasks/tasks-local/src/invariant.ts +++ b/packages/tasks/tasks-local/src/invariant.ts @@ -15,8 +15,11 @@ export const name = 'tasks-local-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the Service Definition companion in `@deepseek-ai/dsh-tasks` already - * validates every registry snapshot this implementation publishes. + * No runtime invariant: `@deepseek-ai/dsh-tasks/invariant` owns per-snapshot identity, status, + * timestamp, and owner checks. This provider's admission decision uses private configuration and + * must fail before a backend starter runs; `LocalTaskService.start()` enforces it synchronously + * for current producers. Repeating an aggregate after publication would expose private + * configuration solely to this companion and would not verify the fail-closed pre-start guarantee. */ const install: InvariantInstaller = () => {} diff --git a/packages/tasks/tasks-local/tests/loader-composition.spec.ts b/packages/tasks/tasks-local/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..9d4b09b6ff --- /dev/null +++ b/packages/tasks/tasks-local/tests/loader-composition.spec.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Include from '@deepseek-ai/cordis-plugin-include' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +describe('tasks-local through a real Loader composition', () => { + it('applies the provider-owned admission config from a Cordis row', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-tasks-local-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-tasks-local'", + ' config:', + ' maxConcurrentTasksPerOwner: 1', + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (specifier === '@deepseek-ai/dsh-tasks-local') return LocalTaskService + throw new Error(`unexpected Loader import: ${specifier}`) + }, + } as unknown as NonNullable + await context.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await context.loader.await() + + expect(context.tasks).toBeInstanceOf(LocalTaskService) + context.tasks.attachController('loader-test') + let settle!: (outcome: { status: 'killed' }) => void + context.tasks.start({ + kind: 'bash', + label: 'hold loader slot', + run: () => ({ + cancel: () => { settle({ status: 'killed' }) }, + done: new Promise((resolve) => { settle = resolve }), + }), + }) + expect(() => context!.tasks.start({ + kind: 'bash', + label: 'blocked loader task', + run: () => ({ cancel: () => {}, done: Promise.resolve({ status: 'completed' }) }), + })).toThrow('(limit: 1)') + }) +}) diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index 56c3fa9181..7fe34c521e 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -7,7 +7,7 @@ import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' import type { ScopeKey } from '@deepseek-ai/dsh-scope' import { TaskId } from '@deepseek-ai/dsh-tasks' import type { TaskHooks, TaskKind, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks' -import LocalTaskService from '@deepseek-ai/dsh-tasks-local' +import LocalTaskService, { type Config as TasksConfig } from '@deepseek-ai/dsh-tasks-local' declare module '@deepseek-ai/dsh-tasks' { interface TaskKindMap { @@ -76,10 +76,10 @@ function producer(overrides: Partial & TaskHooks> = {}) { return { spec, settle, reject, cancels } } -async function harness() { +async function harness(config: TasksConfig = {}) { const ctx = new Context() await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalTaskService) + await ctx.plugin(LocalTaskService, config) ctx.tasks.attachController('test-controller') return ctx } @@ -166,6 +166,101 @@ describe('LocalTaskService.start', () => { expect(() => ctx.tasks.start(producer({ outputLimitBytes: 0 }).spec)).toThrow('outputLimitBytes') }) + it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1])( + 'rejects invalid maxConcurrentTasksPerOwner config: %s', + async (maxConcurrentTasksPerOwner) => { + const ctx = new Context() + await expect(ctx.plugin(LocalTaskService, { maxConcurrentTasksPerOwner })) + .rejects.toThrow() + }, + ) + + it('accepts the largest safe integer limit', async () => { + const ctx = await harness({ maxConcurrentTasksPerOwner: Number.MAX_SAFE_INTEGER }) + expect(ctx.tasks).toBeInstanceOf(LocalTaskService) + }) + + it('defaults each owner bucket to ten active tasks', async () => { + const ctx = await harness() + const live = Array.from({ length: 10 }, () => producer()) + for (const task of live) ctx.tasks.start(task.spec) + + const blocked = producer() + const run = vi.fn(() => blocked.spec.run()) + expect(() => ctx.tasks.start({ ...blocked.spec, run })) + .toThrow('background task limit reached for this owner (limit: 10)') + expect(run).not.toHaveBeenCalled() + for (const task of live) task.settle({ status: 'completed' }) + }) + + it('rejects before producer start and id allocation, then admits immediately after settlement', async () => { + const ctx = await harness({ maxConcurrentTasksPerOwner: 1 }) + const first = producer() + expect(ctx.tasks.start(first.spec)).toBe('bash-1') + + const blocked = producer() + const run = vi.fn(() => blocked.spec.run()) + expect(() => ctx.tasks.start({ ...blocked.spec, run })) + .toThrow('use task_kill to stop an unneeded task, wait for it to finish, then retry') + expect(run).not.toHaveBeenCalled() + + first.settle({ status: 'completed' }) + await tick() + expect(ctx.tasks.start(blocked.spec)).toBe('bash-2') + }) + + it('keeps a stopping task in the bucket until producer settlement', async () => { + const ctx = await harness({ maxConcurrentTasksPerOwner: 1 }) + const first = producer() + const id = ctx.tasks.start(first.spec) + expect(ctx.tasks.kill(id)).toBe('requested') + + const replacement = producer() + expect(() => ctx.tasks.start(replacement.spec)).toThrow('(limit: 1)') + + first.settle({ status: 'killed' }) + await tick() + expect(ctx.tasks.start(replacement.spec)).toBe('bash-2') + }) + + it.each(['completed', 'killed', 'failed'] as const)( + 'releases the bucket after a %s terminal outcome', + async (status) => { + const ctx = await harness({ maxConcurrentTasksPerOwner: 1 }) + const first = producer() + ctx.tasks.start(first.spec) + first.settle({ status }) + await tick() + expect(() => ctx.tasks.start(producer().spec)).not.toThrow() + }, + ) + + it('isolates exact owners, replacement objects with the same session id, and the unowned bucket', async () => { + const ctx = await harness({ maxConcurrentTasksPerOwner: 1 }) + const oldOwner = stubAgent(ctx, 'shared-session') + const detachOld = ctx.agents.register(oldOwner) + const oldTask = producer({ owner: oldOwner }) + ctx.tasks.start(oldTask.spec) + + const otherOwner = stubAgent(ctx, 'other-session') + ctx.agents.register(otherOwner) + expect(() => ctx.tasks.start(producer({ owner: otherOwner }).spec)).not.toThrow() + + detachOld() + const replacement = stubAgent(ctx, 'shared-session') + ctx.agents.register(replacement) + expect(() => ctx.tasks.start(producer({ owner: replacement }).spec)).not.toThrow() + + ctx.tasks.start(producer().spec) + expect(() => ctx.tasks.start(producer().spec)).toThrow('(limit: 1)') + expect(() => ctx.tasks.start(producer({ owner: oldOwner }).spec)) + .toThrow('is not the registered agent instance') + + oldTask.settle({ status: 'completed' }) + await tick() + await disposeAgentScope(oldOwner) + }) + it('issues kind-prefixed ids from per-kind counters', async () => { const ctx = await harness() expect(ctx.tasks.start(producer().spec)).toBe('bash-1') diff --git a/packages/tasks/tasks-local/tsconfig.json b/packages/tasks/tasks-local/tsconfig.json index 4e9a3e20bf..ef21585d7c 100644 --- a/packages/tasks/tasks-local/tsconfig.json +++ b/packages/tasks/tasks-local/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../core/agent" }, diff --git a/packages/tasks/tasks/README.i18n.yaml b/packages/tasks/tasks/README.i18n.yaml index 2a76bd42d6..1a4f9fdf08 100644 --- a/packages/tasks/tasks/README.i18n.yaml +++ b/packages/tasks/tasks/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/tasks/tasks/README.md -README.md: 18029a2e93396336139612ba72804aeb11e87edf -README.zh.md: 611014c820c2d2d1e0891a93352a4f1b198fea98 +README.md: 60898e8de8ffa29a823c537ba5f5876b63a03c88 +README.zh.md: 5d58375183227e27cc3978e4d354edd4fee3bd81 diff --git a/packages/tasks/tasks/README.md b/packages/tasks/tasks/README.md index 18029a2e93..60898e8de8 100644 --- a/packages/tasks/tasks/README.md +++ b/packages/tasks/tasks/README.md @@ -6,7 +6,7 @@ The background task registry contract (`ctx.tasks`). The abstract `TaskService` ## Service contract -- `start(spec): TaskId` validates the attached controller, spec, exact live owner, and optional positive `outputLimitBytes` before calling the producer's `run()` once. A starter throw leaves nothing registered; successful return commits without another failable step. +- `start(spec): TaskId` validates the attached controller, spec, exact live owner, optional positive `outputLimitBytes`, and any provider-owned admission policy before calling the producer's `run()` once. A preflight rejection or starter throw leaves no task id or registered work; successful return commits without another failable step. - `get(id, caller?)` and `list(caller?)` return non-consuming snapshots. Listing includes only caller-owned and unowned tasks. - `read(id, caller?)` consumes the single cursor for stream tasks and reads terminal output idempotently for final-output tasks. - `kill(id, caller?, reason?)` invokes producer cancellation before changing status. A cancellation throw leaves the task running; success changes it to `stopping` and marks terminal delivery reported. diff --git a/packages/tasks/tasks/README.zh.md b/packages/tasks/tasks/README.zh.md index 611014c820..5d58375183 100644 --- a/packages/tasks/tasks/README.zh.md +++ b/packages/tasks/tasks/README.zh.md @@ -6,7 +6,7 @@ ## 服务约定 -- `start(spec): TaskId` 验证已附加的任务控制器、spec、确切且仍存活的 owner,以及可选的 `outputLimitBytes`(如提供则须为正数),然后只调用生产方的 `run()` 一次。启动方抛出异常时不注册任何内容;成功返回会直接提交,不再执行其他可能失败的步骤。 +- `start(spec): TaskId` 验证已附加的任务控制器、spec、确切且仍存活的 owner、可选的正数 `outputLimitBytes`,以及 Service provider 所拥有的准入策略,然后只调用生产方的 `run()` 一次。预检拒绝或启动方抛出异常时都不会生成 task id 或注册工作;成功返回会直接提交,不再执行其他可能失败的步骤。 - `get(id, caller?)` 和 `list(caller?)` 返回非消费式快照。列表只包含调用方拥有及无 owner 的任务。 - `read(id, caller?)` 消费流任务的唯一游标;对于最终输出任务,则以幂等方式读取终止输出。 - `kill(id, caller?, reason?)` 在更改状态前调用生产方取消。取消抛出异常时任务保持运行;成功则把状态改为 `stopping`,并将终止交付标记为已报告。 diff --git a/packages/tasks/tasks/src/index.ts b/packages/tasks/tasks/src/index.ts index ba5ce7eb2f..2b53de6c6a 100644 --- a/packages/tasks/tasks/src/index.ts +++ b/packages/tasks/tasks/src/index.ts @@ -71,10 +71,11 @@ export abstract class TaskService extends Service { } /** - * Preflight access, validation, and owner cleanup before starting and - * atomically registering work. A throwing starter leaves nothing registered; - * after it returns, registration cannot fail. Settlement records the outcome, - * notifies listeners, and releases waiters. + * Preflight access, validation, owner cleanup, and implementation-owned + * admission before starting and atomically registering work. Any preflight + * rejection leaves no task id or execution resource. A throwing starter + * leaves nothing registered; after it returns, registration cannot fail. + * Settlement records the outcome, notifies listeners, and releases waiters. * @param spec - task identity, owner, and synchronous starter. * @returns the registry-issued `-N` id. */ diff --git a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts index 593a368f8b..50f481fbb0 100644 --- a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts +++ b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts @@ -759,6 +759,7 @@ describe('completion notices', () => { const prior = producer({ kind: 'pty-send' }) ctx.tasks.start(prior.spec) prior.settle({ status: 'completed' }) + await tick() } const inject = vi.fn() const owner = fakeAgent(ctx, 'sess-1', { inject }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e71c12536..28a20e6dc4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -846,6 +846,9 @@ importers: '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal + '@deepseek-ai/dsh-host-plugin-inventory': + specifier: workspace:^ + version: link:../../host/plugin-inventory '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -1593,6 +1596,9 @@ importers: '@deepseek-ai/dsh-client-ui-plugin-config': specifier: workspace:^ version: link:../../client/ui-plugin-config + '@deepseek-ai/dsh-client-ui-plugins': + specifier: workspace:^ + version: link:../../client/ui-plugins '@deepseek-ai/dsh-client-ui-question': specifier: workspace:^ version: link:../../client/ui-question @@ -1656,6 +1662,9 @@ importers: '@deepseek-ai/dsh-host-directory-picker-native': specifier: workspace:^ version: link:../../host/directory-picker-native + '@deepseek-ai/dsh-host-plugin-inventory': + specifier: workspace:^ + version: link:../../host/plugin-inventory '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver @@ -2582,6 +2591,48 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-plugins: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + packages/client/ui-primitives: dependencies: '@shikijs/langs': @@ -4942,6 +4993,28 @@ importers: specifier: workspace:^ version: link:../../support/invariants + packages/host/plugin-inventory: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + packages/host/webserver: dependencies: '@deepseek-ai/schemastery': @@ -7209,6 +7282,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../subprocess @@ -7346,10 +7422,20 @@ importers: version: link:../../core/session packages/tasks/tasks-local: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent diff --git a/python/development.i18n.yaml b/python/development.i18n.yaml index c341c32cea..d5570fba11 100644 --- a/python/development.i18n.yaml +++ b/python/development.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 python/development.md -development.md: 9614c06436ab6863a5e1b2ff83fbe605552dc13b -development.zh.md: 1c646ca39735b85a5d380768fe215c92532be7e7 +development.md: 31dc254b58c05c2a19c7c4cd5dc1e53207517902 +development.zh.md: dbb85a0cffc5e06c7ca781b2b01f6993395204e3 diff --git a/python/development.md b/python/development.md index 9614c06436..31dc254b58 100644 --- a/python/development.md +++ b/python/development.md @@ -58,4 +58,12 @@ python scripts/build-python-release.py --package runtime --platform macos-arm64 pip install --find-links dist-python deepseek-harness-sdk=="$version" ``` -The runtime distribution is wheel-only. The release pipeline publishes three platform wheels with the pure SDK wheel: Linux x64, Linux arm64, and macOS arm64. A `python-vX.Y.Z` tag is accepted only when it matches the repository version. +The runtime distribution is wheel-only. The release pipeline publishes three platform wheels with the pure SDK wheel: Linux x64, Linux arm64, and macOS 14 or newer on arm64. A `python-v` tag is accepted only when it matches the repository version; prerelease repository versions such as `0.0.1-rc.1` use their normalized PEP 440 spelling, such as `0.0.1rc1`, inside wheel filenames and metadata. + +## Validate a release candidate + +Label a pull request `python-release-dry-run`, or manually run the GitHub `Release (Python)` workflow with `publish=false`, to build all four wheels, install the Linux release set on Python 3.10 and 3.14, check exact filenames and metadata, enforce PyPI's default per-file size limit, and retain one aggregate artifact with SHA-256 hashes. Both paths have no registry credentials; a pull request run cannot enter either publication job. + +Public publication runs from the private automation repository; package metadata points to the separate read-only public source mirror, which does not run release Actions. The private repository defines the repository variable `PYPI_PUBLISHER_REPOSITORY` as its own `owner/name` and keeps `PUBLIC_PYPI_RELEASE_ENABLED=false` except during an intentional release. + +Separate runtime and SDK jobs let an SDK upload failure resume without resending immutable runtime files. They accept `publish=true` only when the workflow runs from the configured publisher repository at the matching `python-v*` tag and the protected `pypi-runtime` and `pypi` environments approve the runtime and SDK jobs, respectively. PyPI Trusted Publishing still supplies short-lived OIDC credentials, but public attestations are disabled because they would disclose the private publisher identity. diff --git a/python/development.zh.md b/python/development.zh.md index 1c646ca397..dbb85a0cff 100644 --- a/python/development.zh.md +++ b/python/development.zh.md @@ -58,4 +58,12 @@ python scripts/build-python-release.py --package runtime --platform macos-arm64 pip install --find-links dist-python deepseek-harness-sdk=="$version" ``` -运行时分发包仅提供 wheel 包。发布流水线会连同纯 SDK wheel 包一起发布三个平台 wheel 包:Linux x64、Linux arm64 和 macOS arm64。只有与仓库版本匹配时,才接受 `python-vX.Y.Z` 标签。 +运行时分发包仅提供 wheel 包。发布流水线会连同纯 SDK wheel 包一起发布三个平台 wheel 包:Linux x64、Linux arm64 和 macOS 14 或更高版本的 arm64。只有与仓库版本匹配时,才接受 `python-v` 标签;`0.0.1-rc.1` 之类的仓库预发布版本在 wheel 包文件名和元数据中使用规范化的 PEP 440 写法,例如 `0.0.1rc1`。 + +## 验证候选发行版 + +为拉取请求添加 `python-release-dry-run` 标签,或手动运行 GitHub 的 `Release (Python)` 工作流并设置 `publish=false`,即可构建全部四个 wheel 包,在 Python 3.10 和 3.14 上安装 Linux 发行集合,检查精确文件名和元数据,执行 PyPI 默认单文件大小限制,并保留一份带 SHA-256 哈希的汇总产物。两条路径都没有注册表凭据,拉取请求运行无法进入任何发布作业。 + +公开发布从私有自动化仓库运行;包元数据指向独立的只读公开源码镜像,该镜像不运行发布 Actions。私有仓库把仓库变量 `PYPI_PUBLISHER_REPOSITORY` 定义为自身的 `owner/name`,并且只在有意发布期间把 `PUBLIC_PYPI_RELEASE_ENABLED` 从 `false` 改为 `true`。 + +独立的运行时与 SDK 作业使 SDK 上传失败后可以继续执行,而无需重新发送不可变的运行时文件。只有工作流从配置的发布仓库、匹配的 `python-v*` 标签运行,且受保护的 `pypi-runtime` 和 `pypi` 环境分别批准运行时与 SDK 作业时,才接受 `publish=true`。PyPI Trusted Publishing 仍会提供短期 OIDC 凭据,但公开 attestation 会披露私有发布仓库身份,因此将其禁用。 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 4814c98ead..330586f6c3 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/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 python/sdk-runtime/README.md -README.md: fa2fc83a88212f6ff163e1a5f86246bfac37cc1f -README.zh.md: 5b82f33cfe1413e4fb6ceded04d9b6feca4c94ca +README.md: 71dedf4cb8064d55bd64b32008b452158a1b154f +README.zh.md: 83c99ed33b2a4ffe00bcb3fe670be455664bfa18 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index fa2fc83a88..71dedf4cb8 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -1,6 +1,6 @@ # DeepSeek Harness Runtime Wheel -English | [中文](README.zh.md) +English | [中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/README.zh.md) Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, module `deepseek_harness_runtime`): it locates the bundled runtime binaries the `deepseek-harness-sdk` client spawns, and ships the default configuration behind zero-config runs. @@ -11,11 +11,11 @@ Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injecte - **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`). macOS builds also ship the native `-spawn-helper` sibling that `node-pty` uses there. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. - **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. -Both carriers hold the same content, defined once: the [package.json](package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. +Both carriers hold the same content, defined once: the [package.json](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. A missing exe raises `FileNotFoundError` naming both acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or install the matching platform runtime wheel produced by the `build-exe-for-python-sdk` CI workflow. A missing dev-only node carrier names its sole route, the build script. The workflow retains wheels rather than standalone executable archives. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. -Each wheel contains exactly one runtime executable. The macOS wheel also contains its matching native spawn helper; a missing sidecar makes that installation incomplete and is a hard startup error, even for a selected Cordis composition that does not use PTY tools. Linux wheels contain no spawn helper because `node-pty` uses the staged `pty.node` addon directly. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the build hook rejects `py3-none-any`, absent or multiple runtime files, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-vX.Y.Z` release tag must match it. +Each wheel contains exactly one runtime executable. The macOS wheel also contains its matching native spawn helper; a missing sidecar makes that installation incomplete and is a hard startup error, even for a selected Cordis composition that does not use PTY tools. Linux wheels contain no spawn helper because `node-pty` uses the staged `pty.node` addon directly. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_14_0_arm64`; the macOS tag conservatively matches the bundled Node 24 executable's macOS 13.5 deployment target. This package's `platforms.json` owns the fixed tag and executable-name pairs used by both the repository release builder and the isolated build hook. The build hook rejects `py3-none-any`, absent or multiple runtime files, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-v` release tag must match it. ## Resolution API @@ -26,4 +26,4 @@ Each wheel contains exactly one runtime executable. The macOS wheel also contain ## Zero-config design -The runtime binary always demands an explicit config (`$DSH_CORDIS_CONFIG`, or a config path as an argv positional argument) and exits loudly without one — that hard semantic is part of the runtime's design and this package does not soften it. The bin (`dsh-jsonrpc-agent`) boots only the plugins the config lists; the serving interface (the stdio JSON-RPC server) is itself one of its entries (`@deepseek-ai/dsh-jsonrpc`), and without it the booted agent has no channel to the outside. This package checks in `runtime/cordis.yml` with the JSON-RPC serving entry, agent core, a preloaded DeepSeek adapter, JSONL persistence, the explicitly composed semantic checkpoint policy, local bash, and a local filesystem provider for bounded workspace-instruction loading. The persistence backend owns durable storage while the separate policy selects request-, tool-dispatch-, and completed-step checkpoints. The adapter reads `DEEPSEEK_API_KEY` and `DEEPSEEK_BASE_URL`, while persistence, bash, and the filesystem provider use `DSH_SESSION_ROOT` and `DSH_CWD` with manual-run fallbacks. When the caller uses no explicit config channel, the `deepseek_harness` client injects that file's path via `DSH_CORDIS_CONFIG` (injection conditions: [sdk README](../sdk/README.md)). Zero-config is thus an explicit, visible parameter pass in the wrapper, not a hidden fallback in the runtime. +The runtime binary always demands an explicit config (`$DSH_CORDIS_CONFIG`, or a config path as an argv positional argument) and exits loudly without one — that hard semantic is part of the runtime's design and this package does not soften it. The bin (`dsh-jsonrpc-agent`) boots only the plugins the config lists; the serving interface (the stdio JSON-RPC server) is itself one of its entries (`@deepseek-ai/dsh-jsonrpc`), and without it the booted agent has no channel to the outside. This package checks in `runtime/cordis.yml` with the JSON-RPC serving entry, agent core, a preloaded DeepSeek adapter, JSONL persistence, the explicitly composed semantic checkpoint policy, local bash, and a local filesystem provider for bounded workspace-instruction loading. The persistence backend owns durable storage while the separate policy selects request-, tool-dispatch-, and completed-step checkpoints. The adapter reads `DEEPSEEK_API_KEY` and `DEEPSEEK_BASE_URL`, while persistence, bash, and the filesystem provider use `DSH_SESSION_ROOT` and `DSH_CWD` with manual-run fallbacks. When the caller uses no explicit config channel, the `deepseek_harness` client injects that file's path via `DSH_CORDIS_CONFIG` (injection conditions: [sdk README](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.md)). Zero-config is thus an explicit, visible parameter pass in the wrapper, not a hidden fallback in the runtime. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 5b82f33cfe..83c99ed33b 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -1,6 +1,6 @@ # DeepSeek Harness 运行时 wheel 包 -[English](README.md) | 中文 +[English](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/README.md) | 中文 Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`,模块名 `deepseek_harness_runtime`):它定位 `deepseek-harness-sdk` 客户端要 spawn 的内置运行时二进制,并附带支撑零配置运行的默认配置。 @@ -11,11 +11,11 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, - **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。macOS 构建还会随附 `node-pty` 在该平台使用的原生 `-spawn-helper` 伴随文件。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 - **node(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 -两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 +两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 node 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。 -每个 wheel 包只包含一个运行时可执行文件。macOS wheel 包还包含与其匹配的原生 spawn helper;缺少伴随文件意味着该安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用 PTY 工具也是如此。Linux wheel 包不包含 spawn helper,因为 `node-pty` 直接使用暂存的 `pty.node` 原生插件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、不存在运行时文件、存在多个运行时文件、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-vX.Y.Z` 发布标签必须与其匹配。 +每个 wheel 包只包含一个运行时可执行文件。macOS wheel 包还包含与其匹配的原生 spawn helper;缺少伴随文件意味着该安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用 PTY 工具也是如此。Linux wheel 包不包含 spawn helper,因为 `node-pty` 直接使用暂存的 `pty.node` 原生插件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_14_0_arm64`;macOS 标签保守匹配内置 Node 24 可执行文件的 macOS 13.5 部署目标。本包的 `platforms.json` 统一定义仓库发行构建器与隔离构建钩子使用的固定标签和可执行文件名。构建钩子会拒绝 `py3-none-any`、不存在运行时文件、存在多个运行时文件、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-v` 发布标签必须与其匹配。 ## 解析 API @@ -26,4 +26,4 @@ exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deep ## 零配置设计 -运行时二进制始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为 argv 位置参数的配置路径),缺了就报错退出——这一强制语义是运行时设计的一部分,本包不会弱化它。bin(`dsh-jsonrpc-agent`)只启动配置里列出的插件;对外服务接口(stdio JSON-RPC 服务器)也是其中一个条目(`@deepseek-ai/dsh-jsonrpc`),缺了它,启动出的 agent(智能体)就没有对外通道。本包检入的 `runtime/cordis.yml` 包含 JSON-RPC 服务条目、agent 核心、预载的 DeepSeek 适配器、JSONL 持久化、显式组合的语义检查点策略、本地 bash,以及用于有界加载工作区指令的本地文件系统提供方。持久化后端负责持久存储,独立的策略则选择请求、工具分发和已完成步骤的检查点。DeepSeek 适配器读取 `DEEPSEEK_API_KEY` 与 `DEEPSEEK_BASE_URL`,持久化、bash 和文件系统提供方则使用 `DSH_SESSION_ROOT` 和 `DSH_CWD`,并为手动运行提供回退值。调用方未使用任何显式配置通道时,`deepseek_harness` 客户端把该文件路径注入 `DSH_CORDIS_CONFIG`(注入条件见 [sdk README](../sdk/README.md))。因此,零配置是包装层中一次显式、可见的参数传递,而不是运行时中的隐藏回退。 +运行时二进制始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为 argv 位置参数的配置路径),缺了就报错退出——这一强制语义是运行时设计的一部分,本包不会弱化它。bin(`dsh-jsonrpc-agent`)只启动配置里列出的插件;对外服务接口(stdio JSON-RPC 服务器)也是其中一个条目(`@deepseek-ai/dsh-jsonrpc`),缺了它,启动出的 agent(智能体)就没有对外通道。本包检入的 `runtime/cordis.yml` 包含 JSON-RPC 服务条目、agent 核心、预载的 DeepSeek 适配器、JSONL 持久化、显式组合的语义检查点策略、本地 bash,以及用于有界加载工作区指令的本地文件系统提供方。持久化后端负责持久存储,独立的策略则选择请求、工具分发和已完成步骤的检查点。DeepSeek 适配器读取 `DEEPSEEK_API_KEY` 与 `DEEPSEEK_BASE_URL`,持久化、bash 和文件系统提供方则使用 `DSH_SESSION_ROOT` 和 `DSH_CWD`,并为手动运行提供回退值。调用方未使用任何显式配置通道时,`deepseek_harness` 客户端把该文件路径注入 `DSH_CORDIS_CONFIG`(注入条件见 [sdk README](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.md))。因此,零配置是包装层中一次显式、可见的参数传递,而不是运行时中的隐藏回退。 diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index b0f9a79550..400d9d585b 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import os import platform import stat @@ -8,11 +9,30 @@ from pathlib import Path from hatchling.builders.hooks.plugin.interface import BuildHookInterface -_PLATFORMS = { - "linux-x64": ("manylinux_2_28_x86_64", "dsh-jsonrpc-agent-pkg-linux-x64"), - "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"), - "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), -} +def _load_platforms() -> dict[str, tuple[str, str]]: + """Load and validate the platform manifest inside an isolated wheel build.""" + path = Path(__file__).with_name("platforms.json") + try: + payload = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError) as error: + raise RuntimeError(f"could not read runtime platform manifest from {path}") from error + if not isinstance(payload, dict) or not payload: + raise RuntimeError(f"{path} must contain a non-empty platform object") + platforms: dict[str, tuple[str, str]] = {} + for name, raw in payload.items(): + if ( + not isinstance(name, str) + or not isinstance(raw, dict) + or set(raw) != {"tag", "executable"} + or not isinstance(raw["tag"], str) + or not isinstance(raw["executable"], str) + ): + raise RuntimeError(f"{path} platform entries must contain string tag and executable fields") + platforms[name] = (raw["tag"], raw["executable"]) + return platforms + + +_PLATFORMS = _load_platforms() def _host_platform_tag() -> str: diff --git a/python/sdk-runtime/platforms.json b/python/sdk-runtime/platforms.json new file mode 100644 index 0000000000..069378e8cb --- /dev/null +++ b/python/sdk-runtime/platforms.json @@ -0,0 +1,14 @@ +{ + "linux-x64": { + "tag": "manylinux_2_28_x86_64", + "executable": "dsh-jsonrpc-agent-pkg-linux-x64" + }, + "linux-arm64": { + "tag": "manylinux_2_28_aarch64", + "executable": "dsh-jsonrpc-agent-pkg-linux-arm64" + }, + "macos-arm64": { + "tag": "macosx_14_0_arm64", + "executable": "dsh-jsonrpc-agent-pkg-macos-arm64" + } +} diff --git a/python/sdk-runtime/pyproject.toml b/python/sdk-runtime/pyproject.toml index 6db7595297..9481d33889 100644 --- a/python/sdk-runtime/pyproject.toml +++ b/python/sdk-runtime/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["hatchling>=1.30.1"] +requires = ["hatchling==1.30.1"] build-backend = "hatchling.build" [project] @@ -8,7 +8,14 @@ version = "0.0.0.dev0" description = "Pinned DeepSeek Harness runtime for the Python SDK" readme = "README.md" requires-python = ">=3.10" -license = { text = "BSD-3-Clause" } +license = "BSD-3-Clause" +authors = [{ name = "DeepSeek" }] + +[project.urls] +Homepage = "https://github.com/deepseek-ai/deepseek-harness" +Documentation = "https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/README.md" +Issues = "https://github.com/deepseek-ai/deepseek-harness/issues" +Source = "https://github.com/deepseek-ai/deepseek-harness" # Include the injected executable and default config; exclude the dev-only node # closure from wheels and sdists. diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index d1a9bbc81b..42aa01ec6e 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/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 python/sdk/README.md -README.md: 686cb46b6d3d12baaf2afdeba10def23d7a08edb -README.zh.md: 6414560deedbb76dd6f8571526251acd1c3f6a80 +README.md: 70b9d6391644d10ee7d5c29ce122632786e3bbcc +README.zh.md: 1d1a23576cc8029dacbb2df0e3d1d9fc2ce27426 diff --git a/python/sdk/README.md b/python/sdk/README.md index 686cb46b6d..70b9d63916 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -1,6 +1,6 @@ # DeepSeek Harness Python SDK -English | [中文](README.zh.md) +English | [中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.zh.md) Python subprocess SDK for driving DeepSeek Harness over JSON-RPC stdio. The runtime inherits normal DeepSeek Harness environment variables such as @@ -40,12 +40,12 @@ with DeepSeekHarness( `provider` selects a provider route registered by the chosen Cordis composition; `model` is the model id resolved by that adapter. `max_tokens` is an optional positive per-request output-token cap for the root agent and its in-process descendants; omission leaves the provider default in control. Compaction summaries keep the separate limit configured by their compaction plugin. The bundled default composition registers `deepseek-official`. A custom composition can mount `llm-pi-ai`, configure provider-specific credentials/endpoints there, and select any provider/model present in pi-ai's installed catalog. -The [Python SDK tutorial](../../docs/user/guide/python-sdk.md) provides an ordered installation and first-run path without the Web UI. The [`jsonrpc-agent` example](../../examples/jsonrpc-agent/README.md) owns the complete standalone Cordis file used there. +The [Python SDK tutorial](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/guide/python-sdk.md) provides an ordered installation and first-run path without the Web UI. The [`jsonrpc-agent` example](https://github.com/deepseek-ai/deepseek-harness/blob/master/examples/jsonrpc-agent/README.md) owns the complete standalone Cordis file used there. `Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, finish_reason, events, notifications, session_root)`. `final_response` is the last committed root-session assistant text in the interval. `finish_reason` is the `kind` of the last root-session `turn/end` in the interval, such as `completed`, `max-tokens`, or `error`, and is `None` when no turn ended. A `turn/end` without a string `data.reason.kind` violates the runtime protocol and raises `SdkProtocolError`. Both result fields describe the owned interval rather than an output or ending causally assigned to the prompt. Steering, injected context, and other queued work may contribute before idle. `HarnessClient` retains discovered subagent ancestry for the lifetime of the runtime process. During each `Session.run()`, `RunResult.notifications` and `on_notification` receive the root session and all known descendant notifications in wire order, including nested subagent lifecycle and session events. `RunResult.events` contains root-session events only, so descendant messages cannot replace the root response. The low-level `session_prompt()` returns the queued `MessageId` immediately; callers that bypass `Session.run()` own any later activity boundary themselves. -The same behavior can be selected for the runtime subprocess with `DSH_CORDIS_CONFIG`. The injection lives in `HarnessClient.start()`, so the low-level client's default launch gets it too: when the launch resolves to the bundled runtime and neither `cordis` nor a non-empty `DSH_CORDIS_CONFIG` is set (the runtime treats an empty value as absent, and so does the injection check), the bundled default configuration is used; an explicit `runtime_bin`, `bridge_bin`, or `launch_args_override` disables the injection entirely. See the [sdk-runtime README](../sdk-runtime/README.md) for the runtime carriers (production exe vs dev-only node closure) and how to obtain them. +The same behavior can be selected for the runtime subprocess with `DSH_CORDIS_CONFIG`. The injection lives in `HarnessClient.start()`, so the low-level client's default launch gets it too: when the launch resolves to the bundled runtime and neither `cordis` nor a non-empty `DSH_CORDIS_CONFIG` is set (the runtime treats an empty value as absent, and so does the injection check), the bundled default configuration is used; an explicit `runtime_bin`, `bridge_bin`, or `launch_args_override` disables the injection entirely. See the [sdk-runtime README](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/README.md) for the runtime carriers (production exe vs dev-only node closure) and how to obtain them. `cwd` and `runtime_cwd` are resolved to absolute paths before subprocess launch, environment injection, and the wire handshake. The public API exposes only applied options: deployment persona and persistence belong in `cordis.yml`, while `session_root` remains the high-level convenience that sets `DSH_SESSION_ROOT`. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 6414560dee..1d1a23576c 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -1,6 +1,6 @@ # DeepSeek Harness Python SDK -[English](README.md) | 中文 +[English](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.md) | 中文 通过 JSON-RPC stdio 驱动 DeepSeek Harness 的 Python 子进程 SDK。运行时继承常规的 DeepSeek Harness 环境变量(如 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`),调用方可以直接使用真实模型端点,也可以把这些变量指向本地代理。 @@ -37,12 +37,12 @@ with DeepSeekHarness( `provider` 用于选择当前 Cordis 组合已注册的提供方路由;`model` 是该适配器解析的模型 ID。`max_tokens` 是可选的正整数,用于限制根 agent(智能体)及其进程内后代每次请求的输出 token;省略时由提供方默认值控制。压缩摘要继续使用压缩插件单独配置的上限。内置默认组合注册 `deepseek-official`。自定义组合可以挂载 `llm-pi-ai`,在其中配置各提供方的凭据与端点,再选择 pi-ai 已安装目录中的任意提供方/模型组合。 -[Python SDK 教程](../../docs/user/guide/python-sdk.md)提供不使用 Web UI 的顺序安装与首次运行路径。[`jsonrpc-agent` 示例](../../examples/jsonrpc-agent/README.md)归属该教程使用的完整独立 Cordis 文件。 +[Python SDK 教程](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/guide/python-sdk.md)提供不使用 Web UI 的顺序安装与首次运行路径。[`jsonrpc-agent` 示例](https://github.com/deepseek-ai/deepseek-harness/blob/master/examples/jsonrpc-agent/README.md)归属该教程使用的完整独立 Cordis 文件。 `Session.run()` 拥有一个从提示词进入持久 inbox 时开始、到整个 agent 下一次进入空闲状态为止的活动区间,并返回 `RunResult(session_id, final_response, finish_reason, events, notifications, session_root)`。`final_response` 是该区间内根会话最后提交的助手文本。`finish_reason` 是该区间内根会话最后一个 `turn/end` 的 `kind`,例如 `completed`、`max-tokens` 或 `error`;没有轮次结束时为 `None`。缺少字符串 `data.reason.kind` 的 `turn/end` 违反运行时协议,并会抛出 `SdkProtocolError`。两个结果字段描述的都是自有活动区间,而不是因果上归属于该提示词的输出或结束原因。steering(中途引导)、注入的上下文和其他排队工作都可能在进入空闲状态前参与其中。 `HarnessClient` 会在运行时进程的生命周期内保留已发现的 subagent(子 agent)祖先关系。每次执行 `Session.run()` 时,`RunResult.notifications` 与 `on_notification` 会按协议传输顺序收到根会话及所有已知后代的通知,其中包括嵌套 subagent 的生命周期事件与会话事件。`RunResult.events` 只包含根会话事件,因此后代消息不会覆盖根会话回复。底层 `session_prompt()` 会立即返回已排队消息的 `MessageId`;绕过 `Session.run()` 的调用方必须自行负责后续的活动边界。 -同样的行为也可以通过 `DSH_CORDIS_CONFIG` 为运行时子进程选定。注入逻辑位于 `HarnessClient.start()`,因此底层客户端的默认启动也具有此行为:当启动解析到内置运行时,且 `cordis` 与非空的 `DSH_CORDIS_CONFIG` 均未设置时(运行时把空值视为缺省,注入检查与之一致),使用内置的默认配置;显式给出 `runtime_bin`、`bridge_bin` 或 `launch_args_override` 则完全禁用注入。运行时载体(生产用 exe 与仅限开发的 `node` 闭包)及其获取方式见 [sdk-runtime README](../sdk-runtime/README.md)。 +同样的行为也可以通过 `DSH_CORDIS_CONFIG` 为运行时子进程选定。注入逻辑位于 `HarnessClient.start()`,因此底层客户端的默认启动也具有此行为:当启动解析到内置运行时,且 `cordis` 与非空的 `DSH_CORDIS_CONFIG` 均未设置时(运行时把空值视为缺省,注入检查与之一致),使用内置的默认配置;显式给出 `runtime_bin`、`bridge_bin` 或 `launch_args_override` 则完全禁用注入。运行时载体(生产用 exe 与仅限开发的 `node` 闭包)及其获取方式见 [sdk-runtime README](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/README.md)。 `cwd` 与 `runtime_cwd` 会在启动子进程、注入环境变量和协议握手前解析为绝对路径。公开 API 只暴露真正生效的选项:部署的角色设定与持久化配置归 `cordis.yml` 管理,而 `session_root` 继续作为设置 `DSH_SESSION_ROOT` 的高层便捷选项。 diff --git a/python/sdk/pyproject.toml b/python/sdk/pyproject.toml index 48ffbf2499..246e44ee26 100644 --- a/python/sdk/pyproject.toml +++ b/python/sdk/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["hatchling>=1.30.1"] +requires = ["hatchling==1.30.1"] build-backend = "hatchling.build" [project] @@ -8,12 +8,19 @@ version = "0.0.0.dev0" description = "Python SDK for DeepSeek Harness" readme = "README.md" requires-python = ">=3.10" -license = { text = "BSD-3-Clause" } +license = "BSD-3-Clause" +authors = [{ name = "DeepSeek" }] dependencies = [ - "pydantic>=2.12", + "pydantic>=2.12,<3", "deepseek-harness-runtime-bin==0.0.0.dev0", ] +[project.urls] +Homepage = "https://github.com/deepseek-ai/deepseek-harness" +Documentation = "https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/guide/python-sdk.md" +Issues = "https://github.com/deepseek-ai/deepseek-harness/issues" +Source = "https://github.com/deepseek-ai/deepseek-harness" + [dependency-groups] test = ["pytest>=8.0"] diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index 629ddf901f..5442c7e144 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -269,7 +269,11 @@ class HarnessClient: if remaining <= 0: with self._lock: self._responses.pop(request_id, None) - raise TimeoutError(f"{method} timed out waiting for DeepSeek Harness runtime") + diagnostics = self._runtime_diagnostics() + suffix = f"\n{diagnostics}" if diagnostics else "" + raise TimeoutError( + f"{method} timed out waiting for DeepSeek Harness runtime{suffix}" + ) wait_timeout = remaining if wait_timeout is None else min(wait_timeout, remaining) try: item = waiter.get(timeout=wait_timeout) @@ -393,6 +397,11 @@ class HarnessClient: self._requests.put(exc) def _runtime_closed_error(self, reason: str) -> TransportClosedError: + diagnostics = self._runtime_diagnostics() + return TransportClosedError(f"{reason}\n{diagnostics}" if diagnostics else reason) + + def _runtime_diagnostics(self) -> str: + """Return available subprocess state for transport failures and timeouts.""" proc = self._proc if ( proc is not None @@ -403,14 +412,14 @@ class HarnessClient: ): self._stderr_thread.join(timeout=0.1) - parts = [reason] + parts: list[str] = [] if proc is not None: exit_code = proc.poll() if exit_code is not None: parts.append(f"exit code: {exit_code}") if self._stderr_lines: parts.append("stderr tail:\n" + "\n".join(self._stderr_lines)) - return TransportClosedError("\n".join(parts)) + return "\n".join(parts) def _default_launch_args(self) -> tuple[str, ...]: if self.config.runtime_bin is not None: diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index 3d341492d8..51c9dacb31 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -721,8 +721,10 @@ def test_client_request_times_out_when_bridge_does_not_respond(tmp_path: Path) - script = tmp_path / "fake_bridge.py" script.write_text( """ +import sys import time +print("bridge is still starting", file=sys.stderr, flush=True) time.sleep(60) """.strip() ) @@ -736,8 +738,9 @@ time.sleep(60) start = time.monotonic() try: client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") - except TimeoutError: + except TimeoutError as exc: assert time.monotonic() - start < 2 + assert "bridge is still starting" in str(exc) else: raise AssertionError("initialize should time out") diff --git a/python/sdk/tests/test_macos_deployment_target.py b/python/sdk/tests/test_macos_deployment_target.py new file mode 100644 index 0000000000..8e68089a6e --- /dev/null +++ b/python/sdk/tests/test_macos_deployment_target.py @@ -0,0 +1,37 @@ +"""Tests for macOS runtime wheel deployment-target validation.""" + +from __future__ import annotations + +import runpy +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ROOT / "scripts" / "check-macos-deployment-target.py" +checker = SimpleNamespace(**runpy.run_path(str(SCRIPT))) + + +def test_otool_parser_uses_the_newest_macho_slice() -> None: + output = """ + cmd LC_BUILD_VERSION + minos 11.0 + cmd LC_BUILD_VERSION + minos 13.5 + """ + + assert checker.parse_otool_deployment_target(output) == (13, 5) + + +def test_otool_parser_requires_a_deployment_target() -> None: + with pytest.raises(ValueError, match="contains no LC_BUILD_VERSION"): + checker.parse_otool_deployment_target("Load command 0\n") + + +def test_wheel_tag_rejects_a_newer_executable_target() -> None: + checker.ensure_compatible(Path("runtime"), (13, 5), "macosx_14_0_arm64") + + with pytest.raises(RuntimeError, match="requires macOS 14.1"): + checker.ensure_compatible(Path("spawn-helper"), (14, 1), "macosx_14_0_arm64") diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index c66de01a3e..3bff2df3f9 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -57,6 +57,18 @@ def test_pep440_version_spells_a_prerelease_the_python_way() -> None: build_python_release.pep440_version("1.2.3-nightly") +def test_macos_wheel_tag_does_not_claim_unsupported_node_platforms() -> None: + assert build_python_release.PLATFORMS["macos-arm64"][0] == "macosx_14_0_arm64" + + +def test_platform_manifest_rejects_incomplete_entries(tmp_path: Path) -> None: + manifest = tmp_path / "platforms.json" + manifest.write_text('{"macos-arm64":{"tag":"macosx_14_0_arm64"}}\n') + + with pytest.raises(ValueError, match="tag and executable fields"): + build_python_release.load_platforms(manifest) + + def test_stage_sdk_keeps_distribution_module_and_runtime_pin_distinct(tmp_path: Path) -> None: destination = tmp_path / "staging" @@ -66,6 +78,8 @@ def test_stage_sdk_keeps_distribution_module_and_runtime_pin_distinct(tmp_path: assert 'name = "deepseek-harness-sdk"' in pyproject assert 'version = "1.2.3"' in pyproject assert '"deepseek-harness-runtime-bin==1.2.3"' in pyproject + assert 'license-files = ["LICENSE"]' in pyproject + assert (destination / "LICENSE").read_bytes() == (ROOT / "LICENSE").read_bytes() assert (destination / "src" / "deepseek_harness" / "__init__.py").is_file() @@ -88,3 +102,12 @@ def test_stage_runtime_copies_platform_payload( runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" assert {path.name: path.read_bytes() for path in runtime_dir.glob("dsh-jsonrpc-agent-pkg-*")} == expected + pyproject = (destination / "pyproject.toml").read_text() + assert 'license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"]' in pyproject + assert (destination / "platforms.json").read_bytes() == ( + ROOT / "python" / "sdk-runtime" / "platforms.json" + ).read_bytes() + assert (destination / "LICENSE").read_bytes() == (ROOT / "LICENSE").read_bytes() + assert (destination / "THIRD_PARTY_NOTICES.md").read_bytes() == ( + ROOT / "THIRD_PARTY_NOTICES.md" + ).read_bytes() diff --git a/python/sdk/tests/test_smoke_model.py b/python/sdk/tests/test_smoke_model.py new file mode 100644 index 0000000000..d4be72032d --- /dev/null +++ b/python/sdk/tests/test_smoke_model.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import runpy +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[3] +SMOKE = runpy.run_path(ROOT / "scripts" / "smoke-python-runtime.py") + + +@pytest.mark.parametrize( + ("prompt_name", "expected"), + [ + ("SNAPSHOT_DIRECT_CHILD_PROMPT", "DIRECT_CHILD_OK"), + ("SNAPSHOT_WORKFLOW_CHILD_PROMPT", "WORKFLOW_CHILD_OK"), + ], +) +def test_child_prompt_precedes_runtime_context(prompt_name: str, expected: str) -> None: + chunks = SMOKE["completion_chunks"]({ + "messages": [ + {"role": "user", "content": SMOKE[prompt_name]}, + {"role": "user", "content": "Current runtime context"}, + ], + }) + + assert any( + choice.get("delta", {}).get("content") == expected + for chunk in chunks + for choice in chunk.get("choices", []) + ) diff --git a/python/sdk/uv.lock b/python/sdk/uv.lock index e2a62a9fe0..c6715ebdd8 100644 --- a/python/sdk/uv.lock +++ b/python/sdk/uv.lock @@ -42,7 +42,7 @@ test = [ [package.metadata] requires-dist = [ { name = "deepseek-harness-runtime-bin", editable = "../sdk-runtime" }, - { name = "pydantic", specifier = ">=2.12" }, + { name = "pydantic", specifier = ">=2.12,<3" }, ] [package.metadata.requires-dev] diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index d326222efa..755dc46c44 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -19,11 +19,32 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SDK_DISTRIBUTION = "deepseek-harness-sdk" RUNTIME_DISTRIBUTION = "deepseek-harness-runtime-bin" -PLATFORMS = { - "linux-x64": ("manylinux_2_28_x86_64", "dsh-jsonrpc-agent-pkg-linux-x64"), - "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"), - "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), -} +PLATFORM_MANIFEST = ROOT / "python" / "sdk-runtime" / "platforms.json" + + +def load_platforms(path: Path = PLATFORM_MANIFEST) -> dict[str, tuple[str, str]]: + """Load the release platform tag and executable pairs from the build manifest.""" + try: + payload = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError) as error: + raise ValueError(f"could not read runtime platform manifest from {path}") from error + if not isinstance(payload, dict) or not payload: + raise ValueError(f"{path} must contain a non-empty platform object") + platforms: dict[str, tuple[str, str]] = {} + for name, raw in payload.items(): + if ( + not isinstance(name, str) + or not isinstance(raw, dict) + or set(raw) != {"tag", "executable"} + or not isinstance(raw["tag"], str) + or not isinstance(raw["executable"], str) + ): + raise ValueError(f"{path} platform entries must contain string tag and executable fields") + platforms[name] = (raw["tag"], raw["executable"]) + return platforms + + +PLATFORMS = load_platforms() def runtime_suffixes(executable_name: str) -> tuple[str, ...]: @@ -35,7 +56,7 @@ def main() -> None: parser.add_argument("--package", choices=("sdk", "runtime"), required=True) parser.add_argument( "--tag", - help="optional python-vX.Y.Z release tag; it must match package.json", + help="optional python-v release tag; it must match package.json", ) parser.add_argument("--output-dir", type=Path, required=True) parser.add_argument("--platform", choices=tuple(PLATFORMS)) @@ -146,8 +167,29 @@ def rewrite_version(pyproject: Path, version: str) -> None: pyproject.write_text(text) +def stage_license_files(destination: Path, *, include_notices: bool) -> None: + """Copy legal files and declare them as wheel license payloads.""" + shutil.copy2(ROOT / "LICENSE", destination / "LICENSE") + license_files = '["LICENSE"]' + if include_notices: + shutil.copy2(ROOT / "THIRD_PARTY_NOTICES.md", destination / "THIRD_PARTY_NOTICES.md") + license_files = '["LICENSE", "THIRD_PARTY_NOTICES.md"]' + pyproject = destination / "pyproject.toml" + text, count = re.subn( + r'^(license = "[^"]+")$', + rf"\1\nlicense-files = {license_files}", + pyproject.read_text(), + count=1, + flags=re.MULTILINE, + ) + if count != 1: + raise RuntimeError(f"could not declare license files in {pyproject}") + pyproject.write_text(text) + + def stage_sdk(destination: Path, version: str) -> None: copy_package(ROOT / "python" / "sdk", destination) + stage_license_files(destination, include_notices=False) pyproject = destination / "pyproject.toml" rewrite_version(pyproject, version) text, count = re.subn( @@ -163,6 +205,7 @@ def stage_sdk(destination: Path, version: str) -> None: def stage_runtime(destination: Path, version: str, executable: Path, executable_name: str) -> None: copy_package(ROOT / "python" / "sdk-runtime", destination) + stage_license_files(destination, include_notices=True) rewrite_version(destination / "pyproject.toml", version) runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" runtime_dir.mkdir(parents=True, exist_ok=True) @@ -191,6 +234,16 @@ def verify_wheel( raise RuntimeError( f"{wheel} has distribution name {metadata.get('Name')}, expected {expected_distribution}" ) + if metadata.get("License-Expression") != "BSD-3-Clause": + raise RuntimeError( + f"{wheel} has license expression {metadata.get('License-Expression')}, expected BSD-3-Clause" + ) + expected_license_files = ["LICENSE"] if package == "sdk" else ["LICENSE", "THIRD_PARTY_NOTICES.md"] + license_files = [Path(name).name for name in metadata.get_all("License-File") or []] + if license_files != expected_license_files: + raise RuntimeError( + f"{wheel} has license files {license_files}, expected {expected_license_files}" + ) runtime_files = [ name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name ] diff --git a/scripts/check-macos-deployment-target.py b/scripts/check-macos-deployment-target.py new file mode 100644 index 0000000000..633a3d7ced --- /dev/null +++ b/scripts/check-macos-deployment-target.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Reject runtime executables that require newer macOS than their wheel tag.""" + +from __future__ import annotations + +import argparse +import re +import runpy +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +RELEASE = runpy.run_path(str(ROOT / "scripts" / "build-python-release.py")) +MACOS_PLATFORM_TAG = RELEASE["PLATFORMS"]["macos-arm64"][0] + + +def parse_version(value: str) -> tuple[int, ...]: + """Parse a dot-separated numeric deployment version.""" + if re.fullmatch(r"\d+(?:\.\d+)*", value) is None: + raise ValueError(f"invalid macOS deployment version: {value!r}") + return tuple(int(part) for part in value.split(".")) + + +def claimed_version(platform_tag: str) -> tuple[int, ...]: + """Return the minimum macOS version encoded by a wheel platform tag.""" + match = re.fullmatch(r"macosx_(\d+)_(\d+)_arm64", platform_tag) + if match is None: + raise ValueError(f"unsupported macOS wheel platform tag: {platform_tag!r}") + return int(match.group(1)), int(match.group(2)) + + +def parse_otool_deployment_target(output: str) -> tuple[int, ...]: + """Return the newest deployment target from one or more Mach-O slices.""" + versions = [ + parse_version(match.group(1)) + for match in re.finditer(r"^\s*minos\s+(\d+(?:\.\d+)*)\s*$", output, re.MULTILINE) + ] + if not versions: + raise ValueError("otool output contains no LC_BUILD_VERSION deployment target") + return max(versions) + + +def deployment_target(executable: Path) -> tuple[int, ...]: + """Read one Mach-O executable's deployment target with ``otool``.""" + if not executable.is_file(): + raise FileNotFoundError(f"runtime executable does not exist: {executable}") + result = subprocess.run( + ["otool", "-l", str(executable)], + check=True, + capture_output=True, + text=True, + ) + try: + return parse_otool_deployment_target(result.stdout) + except ValueError as error: + raise ValueError(f"{executable}: {error}") from error + + +def ensure_compatible( + executable: Path, actual: tuple[int, ...], platform_tag: str +) -> None: + """Reject an executable whose deployment target exceeds its wheel claim.""" + claimed = claimed_version(platform_tag) + width = max(len(actual), len(claimed)) + padded_actual = actual + (0,) * (width - len(actual)) + padded_claimed = claimed + (0,) * (width - len(claimed)) + if padded_actual > padded_claimed: + rendered = ".".join(str(part) for part in actual) + raise RuntimeError( + f"{executable} requires macOS {rendered} but the wheel claims {platform_tag}" + ) + + +def validate_deployment_targets( + executables: list[Path], platform_tag: str = MACOS_PLATFORM_TAG +) -> list[tuple[Path, tuple[int, ...]]]: + """Validate every executable and return its measured deployment target.""" + measured = [(executable, deployment_target(executable)) for executable in executables] + for executable, actual in measured: + ensure_compatible(executable, actual, platform_tag) + return measured + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("executables", type=Path, nargs="+") + args = parser.parse_args() + for executable, version in validate_deployment_targets(args.executables): + rendered = ".".join(str(part) for part in version) + print(f"{executable}: macOS {rendered} <= {MACOS_PLATFORM_TAG}") + + +if __name__ == "__main__": + main() diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 2642f234c4..6c12159019 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -127,6 +127,135 @@ describe('E2B e2e workflow', () => { }) }) +describe('Python release workflows', () => { + it('keeps complete wheel validation separate from protected public publication', () => { + const workflow = loadWorkflow('.github/workflows/python-release.yml') + const dispatch = workflowEvent(workflow, 'workflow_dispatch') + const pullRequest = workflowEvent(workflow, 'pull_request') + const build = workflowJob(workflow, 'build') + const pythonCompat = workflowJob(workflow, 'python-compat') + const validate = workflowJob(workflow, 'validate') + const publishRuntime = workflowJob(workflow, 'publish-runtime') + const publishSdk = workflowJob(workflow, 'publish-sdk') + if (!isRecord(dispatch.inputs) + || !isRecord(dispatch.inputs.publish) + || !Array.isArray(pythonCompat.steps) + || !Array.isArray(validate.steps) + || !Array.isArray(publishRuntime.steps) + || !Array.isArray(publishSdk.steps)) { + throw new TypeError('Python release workflow must define publish input and release steps') + } + + expect(dispatch.inputs.publish).toMatchObject({ type: 'boolean', default: false }) + expect(pullRequest).toEqual({ types: ['labeled'] }) + expect(build).toMatchObject({ + if: "github.event_name == 'workflow_dispatch' || github.event.label.name == 'python-release-dry-run'", + uses: './.github/workflows/build-exe-for-python-sdk.yml', + with: { + targets: 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64', + release: true, + }, + }) + expect(pythonCompat.strategy).toMatchObject({ matrix: { python: ['3.10', '3.14'] } }) + expect(JSON.stringify(pythonCompat.steps)).toContain('deepseek-harness-sdk==${{ steps.compatibility-version.outputs.version }}') + const validateSteps = JSON.stringify(validate.steps) + const authorize = validate.steps.filter(isRecord).find(step => step.name === 'Authorize publication request') + if (!isRecord(authorize) || typeof authorize.run !== 'string') { + throw new TypeError('Python release validation must authorize publication requests') + } + expect(validateSteps).toContain('PUBLIC_PYPI_RELEASE_ENABLED') + expect(authorize).toMatchObject({ + env: { + PYPI_PUBLISHER_REPOSITORY: '${{ vars.PYPI_PUBLISHER_REPOSITORY }}', + REPOSITORY: '${{ github.repository }}', + }, + }) + expect(authorize.run).toContain('[ "$REPOSITORY" = "$PYPI_PUBLISHER_REPOSITORY" ]') + expect(validateSteps).toContain('100000000') + expect(publishRuntime).toMatchObject({ + if: "github.event_name == 'workflow_dispatch' && inputs.publish", + needs: 'validate', + environment: 'pypi-runtime', + permissions: { contents: 'read', 'id-token': 'write' }, + }) + expect(publishSdk).toMatchObject({ + if: "github.event_name == 'workflow_dispatch' && inputs.publish", + needs: ['validate', 'publish-runtime'], + environment: 'pypi', + permissions: { contents: 'read', 'id-token': 'write' }, + }) + const runtimeSteps = publishRuntime.steps.filter(isRecord) + const sdkSteps = publishSdk.steps.filter(isRecord) + const runtimePublish = runtimeSteps.find(step => step.name === 'Publish runtime wheels') + const sdkPublish = sdkSteps.find(step => step.name === 'Publish SDK wheel') + const runtimeHashes = runtimeSteps.find(step => step.name === 'Verify release artifact hashes') + const sdkHashes = sdkSteps.find(step => step.name === 'Verify release artifact hashes') + expect([...runtimeSteps, ...sdkSteps].some( + step => typeof step.uses === 'string' && step.uses.startsWith('actions/checkout@'), + )).toBe(false) + expect([...runtimeSteps, ...sdkSteps].filter( + step => step.uses === 'pypa/gh-action-pypi-publish@release/v1', + )).toHaveLength(2) + expect(runtimePublish).toMatchObject({ + with: { 'packages-dir': 'dist/runtime/', attestations: false }, + }) + expect(sdkPublish).toMatchObject({ + with: { 'packages-dir': 'dist/sdk/', attestations: false }, + }) + expect(runtimeHashes).toMatchObject({ run: 'cd dist && sha256sum -c SHA256SUMS' }) + expect(sdkHashes).toMatchObject({ run: 'cd dist && sha256sum -c SHA256SUMS' }) + }) + + it('exposes the native wheel builder to the release caller with normalized versions', () => { + const workflow = loadWorkflow('.github/workflows/build-exe-for-python-sdk.yml') + const call = workflowEvent(workflow, 'workflow_call') + const plan = workflowJob(workflow, 'plan') + const build = workflowJob(workflow, 'build') + if (!isRecord(call.inputs) || !Array.isArray(plan.steps) || !Array.isArray(build.steps)) { + throw new TypeError('Python wheel builder must define workflow_call inputs and plan steps') + } + + const buildSteps: unknown[] = build.steps + const manylinuxAddon = buildSteps.find(step => isRecord(step) && step.name === 'Rebuild Linux node-pty against manylinux 2.28') + const macosCheck = buildSteps.find(step => isRecord(step) && step.name === 'Check macOS deployment target') + const manylinuxSmoke = buildSteps.find(step => isRecord(step) && step.name === 'Run wheel in a manylinux 2.28 container') + expect(call.inputs).toHaveProperty('targets') + expect(call.inputs).toMatchObject({ release: { type: 'boolean', default: false } }) + expect(plan.if).toContain('inputs.release') + expect(JSON.stringify(plan.steps)).toContain('pep440_version') + expect(JSON.stringify(workflow)).toContain('macosx_14_0_arm64') + expect(manylinuxAddon).toMatchObject({ if: "runner.os == 'Linux'" }) + expect(JSON.stringify(manylinuxAddon)).toContain('manylinux_2_28_x86_64') + expect(JSON.stringify(manylinuxAddon)).toContain('manylinux_2_28_aarch64') + expect(JSON.stringify(manylinuxAddon)).toContain('$HOME/setup-pnpm:$HOME/setup-pnpm:ro') + expect(JSON.stringify(manylinuxAddon)).toContain('node-pty-glibc-versions.txt') + expect(JSON.stringify(manylinuxAddon)).toContain('le 2.28') + expect(macosCheck).toMatchObject({ if: "runner.os == 'macOS'" }) + expect(JSON.stringify(macosCheck)).toContain('scripts/check-macos-deployment-target.py') + expect(JSON.stringify(macosCheck)).toContain('$EXE-spawn-helper') + expect(manylinuxSmoke).toMatchObject({ if: "runner.os == 'Linux'" }) + expect(JSON.stringify(manylinuxSmoke)).toContain('-e DSH_TELEMETRY_DISABLED') + }) + + it('uses the shared macOS deployment-target check in GitLab', () => { + const workflow = loadWorkflow('.gitlab-ci.yml') + const runtimeWheel = workflow['.runtime-wheel'] + if (!isRecord(runtimeWheel) || !Array.isArray(runtimeWheel.script)) { + throw new TypeError('GitLab CI must define the runtime wheel script') + } + const runtimeScript: unknown[] = runtimeWheel.script + const macosCheck = runtimeScript.find( + step => typeof step === 'string' && step.includes('PLATFORM" = macos-arm64'), + ) + if (typeof macosCheck !== 'string') { + throw new TypeError('GitLab CI must check the macOS deployment target') + } + + expect(macosCheck).toContain('scripts/check-macos-deployment-target.py') + expect(macosCheck).toContain('"$EXE" "$EXE-spawn-helper"') + }) +}) + describe('Issue lifecycle workflow', () => { it('uses explicit review handoff events without rerunning when a draft becomes ready', () => { const lifecycle = loadWorkflow('.github/workflows/issue-lifecycle.yml') diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index 562df43da6..fc00b3ea9e 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -123,7 +123,7 @@ const POSTCONDITIONS: readonly PostCondition[] = [ { file: 'scripts/check-workspace-constraints.ts', text: '?.[\'@deepseek-ai/cordis\']', count: 2 }, { file: 'packages/boot/app-boot/tsdown.config.ts', text: '[\'@deepseek-ai/cordis-plugin-include\']', count: 1 }, { file: 'tsconfig.base.json', text: '"@deepseek-ai/cordis-plugin-loader": ["./vendor/loader/src"]', count: 1 }, - // One insertion, once: a duplicated log entry is what a non-idempotent apply produced. + // The vendored README owns this required entry; reject its deletion or duplication. { file: 'vendor/README.md', text: '17. **`@deepseek-ai` rescope**', count: 1 }, { file: 'knip.json', text: '@cordisjs', count: 0 }, { file: 'pnpm-workspace.yaml', text: 'cordis@4.0.0-rc.7', count: 0 }, @@ -241,13 +241,6 @@ const EXACT_EDITS: readonly ExactEdit[] = [ replace: '| Directory | npm name | Upstream name | Version | Upstream repo | Commit |\n|---|---|---|---|---|---|', expect: 1, }, - { - id: 'vendor-readme-local-modification-log', - file: 'vendor/README.md', - find: '\n16. **`cordis/package.json` publishes `src`**', - replace: '\n16. **`cordis/package.json` publishes `src`**: added `src` to the `files` list, joining the other eight vendored packages. Cordis declares `"./src/*": "./src/*"` in its exports, so a tarball without `src` publishes an export map pointing at absent files; the release change judgement also reads `files` to decide whether a diff reaches the payload, and a package whose only published paths are build output has no tracked path to match.\n17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table\'s `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for(\'schemastery\')` and Schemastery\'s `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table\'s two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).', - expect: 1, - }, { // A plain fence listing the bundle's mounted tree: a bare token, no quotes. id: 'agent-spine-demo-mounted-tree', diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 910b4ffc0a..242f1f43b1 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -155,15 +155,16 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: return text_chunks(WORKFLOW_WORKER_TEXT) raise AssertionError(f"unexpected tool follow-up: {tool_name}") + user_prompts = [ + message_text(message.get("content")) + for message in reversed(messages) + if isinstance(message, dict) and message.get("role") == "user" + ] minimal_prompt = next( ( - message_text(message.get("content")) - for message in reversed(messages) - if isinstance(message, dict) - and message.get("role") == "user" - and message_text(message.get("content")).startswith( - f"{MINIMAL_PROMPT}\n{MINIMAL_EDITOR_PATH_PREFIX}" - ) + prompt + for prompt in user_prompts + if prompt.startswith(f"{MINIMAL_PROMPT}\n{MINIMAL_EDITOR_PATH_PREFIX}") ), None, ) @@ -183,7 +184,17 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: "bash", {"command": MINIMAL_BASH_COMMAND}, ) - prompt = message_text(latest.get("content")) + scenario_prompts = { + SNAPSHOT_DIRECT_CHILD_PROMPT, + SNAPSHOT_WORKFLOW_CHILD_PROMPT, + SNAPSHOT_PROMPT, + CODE_PROMPT, + WORKFLOW_PROMPT, + } + prompt = next( + (candidate for candidate in user_prompts if candidate in scenario_prompts), + message_text(latest.get("content")), + ) if prompt == SNAPSHOT_DIRECT_CHILD_PROMPT: return text_chunks("DIRECT_CHILD_OK") if prompt == SNAPSHOT_WORKFLOW_CHILD_PROMPT: diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json index dff04d578e..ee8cdcb05f 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/result.json +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -2334,9 +2334,42 @@ "payload": { "sessionId": "{{child-1}}", "event": { - "type": "session/title", + "type": "user/message", "seq": 6, "time": 0, + "data": { + "content": [ + { + "type": "text", + "text": "Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it." + } + ], + "source": { + "kind": "plugin", + "plugin": "@deepseek-ai/dsh-system-prompt", + "form": "snapshot", + "sections": [ + { + "name": "subagent:delegation", + "text": "You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it." + } + ] + }, + "role": "user", + "id": "{{messageId}}" + }, + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "session/title", + "seq": 7, + "time": 0, "data": { "title": "Reply with exactly DIRECT_CHILD_OK and", "messageSeqs": [ @@ -2355,7 +2388,7 @@ "sessionId": "{{child-1}}", "event": { "type": "request/header", - "seq": 7, + "seq": 8, "time": 0, "data": { "header": { @@ -2394,7 +2427,7 @@ "sessionId": "{{child-1}}", "event": { "type": "request/context", - "seq": 8, + "seq": 9, "time": 0, "data": { "provider": "deepseek-official", @@ -2410,7 +2443,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 9, + "seq": 10, "time": 0, "data": { "turn": 1, @@ -2430,7 +2463,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 10, + "seq": 11, "time": 0, "data": { "turn": 1, @@ -2450,7 +2483,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 11, + "seq": 12, "time": 0, "data": { "turn": 1, @@ -2473,7 +2506,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 12, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -2495,7 +2528,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 13, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -2516,7 +2549,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/message", - "seq": 14, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -2542,11 +2575,11 @@ } }, "sourceEventSeqs": [ - 9, 10, 11, 12, - 13 + 13, + 14 ], "surfaceOp": "append" } @@ -2558,7 +2591,7 @@ "sessionId": "{{child-1}}", "event": { "type": "step/end", - "seq": 15, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -2573,7 +2606,7 @@ "sessionId": "{{child-1}}", "event": { "type": "turn/end", - "seq": 16, + "seq": 17, "time": 0, "data": { "turn": 1, @@ -2986,9 +3019,42 @@ "payload": { "sessionId": "{{child-2}}", "event": { - "type": "session/title", + "type": "user/message", "seq": 6, "time": 0, + "data": { + "content": [ + { + "type": "text", + "text": "Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it." + } + ], + "source": { + "kind": "plugin", + "plugin": "@deepseek-ai/dsh-system-prompt", + "form": "snapshot", + "sections": [ + { + "name": "subagent:delegation", + "text": "You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it." + } + ] + }, + "role": "user", + "id": "{{messageId}}" + }, + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "session/title", + "seq": 7, + "time": 0, "data": { "title": "Reply with exactly WORKFLOW_CHILD_OK and", "messageSeqs": [ @@ -3007,7 +3073,7 @@ "sessionId": "{{child-2}}", "event": { "type": "request/header", - "seq": 7, + "seq": 8, "time": 0, "data": { "header": { @@ -3046,7 +3112,7 @@ "sessionId": "{{child-2}}", "event": { "type": "request/context", - "seq": 8, + "seq": 9, "time": 0, "data": { "provider": "deepseek-official", @@ -3062,7 +3128,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 9, + "seq": 10, "time": 0, "data": { "turn": 1, @@ -3082,7 +3148,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 10, + "seq": 11, "time": 0, "data": { "turn": 1, @@ -3102,7 +3168,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 11, + "seq": 12, "time": 0, "data": { "turn": 1, @@ -3125,7 +3191,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 12, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -3147,7 +3213,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 13, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -3168,7 +3234,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/message", - "seq": 14, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -3194,11 +3260,11 @@ } }, "sourceEventSeqs": [ - 9, 10, 11, 12, - 13 + 13, + 14 ], "surfaceOp": "append" } @@ -3210,7 +3276,7 @@ "sessionId": "{{child-2}}", "event": { "type": "step/end", - "seq": 15, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -3225,7 +3291,7 @@ "sessionId": "{{child-2}}", "event": { "type": "turn/end", - "seq": 16, + "seq": 17, "time": 0, "data": { "turn": 1, diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl index 3cfcda4d28..73b18b53cd 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl @@ -5,14 +5,15 @@ {"type":"subagent/descriptor","seq":3,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}} {"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":6,"time":0,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} -{"type":"request/context","seq":8,"time":0,"data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} -{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":16,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":6,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":0,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} +{"type":"request/context","seq":9,"time":0,"data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":17,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl index 926acbcecc..07d43b8a2b 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl @@ -5,14 +5,15 @@ {"type":"subagent/descriptor","seq":3,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}} {"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":6,"time":0,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} -{"type":"request/context","seq":8,"time":0,"data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} -{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":16,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":6,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":0,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} +{"type":"request/context","seq":9,"time":0,"data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":17,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index d8944c6e15..9ead4e02b5 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,35 +8,37 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Run\n\nInstall Node.js ^22.19 or >= 24 and pnpm 11, then run the published package:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\nThe command initializes the Web profile and prints the Web UI URL, which is `http://127.0.0.1:3080` by default. Open it, add a DeepSeek API key under **Settings → Models**, then start a session. The invoking directory is the default workspace; try `Summarize this repository and identify its main packages.`\n\nContinue with the [Web UI guide](docs/user/guide/).\n\n### Run from source\n\nTo run a repository checkout instead:\n\n```sh\ngit clone https://github.com/deepseek-harness/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm dsh web\n```\n\nThe last command builds the repository and opens the same Web UI path.\n\n## Profiles and plugins\n\nA profile is an ordered list of plugin bundles. The shipped `web` profile powers `dsh web`. Manage a profile with `dsh plugin --profile `, which forwards the remaining arguments to pnpm in that profile's directory:\n\n```sh\nnpx -p @deepseek-ai/dsh dsh plugin --profile web add \nnpx -p @deepseek-ai/dsh dsh plugin --profile web remove \n```\n\n`add`, `remove`, `update`, `why`, and other pnpm commands work unchanged. The command initializes a missing profile before changing its packages and updates its bundle list from installed packages that declare `dsh.bundle`. See the [CLI reference](apps/cli/reference/README.md#plugin-management) for the exact behavior.\n\nThe [CLI reference](apps/cli/README.md) covers headless execution and custom profiles. The [Python SDK](python/README.md) and [examples](examples/README.md) cover programmatic and custom compositions.\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n\n## Contributing\n\nRead [CONTRIBUTING.md](CONTRIBUTING.md) before contributing to this repository.\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Run\n\nInstall Node.js ^22.19 or >= 24 and pnpm 11, then run the published package:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\nThe command initializes the Web profile and prints the Web UI URL, which is `http://127.0.0.1:3080` by default. Open it, add a DeepSeek API key under **Settings → Models**, then start a session. The invoking directory is the default workspace; try `Summarize this repository and identify its main packages.`\n\nContinue with the [Web UI guide](docs/user/guide/index.md).\n\n### Run from source\n\nTo run a repository checkout instead:\n\n```sh\ngit clone https://github.com/deepseek-harness/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm run build\npnpm dsh web\n```\n\n`pnpm run build` prepares the repository artifacts. `pnpm dsh web` starts the Web UI without rebuilding and opens the same path.\n\n## Profiles and plugins\n\nA profile is an ordered list of plugin bundles. The shipped `web` profile powers `dsh web`. Manage a profile with `dsh plugin --profile `, which forwards the remaining arguments to pnpm in that profile's directory:\n\n```sh\nnpx -p @deepseek-ai/dsh dsh plugin --profile web add \nnpx -p @deepseek-ai/dsh dsh plugin --profile web remove \n```\n\n`add`, `remove`, `update`, `why`, and other pnpm commands work unchanged. The command initializes a missing profile before changing its packages and updates its bundle list from installed packages that declare `dsh.bundle`. See the [CLI reference](apps/cli/reference/README.md#plugin-management) for the exact behavior.\n\nThe [CLI reference](apps/cli/README.md) covers headless execution and custom profiles. The [Python SDK](python/README.md) and [examples](examples/README.md) cover programmatic and custom compositions.\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n\n## Contributing\n\nRead [CONTRIBUTING.md](CONTRIBUTING.md) before contributing to this repository.\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 运行\n\n安装 Node.js ^22.19 或 >= 24 和 pnpm 11,然后运行已发布的包:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\n该命令会初始化 Web profile 并打印 Web UI 地址,默认地址为 `http://127.0.0.1:3080`。打开该地址,在**设置 → 模型**中添加 DeepSeek API 密钥,然后启动一个会话。调用目录是默认工作区;你可以尝试输入 `Summarize this repository and identify its main packages.`。\n\n下一步请阅读 [Web UI 指南](docs/user/guide/)。\n\n### 从源码运行\n\n如需改为运行仓库 checkout:\n\n```sh\ngit clone https://github.com/deepseek-harness/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm dsh web\n```\n\n最后一条命令会构建仓库,并进入相同的 Web UI 路径。\n\n## Profile 与插件\n\nprofile 是按顺序排列的插件 bundle 列表。随附的 `web` profile 为 `dsh web` 提供功能。使用 `dsh plugin --profile ` 管理 profile;该命令会在对应 profile 目录中将剩余参数转发给 pnpm:\n\n```sh\nnpx -p @deepseek-ai/dsh dsh plugin --profile web add \nnpx -p @deepseek-ai/dsh dsh plugin --profile web remove \n```\n\n`add`、`remove`、`update`、`why` 等 pnpm 命令均可直接使用。该命令会先初始化不存在的 profile,再修改其中的包,并根据声明了 `dsh.bundle` 的已安装包更新 bundle 列表。准确行为见 [CLI 参考](apps/cli/reference/README.md#plugin-management)。\n\n[CLI(命令行界面)参考](apps/cli/README.md)介绍 headless 执行与自定义 profile。[Python SDK](python/README.md) 和[示例](examples/README.md)介绍程序化组合与自定义组合。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n\n## 参与贡献\n\n向本仓库贡献前,请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 运行\n\n安装 Node.js ^22.19 或 >= 24 和 pnpm 11,然后运行已发布的包:\n\n```sh\nnpx @deepseek-ai/dsh web\n```\n\n该命令会初始化 Web profile 并打印 Web UI 地址,默认地址为 `http://127.0.0.1:3080`。打开该地址,在**设置 → 模型**中添加 DeepSeek API 密钥,然后启动一个会话。调用目录是默认工作区;你可以尝试输入 `Summarize this repository and identify its main packages.`。\n\n下一步请阅读 [Web UI 指南](docs/user/guide/index.md)。\n\n### 从源码运行\n\n如需改为运行仓库 checkout:\n\n```sh\ngit clone https://github.com/deepseek-harness/deepseek-harness.git\ncd deepseek-harness\npnpm install\npnpm run build\npnpm dsh web\n```\n\n`pnpm run build` 准备仓库产物。`pnpm dsh web` 启动 Web UI,不会重新构建,并进入相同的路径。\n\n## Profile 与插件\n\nprofile 是按顺序排列的插件 bundle 列表。随附的 `web` profile 为 `dsh web` 提供功能。使用 `dsh plugin --profile ` 管理 profile;该命令会在对应 profile 目录中将剩余参数转发给 pnpm:\n\n```sh\nnpx -p @deepseek-ai/dsh dsh plugin --profile web add \nnpx -p @deepseek-ai/dsh dsh plugin --profile web remove \n```\n\n`add`、`remove`、`update`、`why` 等 pnpm 命令均可直接使用。该命令会先初始化不存在的 profile,再修改其中的包,并根据声明了 `dsh.bundle` 的已安装包更新 bundle 列表。准确行为见 [CLI 参考](apps/cli/reference/README.md#plugin-management)。\n\n[CLI(命令行界面)参考](apps/cli/README.md)介绍 headless 执行与自定义 profile。[Python SDK](python/README.md) 和[示例](examples/README.md)介绍程序化组合与自定义组合。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n\n## 参与贡献\n\n向本仓库贡献前,请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。\n" }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI organization. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local Lefthook hooks and the `dsh-translation-pairing` Git merge driver through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the hook-path safety contract; the [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the merge driver.\n\nIf either integration is missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No |\n| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler settings (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No |\n\nHost and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges.\n- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase.\n\n`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf; it discovers split packages from the presence of both leaf configs, so a new split joins the gate automatically. Do not copy this structure to other packages; the [`api-remotes` README](../packages/api/remotes/README.md) explains the Host/Client split and build order.\n\nThe root build follows the generated dependency order:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\nBoth tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase.\n\nTypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the TypeRT contract-generation pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate setup, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [TypeRT Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract.\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git integrations\n\nThe pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact files and states the driver accepts.\n\nThe installer probes the exact Node/tsx driver entrypoint before publishing its worktree configuration. If that runtime later becomes unavailable, the Node-independent launcher writes Git's ordinary text result, leaves the sidecar unresolved, and prints the recovery path; restore dependencies and run `pnpm run resolve-translation-pairing-conflicts`, or run `git merge --abort`. If `pre-merge-commit` rejects an otherwise clean merge, Git leaves the complete result staged without a commit; repair the failure and run `git commit`, or abort. The [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract) owns the exact index and `MERGE_HEAD` states.\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit.\n- `pre-push` runs `pnpm run typecheck`, which completes the Host lib phase, including generated TypeRT contracts, before the Client TypeScript check.\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nApart from the scoped staged-record verification, the hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of the Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [subsystems](subsystems/README.md) pages paste source-equivalent declarations together with their original JSDoc so a reader sees the exact type definition and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact type definition. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI organization. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local Lefthook hooks and the `dsh-translation-pairing` Git merge driver through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the hook-path safety contract; the [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the merge driver.\n\nIf either integration is missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No |\n| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler settings (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No |\n\nHost and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges.\n- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase.\n\n`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf; it discovers split packages from the presence of both leaf configs, so a new split joins the gate automatically. Do not copy this structure to other packages; the [`api-remotes` README](../packages/api/remotes/README.md) explains the Host/Client split and build order.\n\nThe root build follows the generated dependency order:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\nBoth tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase.\n\nTypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the TypeRT contract-generation pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate setup, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [TypeRT Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract.\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git integrations\n\nThe pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact files and states the driver accepts.\n\nThe installer probes the exact Node/tsx driver entrypoint before publishing its worktree configuration. If that runtime later becomes unavailable, the Node-independent launcher writes Git's ordinary text result, leaves the sidecar unresolved, and prints the recovery path; restore dependencies and run `pnpm run resolve-translation-pairing-conflicts`, or run `git merge --abort`. If `pre-merge-commit` rejects an otherwise clean merge, Git leaves the complete result staged without a commit; repair the failure and run `git commit`, or abort. The [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract) owns the exact index and `MERGE_HEAD` states.\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit.\n- `pre-push` runs `pnpm run typecheck`, which completes the Host lib phase, including generated TypeRT contracts, before the Client TypeScript check.\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nApart from the scoped staged-record verification, the hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of the Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nRun the repository build separately before using these source-checkout demos:\n\n```sh\npnpm run build\n```\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [subsystems](subsystems/README.md) pages paste source-equivalent declarations together with their original JSDoc so a reader sees the exact type definition and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact type definition. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出目录通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 组织方式。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 Lefthook 钩子和 `dsh-translation-pairing` Git 合并驱动。[worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责钩子路径的安全约定;[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责合并驱动。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致任一集成缺失,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库使用相互隔离的 Host 与 Client aggregate。普通包只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 |\n| `tsconfig.host.json` | Host aggregate:Host 包、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 |\n| `tsconfig.client.json` | Client aggregate:`packages/client/*` 包及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译设置(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` 包 extends。 | 否 |\n\nHost 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式以 `tsconfig.host.json` 或 `tsconfig.client.json` 为种子——根 solution 永不作为种子,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。\n- 新包只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client 插件的两份运行时产物都在 Client 构建阶段生成。\n\n`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf;该门禁按「两个 leaf 配置同时存在」自动发现拆分包,所以新拆分的包会自动纳入管辖。不要把该结构推广到其他包;[`api-remotes` README](../packages/api/remotes/README.md) 说明 Host/Client 拆分与构建顺序。\n\n根构建按生成依赖排序:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\n两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client 包,也不维护 Host/Client 包过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client 插件在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。\n\nTypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 TypeRT 约定生成阶段或完整构建。两个 aggregate 的设置见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [TypeRT Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。\n\n业务服务在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验包入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 集成\n\n当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob,推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。[双语文档约定](i18n/README.md#the-pairing-contract)列出该驱动接受的确切文件和状态。\n\n安装脚本在发布 worktree 配置前,会探测确切的 Node/tsx 驱动入口点。如果该运行时之后变得不可用,不依赖 Node 的启动器会写入 Git 的普通文本合并结果、让伴随文件保持未解决状态,并打印恢复路径;请恢复依赖后运行 `pnpm run resolve-translation-pairing-conflicts`,或运行 `git merge --abort`。如果 `pre-merge-commit` 拒绝原本能干净完成的合并,Git 会把完整结果留在暂存区但不创建提交;请修复失败后运行 `git commit`,或中止合并。确切的索引与 `MERGE_HEAD` 状态由[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract)负责记录。\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查;\n- `pre-push` 运行 `pnpm run typecheck`;该命令会先完成包含 TypeRT 约定生成的完整 Host lib 阶段,再运行 Client TypeScript 检查。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n除限定范围的暂存记录校验外,这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;包公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型定义(`ts type-equiv`)\n\n[子系统](subsystems/README.md)页面会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切类型定义和源码约定。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切类型定义。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例的计算之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 组织方式。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 Lefthook 钩子和 `dsh-translation-pairing` Git 合并驱动。[worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责钩子路径的安全约定;[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责合并驱动。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致任一集成缺失,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库使用相互隔离的 Host 与 Client aggregate。普通 package 只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 |\n| `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 |\n| `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译设置(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 |\n\nHost 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。\n- 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。\n\n`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf;该门禁按「两个 leaf 配置同时存在」自动发现拆分包,所以新拆分的包会自动纳入管辖。不要把该结构推广到其他包;[`api-remotes` README](../packages/api/remotes/README.md) 说明 Host/Client 拆分与构建顺序。\n\n根构建按生成依赖排序:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\n两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client package,也不维护 Host/Client package 过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client plugin 在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。\n\nTypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 TypeRT 约定生成阶段或完整构建。两个 aggregate 的设置见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [TypeRT Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 集成\n\n当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob,推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。[双语文档约定](i18n/README.md#the-pairing-contract)列出该驱动接受的确切文件和状态。\n\n安装脚本在发布 worktree 配置前,会探测确切的 Node/tsx 驱动入口点。如果该运行时之后变得不可用,不依赖 Node 的启动器会写入 Git 的普通文本合并结果、让伴随文件保持未解决状态,并打印恢复路径;请恢复依赖后运行 `pnpm run resolve-translation-pairing-conflicts`,或运行 `git merge --abort`。如果 `pre-merge-commit` 拒绝原本能干净完成的合并,Git 会把完整结果留在暂存区但不创建提交;请修复失败后运行 `git commit`,或中止合并。确切的索引与 `MERGE_HEAD` 状态由[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract)负责记录。\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查;\n- `pre-push` 运行 `pnpm run typecheck`;该命令会先完成包含 TypeRT 约定生成的完整 Host lib 阶段,再运行 Client TypeScript 检查。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n除限定范围的暂存记录校验外,这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n从源码 checkout 运行这些演示前,请单独执行仓库构建:\n\n```sh\npnpm run build\n```\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[子系统](subsystems/README.md)页面会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切类型定义和源码约定。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切类型定义。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" + }, { "role": "user", - "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. Routine agent work follows the lightweight path in [docs/AGENTS.md](../AGENTS.md); the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow is available only through explicit user invocation.\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. Routine work makes that patch directly; when the user explicitly invokes the extended workflow, `pnpm run gen-translation-brief ` can instead assemble the update at the narrowest safely aligned granularity and `--apply` can splice a code-fence-only change after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n\n When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.\n- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), the Chinese side and every authored English source carry their language switchers (listed generated English sources are exempt), and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing ` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart directly in one terminology-guided pass and re-records the pair with `--write `**, exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: the root CONTRIBUTING document, every non-vendor README, and every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\nGenerated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md) — generated without a reviewed Chinese counterpart, so both website locales project the English source.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nRoutine counterparts are updated directly by the working agent in one shot and one pass after it loads [terminology.md](terminology.md); it does not invoke a translation skill, generate a briefing, run a separate translation-review pass, or delegate to a subagent. The extended [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow retains those heavier mechanisms for explicit user invocation. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" + "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. Routine agent work follows the lightweight path in [docs/AGENTS.md](../AGENTS.md); the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow is available only through explicit user invocation.\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. Routine work makes that patch directly; when the user explicitly invokes the extended workflow, `pnpm run gen-translation-brief ` can instead assemble the update at the narrowest safely aligned granularity and `--apply` can splice a code-fence-only change after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n\n When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.\n- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output. A README published outside GitHub, such as PyPI project metadata, may use the canonical `https://github.com/deepseek-ai/deepseek-harness/blob/master/` URL to the same counterpart so the switcher still resolves there.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), the Chinese side and every authored English source carry their language switchers (listed generated English sources are exempt), and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing ` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart directly in one terminology-guided pass and re-records the pair with `--write `**, exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: the root CONTRIBUTING document, every non-vendor README, and every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\nGenerated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md) — generated without a reviewed Chinese counterpart, so both website locales project the English source.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nRoutine counterparts are updated directly by the working agent in one shot and one pass after it loads [terminology.md](terminology.md); it does not invoke a translation skill, generate a briefing, run a separate translation-review pass, or delegate to a subagent. The extended [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow retains those heavier mechanisms for explicit user invocation. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" }, { "role": "assistant", - "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。agent 的日常工作遵循 [docs/AGENTS.md](../AGENTS.md) 中的轻量路径;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时可用。\n\n## 配对约定\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 Git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的 worktree 内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。日常工作会直接完成这份修补;用户显式调用扩展工作流时,可改由 `pnpm run gen-translation-brief ` 以能安全对齐的最窄粒度汇集这次更新,并由 `--apply` 在结构校验后拼接仅涉及围栏代码块的改动([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 YAML diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n\n 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。\n- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份约定:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、中文侧和所有普通撰写的英文源都带语言切换行(清单内的生成英文源除外)、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest(元数据清单)中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing ` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 在术语指导下直接一次完成对侧文件的更新,并用 `--write ` 重新记录配对**,与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n门禁的限制很明确:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与 Markdown 结构;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:根目录 CONTRIBUTING 文档、除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n有经评审的中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md):该生成文档没有经评审的中文对侧,因此网站的两个 locale 都投影英文源文件。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n日常更新对侧文件时,负责处理的 agent 会先加载 [terminology.md](terminology.md),再直接一次性更新且只处理一遍;它不会调用翻译 skill(技能)、生成简报、执行单独的翻译评审轮次,也不会委派给 subagent。扩展版 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流保留这些较重的机制,仅供用户显式调用。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" + "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。agent 的日常工作遵循 [docs/AGENTS.md](../AGENTS.md) 中的轻量路径;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时可用。\n\n## 配对约定\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。日常工作会直接完成这份修补;用户显式调用扩展工作流时,可改由 `pnpm run gen-translation-brief ` 以能安全对齐的最窄粒度汇集这次更新,并由 `--apply` 在结构校验后拼接仅涉及围栏代码块的改动([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n\n 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。\n- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。发布到 GitHub 以外位置的 README(例如 PyPI 项目元数据)可以改用指向同一对侧文件的规范 `https://github.com/deepseek-ai/deepseek-harness/blob/master/` URL,使切换行在该位置仍可访问。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份约定:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、中文侧和所有普通撰写的英文源都带语言切换行(清单内的生成英文源除外)、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing ` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 在术语指导下直接一次完成对侧文件的更新,并用 `--write ` 重新记录配对**,与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n门禁的限制很明确:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与 Markdown 结构;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:根目录 CONTRIBUTING 文档、除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n有经评审中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md):该生成文档没有经评审的中文对侧,因此网站的两个 locale 都投影英文源文件。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n日常更新对侧文件时,负责处理的 agent 会先加载 [terminology.md](terminology.md),再直接一次性更新且只处理一遍;它不会调用翻译 skill(技能)、生成简报、执行单独的翻译评审轮次,也不会委派给 subagent。扩展版 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流保留这些较重的机制,仅供用户显式调用。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" + }, { "role": "user", - "content": "# Translation rules\n\nEnglish | [中文](translation-rules.zh.md)\n\nHow to translate between the two sides of a documentation pair in this repo. Both languages carry equal authority ([README.md](README.md)): a change is authored in either language, and that side is the source for that update — these rules govern producing or updating the counterpart. They bind humans and agents equally. Routine agent work translates the changed content directly in one terminology-guided pass; the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow runs only when the user explicitly invokes it. Rule levels follow RFC 2119 usage: **MUST** / **MUST NOT** are gate- or review-blocking; **SHOULD** needs a stated reason to deviate; **MAY** is discretionary.\n\n## Faithfulness\n\n- The counterpart *MUST* say what the authored side says — no added behavior, prerequisites, warnings, version claims, or examples, and no dropped ones. If the pair disagrees on substance, neither language wins by default: fix the side that is wrong, then bring the other along in the same change.\n- The counterpart *SHOULD* read as natural technical writing in its own language, not word-by-word gloss. Translate meaning, restructure sentences where the target grammar wants it, and keep the author's register — terse stays terse.\n- Do not translate the untranslatable: if a sentence resists natural rendering because it leans on an idiom of the source language, translate the idea, not the idiom.\n\n## Voice\n\n- The register is calibrated by [style-samples.md](style-samples.md) — human-approved gold pairs, one per document genre. The counterpart MUST match the target-language side of the nearest sample; where its voice and a prose voice rule disagree, the sample wins. Chinese targets use institutional technical Chinese; English targets use concise professional developer prose.\n- Write as a native technical author restating the content, not as a translator transposing sentences, while preserving every source clause: nothing added, nothing dropped — fluency never justifies losing a clause.\n- Give sentences an explicit actor when the target language would otherwise obscure it; for Chinese, replace vague passives or abstract subjects with the actual actor (系统、门禁、评审人).\n- Prefer established target-language engineering idiom over calques (误报/漏检 for false positive/negative, 执行红线 for enforcement frontier); localize metaphors instead of transplanting them, and unpack noun chains where the target language requires it.\n- Split long paragraphs by semantic unit — one idea per paragraph. Paragraph boundaries MAY differ from the source; the structural signature does not count paragraphs.\n- When translating into Chinese, category nouns use Chinese with a first-mention English annotation (实操手册(cookbook)); when translating into English, use the conventional English category name. Literal directory or file references stay code-formatted English.\n\n## Structure preservation\n\nThe pairing gate checks heading depths, fenced code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, and link targets. Preserve the rest of the frame manually; the paired files MUST match one to one in:\n\n- heading hierarchy (same levels, same order — heading TEXT is translated),\n- list shape and numbering,\n- tables (same columns, same row order; header cells translated per terminology),\n- fenced code blocks — **byte-identical, including comments**; the pairing signature compares their info strings and contents, and ` ```ts ` blocks compile under `doc-typecheck`,\n- inline code spans (commands, flags, config keys, file paths, event names, API names, version numbers) — verbatim, never translated or reformatted,\n- links and anchors: every relative link MUST point at the same target in both files — by convention the `.md` path, not the `.zh.md` sibling — so links never dangle when one pair lands before its neighbors. The ONLY zh-specific link is the language switcher. Link TEXT is translated; the target is not.\n\nThe repo's Markdown conventions apply to `.zh.md` files unchanged: one physical line per paragraph (`verify-md-wrap`), resolving relative links (`verify-md-links`), exactly one trailing newline.\n\n## Terminology\n\n- [terminology.md](terminology.md) is the source of truth in both directions. Before translating, load it; every listed term MUST follow its row and its \"不要译作\" prohibitions. A Chinese target uses the \"中文\" column and its \"首次出现\" annotation; an English target uses the \"English\" column without adding a Chinese gloss.\n- For a Chinese target, an unlisted technical term MAY use an established rendering from a major Chinese-language OSS or vendor source (K8s/Vue/MDN Chinese docs, 微软简中风格指南, big-tech project docs), cited in the PR. Without such precedent it MUST stay in English and be listed under 「待定术语」(pending terms) with a suggested rendering.\n- For an English target, use the established English technical term. If the source term has no unambiguous established equivalent, preserve it with a short explanatory gloss and list it under pending terms. Neither direction may invent a rendering inline; a decided term enters [terminology.md](terminology.md) in the same PR or a follow-up.\n\n## Typography\n\nThese rules govern the Chinese side; the English side follows the repo's normal Markdown conventions (root `AGENTS.md`). The mixed-script rules below follow the cross-project consensus of the [MDN Simplified Chinese translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md), the [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/), the [Vue.js Chinese translation conventions](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5), and [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines), which in turn ground in [W3C clreq](https://www.w3.org/TR/clreq/) and GB/T 15834—2011:\n\n- MUST put one half-width space between Chinese text and Latin words, and between Chinese text and numerals: `每个 plugin 注册 3 个 tool`。No space between a full-width punctuation mark and anything.\n- MUST use full-width (Chinese) punctuation in Chinese prose: `,。:;?!()「」`. Half-width punctuation stays inside code spans, inside complete English sentences quoted as-is, and in numbers (`3.5`, `1,024`).\n- Chinese prose *SHOULD* prefer colons, periods, commas, or parentheses over em dashes. Keep an em dash only when no other punctuation preserves the sentence naturally.\n- Enumeration commas: a Chinese list of parallel items uses 顿号(、), not commas.\n- MUST NOT use full-width digits or full-width Latin letters — `123` never, `123` always.\n- Proper nouns keep their canonical casing: GitHub, TypeScript, DeepSeek — never `github`/`Github` unless quoting code.\n- Second person is 你, not 您 (matches the Vue and Kubernetes Chinese conventions and this repo's direct voice).\n- Emphasis markers (`**bold**`, `*italic*`) stay on the same spans as the source; Chinese has no italics, so the rendered emphasis may look identical — do not substitute quotation marks or other decoration.\n\n## Quality bar\n\n- A pair is done when a bilingual engineer reading either file alone gets everything a reader of the other gets — same facts, same caveats, same tone — and nothing extra.\n- Run `pnpm run verify-translation-pairing` and the rest of `doc-sync` for records, switchers, heading depths, code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, links, and repository Markdown rules. Human review owns list and table order, noncanonical list numbering, inline code, emphasis, meaning, terminology, and tone.\n\n## References\n\nAuthorities cited by these rules, for humans and agents who want the underlying reasoning:\n\n- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines) — the de-facto community standard for mixed CJK/Latin spacing and punctuation.\n- [MDN zh-CN translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md) — an in-repo translation-rules file of the same shape as this one; spacing, punctuation, and glossary practice.\n- [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/) — terminology-first-occurrence and punctuation practice from the largest zh localization team.\n- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) — per-term translate/keep decisions and tone.\n- [zh-style-guide](https://zh-style-guide.readthedocs.io) — a community Chinese technical-writing style guide whose rule-level taxonomy (and RFC 2119 keyword levels) this file borrows; aggregates GB/T 15834/15835, clreq, and vendor guides.\n- [W3C clreq](https://www.w3.org/TR/clreq/) and the [Microsoft Simplified Chinese style guide](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides) — the formal typographic and vendor-localization baselines.\n- GB/T 19682-2005《翻译服务译文质量要求》 — the national standard whose three base requirements (忠实原文、术语统一、行文通顺) this file's Faithfulness and Terminology sections operationalize.\n" + "content": "# Translation rules\n\nEnglish | [中文](translation-rules.zh.md)\n\nHow to translate between the two sides of a documentation pair in this repo. Both languages carry equal authority ([README.md](README.md)): a change is authored in either language, and that side is the source for that update — these rules govern producing or updating the counterpart. They bind humans and agents equally. Routine agent work translates the changed content directly in one terminology-guided pass; the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow runs only when the user explicitly invokes it. Rule levels follow RFC 2119 usage: **MUST** / **MUST NOT** are gate- or review-blocking; **SHOULD** needs a stated reason to deviate; **MAY** is discretionary.\n\n## Faithfulness\n\n- The counterpart *MUST* say what the authored side says — no added behavior, prerequisites, warnings, version claims, or examples, and no dropped ones. If the pair disagrees on substance, neither language wins by default: fix the side that is wrong, then bring the other along in the same change.\n- The counterpart *SHOULD* read as natural technical writing in its own language, not word-by-word gloss. Translate meaning, restructure sentences where the target grammar wants it, and keep the author's register — terse stays terse.\n- Do not translate the untranslatable: if a sentence resists natural rendering because it leans on an idiom of the source language, translate the idea, not the idiom.\n\n## Voice\n\n- The register is calibrated by [style-samples.md](style-samples.md) — human-approved gold pairs, one per document genre. The counterpart MUST match the target-language side of the nearest sample; where its voice and a prose voice rule disagree, the sample wins. Chinese targets use institutional technical Chinese; English targets use concise professional developer prose.\n- Write as a native technical author restating the content, not as a translator transposing sentences, while preserving every source clause: nothing added, nothing dropped — fluency never justifies losing a clause.\n- Give sentences an explicit actor when the target language would otherwise obscure it; for Chinese, replace vague passives or abstract subjects with the actual actor (系统、门禁、评审人).\n- Prefer established target-language engineering idiom over calques (误报/漏检 for false positive/negative, 执行红线 for enforcement frontier); localize metaphors instead of transplanting them, and unpack noun chains where the target language requires it.\n- Split long paragraphs by semantic unit — one idea per paragraph. Paragraph boundaries MAY differ from the source; the structural signature does not count paragraphs.\n- When translating into Chinese, category nouns use Chinese with a first-mention English annotation (实操手册(cookbook)); when translating into English, use the conventional English category name. Literal directory or file references stay code-formatted English.\n\n## Structure preservation\n\nThe pairing gate checks heading depths, fenced code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, and link targets. Preserve the rest of the frame manually; the paired files MUST match one to one in:\n\n- heading hierarchy (same levels, same order — heading TEXT is translated),\n- list shape and numbering,\n- tables (same columns, same row order; header cells translated per terminology),\n- fenced code blocks — **byte-identical, including comments**; the pairing signature compares their info strings and contents, and ` ```ts ` blocks compile under `doc-typecheck`,\n- inline code spans (commands, flags, config keys, file paths, event names, API names, version numbers) — verbatim, never translated or reformatted,\n- links and anchors: every relative link MUST point at the same target in both files — by convention the `.md` path, not the `.zh.md` sibling — so links never dangle when one pair lands before its neighbors. The ONLY zh-specific link is the language switcher. A README rendered outside GitHub MAY use the canonical public repository URL to its exact counterpart as documented in [README.md](README.md). Link TEXT is translated; the target is not.\n\nThe repo's Markdown conventions apply to `.zh.md` files unchanged: one physical line per paragraph (`verify-md-wrap`), resolving relative links (`verify-md-links`), exactly one trailing newline.\n\n## Terminology\n\n- [terminology.md](terminology.md) is the source of truth in both directions. Before translating, load it; every listed term MUST follow its row and its \"不要译作\" prohibitions. A Chinese target uses the \"中文\" column and its \"首次出现\" annotation; an English target uses the \"English\" column without adding a Chinese gloss.\n- For a Chinese target, an unlisted technical term MAY use an established rendering from a major Chinese-language OSS or vendor source (K8s/Vue/MDN Chinese docs, 微软简中风格指南, big-tech project docs), cited in the PR. Without such precedent it MUST stay in English and be listed under 「待定术语」(pending terms) with a suggested rendering.\n- For an English target, use the established English technical term. If the source term has no unambiguous established equivalent, preserve it with a short explanatory gloss and list it under pending terms. Neither direction may invent a rendering inline; a decided term enters [terminology.md](terminology.md) in the same PR or a follow-up.\n\n## Typography\n\nThese rules govern the Chinese side; the English side follows the repo's normal Markdown conventions (root `AGENTS.md`). The mixed-script rules below follow the cross-project consensus of the [MDN Simplified Chinese translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md), the [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/), the [Vue.js Chinese translation conventions](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5), and [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines), which in turn ground in [W3C clreq](https://www.w3.org/TR/clreq/) and GB/T 15834—2011:\n\n- MUST put one half-width space between Chinese text and Latin words, and between Chinese text and numerals: `每个 plugin 注册 3 个 tool`。No space between a full-width punctuation mark and anything.\n- MUST use full-width (Chinese) punctuation in Chinese prose: `,。:;?!()「」`. Half-width punctuation stays inside code spans, inside complete English sentences quoted as-is, and in numbers (`3.5`, `1,024`).\n- Chinese prose *SHOULD* prefer colons, periods, commas, or parentheses over em dashes. Keep an em dash only when no other punctuation preserves the sentence naturally.\n- Enumeration commas: a Chinese list of parallel items uses 顿号(、), not commas.\n- MUST NOT use full-width digits or full-width Latin letters — `123` never, `123` always.\n- Proper nouns keep their canonical casing: GitHub, TypeScript, DeepSeek — never `github`/`Github` unless quoting code.\n- Second person is 你, not 您 (matches the Vue and Kubernetes Chinese conventions and this repo's direct voice).\n- Emphasis markers (`**bold**`, `*italic*`) stay on the same spans as the source; Chinese has no italics, so the rendered emphasis may look identical — do not substitute quotation marks or other decoration.\n\n## Quality bar\n\n- A pair is done when a bilingual engineer reading either file alone gets everything a reader of the other gets — same facts, same caveats, same tone — and nothing extra.\n- Run `pnpm run verify-translation-pairing` and the rest of `doc-sync` for records, switchers, heading depths, code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, links, and repository Markdown rules. Human review owns list and table order, noncanonical list numbering, inline code, emphasis, meaning, terminology, and tone.\n\n## References\n\nAuthorities cited by these rules, for humans and agents who want the underlying reasoning:\n\n- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines) — the de-facto community standard for mixed CJK/Latin spacing and punctuation.\n- [MDN zh-CN translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md) — an in-repo translation-rules file of the same shape as this one; spacing, punctuation, and glossary practice.\n- [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/) — terminology-first-occurrence and punctuation practice from the largest zh localization team.\n- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) — per-term translate/keep decisions and tone.\n- [zh-style-guide](https://zh-style-guide.readthedocs.io) — a community Chinese technical-writing style guide whose rule-level taxonomy (and RFC 2119 keyword levels) this file borrows; aggregates GB/T 15834/15835, clreq, and vendor guides.\n- [W3C clreq](https://www.w3.org/TR/clreq/) and the [Microsoft Simplified Chinese style guide](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides) — the formal typographic and vendor-localization baselines.\n- GB/T 19682-2005《翻译服务译文质量要求》 — the national standard whose three base requirements (忠实原文、术语统一、行文通顺) this file's Faithfulness and Terminology sections operationalize.\n" }, { "role": "assistant", - "content": "# 翻译规则\n\n[English](translation-rules.md) | 中文\n\n本文规定:如何在本仓库文档配对的中英文两种语言之间进行翻译。两种语言同权(见 [README.md](README.md)):每次变更可以用任一语言撰写,被编辑的一侧即为本次更新的源;本文的规则约束如何产出或更新对侧文件。这些规则对人类和 agent(智能体)同等生效。日常工作中,agent 会在术语指导下直接一次完成有改动内容的翻译;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时运行。规则级别沿用 RFC 2119 的用法:**必须(MUST)** / **禁止(MUST NOT)** 会卡门禁或评审;**应当(SHOULD)** 偏离时要说明理由;**可以(MAY)** 自行裁量。\n\n## 忠实性\n\n- 对侧文件*必须*传达与撰写侧相同的内容:不添加行为、前置条件、警告、版本声明或示例,也不漏掉任何一项。如果两侧在实质内容上不一致,没有哪种语言默认获胜;请修正错误的一侧,并在同一个变更里同步更新另一侧。\n- 对侧文件读起来*应当*是其语言自然的技术文字,而非逐词对照的译文。请根据语义翻译,在目标语言语法需要时重组句子,并保持原作者的语域(比如:简练的保持简练)。\n- 不要翻译不可译的内容:如果一句话依赖源语言的习语、无法自然转换,请翻译它的意思,而非习语本身。\n\n## 行文\n\n- 语体以 [style-samples.md](style-samples.md) 为校准锚点。人工定稿的金标样例按文体各一组,译文必须参照文体最接近的样例,采用其中目标语言一侧的语体;如果样例与本文的行文规则冲突,以样例为准。译成中文时,采用规范的技术制度文;译成英文时,采用简洁、专业的开发者文档语体。\n- 以母语技术作者的身份重述内容,而不是以译者身份逐句转写,同时保留原文的每个语义成分:不添加、不遗漏——流畅永远不是丢掉语义成分的理由。\n- 如果直译会让执行主体含糊,请明确写出实际执行者;译成中文时,应由「系统、门禁、评审人」等实际执行者作主语,避免含糊的被动句或抽象主语。\n- 优先采用目标语言中通行的工程表达,避免生硬直译(false positive/negative→误报/漏检、enforcement frontier→执行红线);隐喻应自然改写,名词链则按目标语言的习惯拆开。\n- 长段按语义单元拆分,一段一件事。段落边界可以与原文不同;结构签名不比对段落数。\n- 翻译为中文时,类别名词使用中文并在首现括注英文(实操手册(cookbook));翻译为英文时,使用通行的英文类别名。指目录或文件本身时保留代码体英文。\n\n## 结构保持\n\n配对门禁会检查标题深度、围栏代码块、表格行列数、列表类型、有序列表起始编号、列表项数量与链接目标;门禁未覆盖的结构仍需人工核对。两个配对文件必须在以下方面一一对应:\n\n- 标题层级(相同级别、相同顺序;标题的**文字**要翻译);\n- 列表形态与编号;\n- 表格(相同的列、相同的行序;表头单元格按术语表翻译);\n- 围栏代码块:**逐字节一致,包括注释**。配对签名比对信息字符串与内容,` ```ts ` 块还要通过 `doc-typecheck` 编译;\n- 行内代码(命令、flag、配置键、文件路径、事件名、API 名、版本号):原样保留,从不翻译或重排;\n- 链接与锚点:每个相对链接在两个文件中必须指向相同的目标(按约定是 `.md` 路径而非 `.zh.md` 兄弟文件),这样即使某对文档先于相邻文件落地,链接也不会悬空。唯一的 zh 特有链接是语言切换行。链接**文字**翻译;链接目标不翻。\n\n本仓库的 Markdown 约定对 `.zh.md` 文件原样生效:一个段落一个物理行(`verify-md-wrap`)、相对链接必须可解析(`verify-md-links`)、文件末尾恰好一个换行。\n\n## 术语\n\n- [terminology.md](terminology.md) 是双向的术语真源。翻译前请先加载它;表内术语必须遵守对应行与「不要译作」禁项。译成中文时,采用「中文」列,并按「首次出现」列括注;译成英文时,采用「English」列,不加中文括注。\n- 译成中文时,术语表未收录的技术术语只有在主流中文 OSS 文档或厂商资料中已有通行译法时才可以翻译(K8s/Vue/MDN 中文文档、微软简中风格指南、大厂项目文档),并须在 PR 中注明出处;否则必须保留英文,并在 PR 描述的「待定术语」中给出建议译法。\n- 译成英文时,采用通行的英文技术术语。如果源术语没有明确的通行对应词,则保留原词、附上简短说明,并列入「待定术语」。两个方向都不得自行创造译法;确定后的术语须在同一个 PR 或后续 PR 中加入 [terminology.md](terminology.md)。\n\n## 排版\n\n本节规则约束中文一侧;英文一侧遵循仓库常规的 Markdown 约定(根 `AGENTS.md`)。以下中西文混排规则遵循 [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md)、[Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/)、[Vue.js 中文翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) 与[中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines)的跨项目共识,其根据是 [W3C clreq](https://www.w3.org/TR/clreq/) 与 GB/T 15834—2011:\n\n- 必须在中文与拉丁词之间、中文与数字之间各留一个半角空格:`每个 plugin 注册 3 个 tool`。全角标点与任何字符之间不加空格。\n- 中文行文必须使用全角(中文)标点:`,。:;?!()「」`。半角标点保留在代码内、按原样引用的完整英文句子内、以及数字内(`3.5`、`1,024`)。\n- 中文行文*应当*优先使用冒号、句号、逗号或括号,尽量不用破折号;只有其他标点都无法自然表达时才保留破折号。\n- 顿号:中文的并列项之间使用顿号(、),而非逗号。\n- 禁止使用全角数字或全角拉丁字母:永远不写 `123`,永远写 `123`。\n- 专有名词保持规范大小写:GitHub、TypeScript、DeepSeek。除非引用代码,否则绝不写 `github`/`Github`。\n- 第二人称用「你」,不用「您」(与 Vue、Kubernetes 中文约定及本仓库的直接语气一致)。\n- 强调标记(`**加粗**`、`*斜体*`)落在与对侧相同的文字段上。中文没有斜体,渲染效果可能看不出差别,不要用引号或其他装饰替代。\n\n## 质量标准\n\n- 一对文档的完成标准:一位双语工程师只读其中任一文件,能获得与另一文件读者完全相同的信息(相同的事实、相同的告诫、相同的语气),并且没有任何多余的内容。\n- 请运行 `pnpm run verify-translation-pairing` 与 `doc-sync` 的其余门禁。这些门禁会检查一致性记录、切换行、标题深度、代码块、表格行列数、列表类型、有序列表起始编号、列表项数量、链接及仓库 Markdown 规则;列表与表格的顺序、非常规列表编号、行内代码、强调标记、语义、术语和语体则由人工评审负责。\n\n## 参考资料\n\n本文各规则引用的权威出处,供想了解底层依据的人和 agent 查阅:\n\n- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines):中西文混排空格与标点的社区事实标准。\n- [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md):与本文同形态的仓库内置翻译规则文件;空格、标点与术语表实践。\n- [Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/):最大的中文本地化团队的术语首现与标点实践。\n- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5):逐术语的译/留决策与语气。\n- [zh-style-guide](https://zh-style-guide.readthedocs.io):社区中文技术文档写作规范,本文借用了它的规则级别分类体系(与 RFC 2119 关键词分级);它聚合了 GB/T 15834/15835、clreq 与各厂商指南。\n- [W3C clreq](https://www.w3.org/TR/clreq/) 与[微软简体中文风格指南](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides):排版学与厂商本地化的正式基线。\n- GB/T 19682-2005《翻译服务译文质量要求》:国家标准;本文「忠实性」与「术语」两节将其三项基本要求(忠实原文、术语统一、行文通顺)落实为可操作的规则。\n" + "content": "# 翻译规则\n\n[English](translation-rules.md) | 中文\n\n本文规定:如何在本仓库文档配对的中英文两种语言之间进行翻译。两种语言同权(见 [README.md](README.md)):每次变更可以用任一语言撰写,被编辑的一侧即为本次更新的源;本文的规则约束如何产出或更新对侧文件。这些规则对人类和 agent(智能体)同等生效。日常工作中,agent 会在术语指导下直接一次完成有改动内容的翻译;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时运行。规则级别沿用 RFC 2119 的用法:**必须(MUST)** / **禁止(MUST NOT)** 会卡门禁或评审;**应当(SHOULD)** 偏离时要说明理由;**可以(MAY)** 自行裁量。\n\n## 忠实性\n\n- 对侧文件*必须*传达与撰写侧相同的内容:不添加行为、前置条件、警告、版本声明或示例,也不漏掉任何一项。如果两侧在实质内容上不一致,没有哪种语言默认获胜;请修正错误的一侧,并在同一个变更里同步更新另一侧。\n- 对侧文件读起来*应当*是其语言自然的技术文字,而非逐词对照的译文。请根据语义翻译,在目标语言语法需要时重组句子,并保持原作者的语域(比如:简练的保持简练)。\n- 不要翻译不可译的内容:如果一句话依赖源语言的习语、无法自然转换,请翻译它的意思,而非习语本身。\n\n## 行文\n\n- 语体以 [style-samples.md](style-samples.md) 为校准锚点。人工定稿的金标样例按文体各一组,译文必须参照文体最接近的样例,采用其中目标语言一侧的语体;如果样例与本文的行文规则冲突,以样例为准。译成中文时,采用规范的技术制度文;译成英文时,采用简洁、专业的开发者文档语体。\n- 以母语技术作者的身份重述内容,而不是以译者身份逐句转写,同时保留原文的每个语义成分:不添加、不遗漏——流畅永远不是丢掉语义成分的理由。\n- 如果直译会让执行主体含糊,请明确写出实际执行者;译成中文时,应由「系统、门禁、评审人」等实际执行者作主语,避免含糊的被动句或抽象主语。\n- 优先采用目标语言中通行的工程表达,避免生硬直译(false positive/negative→误报/漏检、enforcement frontier→执行红线);隐喻应自然改写,名词链则按目标语言的习惯拆开。\n- 长段按语义单元拆分,一段一件事。段落边界可以与原文不同;结构签名不比对段落数。\n- 翻译为中文时,类别名词使用中文并在首现括注英文(实操手册(cookbook));翻译为英文时,使用通行的英文类别名。指目录或文件本身时保留代码体英文。\n\n## 结构保持\n\n配对门禁会检查标题深度、围栏代码块、表格行列数、列表类型、有序列表起始编号、列表项数量与链接目标;门禁未覆盖的结构仍需人工核对。两个配对文件必须在以下方面一一对应:\n\n- 标题层级(相同级别、相同顺序;标题的**文字**要翻译);\n- 列表形态与编号;\n- 表格(相同的列、相同的行序;表头单元格按术语表翻译);\n- 围栏代码块:**逐字节一致,包括注释**。配对签名比对信息字符串与内容,` ```ts ` 块还要通过 `doc-typecheck` 编译;\n- 行内代码(命令、flag、配置键、文件路径、事件名、API 名、版本号):原样保留,从不翻译或重排;\n- 链接与锚点:每个相对链接在两个文件中必须指向相同的目标(按约定是 `.md` 路径而非 `.zh.md` 兄弟文件),这样即使某对文档先于相邻文件落地,链接也不会悬空。唯一的 zh 特有链接是语言切换行。在 GitHub 以外位置渲染的 README 可以按 [README.md](README.md) 的规定,使用指向确切对侧文件的规范公开仓库 URL。链接**文字**翻译;链接目标不翻。\n\n本仓库的 Markdown 约定对 `.zh.md` 文件原样生效:一个段落一个物理行(`verify-md-wrap`)、相对链接必须可解析(`verify-md-links`)、文件末尾恰好一个换行。\n\n## 术语\n\n- [terminology.md](terminology.md) 是双向的术语真源。翻译前请先加载它;表内术语必须遵守对应行与「不要译作」禁项。译成中文时,采用「中文」列,并按「首次出现」列括注;译成英文时,采用「English」列,不加中文括注。\n- 译成中文时,术语表未收录的技术术语只有在主流中文 OSS 文档或厂商资料中已有通行译法时才可以翻译(K8s/Vue/MDN 中文文档、微软简中风格指南、大厂项目文档),并须在 PR 中注明出处;否则必须保留英文,并在 PR 描述的「待定术语」中给出建议译法。\n- 译成英文时,采用通行的英文技术术语。如果源术语没有明确的通行对应词,则保留原词、附上简短说明,并列入「待定术语」。两个方向都不得自行创造译法;确定后的术语须在同一个 PR 或后续 PR 中加入 [terminology.md](terminology.md)。\n\n## 排版\n\n本节规则约束中文一侧;英文一侧遵循仓库常规的 Markdown 约定(根 `AGENTS.md`)。以下中西文混排规则遵循 [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md)、[Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/)、[Vue.js 中文翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) 与[中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines)的跨项目共识,其根据是 [W3C clreq](https://www.w3.org/TR/clreq/) 与 GB/T 15834—2011:\n\n- 必须在中文与拉丁词之间、中文与数字之间各留一个半角空格:`每个 plugin 注册 3 个 tool`。全角标点与任何字符之间不加空格。\n- 中文行文必须使用全角(中文)标点:`,。:;?!()「」`。半角标点保留在代码内、按原样引用的完整英文句子内、以及数字内(`3.5`、`1,024`)。\n- 中文行文*应当*优先使用冒号、句号、逗号或括号,尽量不用破折号;只有其他标点都无法自然表达时才保留破折号。\n- 顿号:中文的并列项之间使用顿号(、),而非逗号。\n- 禁止使用全角数字或全角拉丁字母:永远不写 `123`,永远写 `123`。\n- 专有名词保持规范大小写:GitHub、TypeScript、DeepSeek。除非引用代码,否则绝不写 `github`/`Github`。\n- 第二人称用「你」,不用「您」(与 Vue、Kubernetes 中文约定及本仓库的直接语气一致)。\n- 强调标记(`**加粗**`、`*斜体*`)落在与对侧相同的文字段上。中文没有斜体,渲染效果可能看不出差别,不要用引号或其他装饰替代。\n\n## 质量标准\n\n- 一对文档的完成标准:一位双语工程师只读其中任一文件,能获得与另一文件读者完全相同的信息(相同的事实、相同的告诫、相同的语气),并且没有任何多余的内容。\n- 请运行 `pnpm run verify-translation-pairing` 与 `doc-sync` 的其余门禁。这些门禁会检查一致性记录、切换行、标题深度、代码块、表格行列数、列表类型、有序列表起始编号、列表项数量、链接及仓库 Markdown 规则;列表与表格的顺序、非常规列表编号、行内代码、强调标记、语义、术语和语体则由人工评审负责。\n\n## 参考资料\n\n本文各规则引用的权威出处,供想了解底层依据的人和 agent 查阅:\n\n- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines):中西文混排空格与标点的社区事实标准。\n- [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md):与本文同形态的仓库内置翻译规则文件;空格、标点与术语表实践。\n- [Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/):最大的中文本地化团队的术语首现与标点实践。\n- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5):逐术语的译/留决策与语气。\n- [zh-style-guide](https://zh-style-guide.readthedocs.io):社区中文技术文档写作规范,本文借用了它的规则级别分类体系(与 RFC 2119 关键词分级);它聚合了 GB/T 15834/15835、clreq 与各厂商指南。\n- [W3C clreq](https://www.w3.org/TR/clreq/) 与[微软简体中文风格指南](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides):排版学与厂商本地化的正式基线。\n- GB/T 19682-2005《翻译服务译文质量要求》:国家标准;本文「忠实性」与「术语」两节将其三项基本要求(忠实原文、术语统一、行文通顺)落实为可操作的规则。\n" }, { "role": "user", diff --git a/scripts/translation-pairing-merge.ts b/scripts/translation-pairing-merge.ts index 5ba9ceb0cb..856c909091 100644 --- a/scripts/translation-pairing-merge.ts +++ b/scripts/translation-pairing-merge.ts @@ -12,8 +12,9 @@ import { storeGitBlob, } from './translation-pairing-git.ts' import { - linksTo, isTranslationScopeFile, + languageSwitcherTargets, + linksTo, parseTranslationMarkdown, requiresSourceLanguageSwitcher, translationStructureDiff, @@ -164,15 +165,17 @@ function loadRecordOwners( function assertMergedPairStructure(paths: TranslationPairPaths, source: Buffer, zh: Buffer): void { const sourceTree = parseTranslationMarkdown(source.toString('utf8')) const zhTree = parseTranslationMarkdown(zh.toString('utf8')) - if (requiresSourceLanguageSwitcher(paths.source) && !linksTo(sourceTree, basename(paths.zh))) { + const sourceSwitcherTargets = languageSwitcherTargets(paths.source) + const zhSwitcherTargets = languageSwitcherTargets(paths.zh) + if (requiresSourceLanguageSwitcher(paths.source) && !linksTo(sourceTree, zhSwitcherTargets)) { throw new Error(`${paths.source} clean merge lost its language-switcher link to ${basename(paths.zh)}`) } - if (!linksTo(zhTree, basename(paths.source))) { + if (!linksTo(zhTree, sourceSwitcherTargets)) { throw new Error(`${paths.zh} clean merge lost its language-switcher link to ${basename(paths.source)}`) } const divergences = translationStructureDiff( - translationStructureSignature(sourceTree, basename(paths.zh)), - translationStructureSignature(zhTree, basename(paths.source)), + translationStructureSignature(sourceTree, zhSwitcherTargets), + translationStructureSignature(zhTree, sourceSwitcherTargets), ) if (divergences.length > 0) { throw new Error(`${paths.source} and ${paths.zh} clean merges diverge structurally: ${divergences.join('; ')}`) diff --git a/scripts/translation-pairing.spec.ts b/scripts/translation-pairing.spec.ts index a77dd98248..efddff2bbe 100644 --- a/scripts/translation-pairing.spec.ts +++ b/scripts/translation-pairing.spec.ts @@ -14,6 +14,8 @@ import { import { blobHash, isTranslationScopeFile, + languageSwitcherTargets, + linksTo, pairAnchorOfArgument, parseTranslationMarkdown, parseTranslationPairingCliArgs, @@ -151,6 +153,20 @@ describe('translation pairing switchers', () => { expect(requiresSourceLanguageSwitcher('docs/architecture.md')).toBe(true) expect(requiresSourceLanguageSwitcher('packages/core/session/README.md')).toBe(true) }) + + it('accepts only the canonical public URL for an absolute switcher', () => { + const targets = languageSwitcherTargets('python/sdk/README.zh.md') + const canonical = parseTranslationMarkdown( + '[中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.zh.md)', + ) + const wrongPath = parseTranslationMarkdown( + '[中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/other/README.zh.md)', + ) + + expect(linksTo(canonical, targets)).toBe(true) + expect(translationStructureSignature(canonical, targets).links).toEqual([]) + expect(linksTo(wrongPath, targets)).toBe(false) + }) }) describe('translation pairing records', () => { diff --git a/scripts/translation-pairing.ts b/scripts/translation-pairing.ts index ef94d84e2c..930764034b 100644 --- a/scripts/translation-pairing.ts +++ b/scripts/translation-pairing.ts @@ -302,11 +302,19 @@ export function parseTranslationMarkdown(content: string): Nodes { return fromMarkdown(content, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) } -/** Whether the tree contains a link to exactly `target`. */ -export function linksTo(tree: Nodes, target: string): boolean { +const PUBLIC_REPOSITORY_BLOB_ROOT = 'https://github.com/deepseek-ai/deepseek-harness/blob/master/' + +/** Return the accepted relative and public-repository links to one counterpart. */ +export function languageSwitcherTargets(counterpart: string): string[] { + return [basename(counterpart), `${PUBLIC_REPOSITORY_BLOB_ROOT}${counterpart}`] +} + +/** Whether the tree contains a link to any accepted target. */ +export function linksTo(tree: Nodes, targets: string | readonly string[]): boolean { + const accepted = new Set(typeof targets === 'string' ? [targets] : targets) let found = false const visit = (node: Nodes): void => { - if (node.type === 'link' && node.url === target) found = true + if (node.type === 'link' && accepted.has(node.url)) found = true if ('children' in node) for (const child of node.children) visit(child) } visit(tree) @@ -335,8 +343,14 @@ export function requiresSourceLanguageSwitcher(source: string): boolean { ].includes(source) } -/** Collect the ordered structural signature, skipping one switcher target. */ -export function translationStructureSignature(tree: Nodes, switcherTarget: string): TranslationStructureSignature { +/** Collect the ordered structural signature, skipping accepted switcher targets. */ +export function translationStructureSignature( + tree: Nodes, + switcherTargets: string | readonly string[], +): TranslationStructureSignature { + const acceptedSwitchers = new Set( + typeof switcherTargets === 'string' ? [switcherTargets] : switcherTargets, + ) const sig: TranslationStructureSignature = { headings: [], code: [], tables: [], lists: [], links: [] } const visit = (node: Nodes): void => { switch (node.type) { @@ -355,7 +369,7 @@ export function translationStructureSignature(tree: Nodes, switcherTarget: strin : `bullet:items=${node.children.length}`) break case 'link': - if (node.url !== switcherTarget) sig.links.push(node.url) + if (!acceptedSwitchers.has(node.url)) sig.links.push(node.url) break default: // Every other node kind is prose or a container, not part of the signature. diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index b11a8845e8..f2270444ac 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -91,6 +91,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-settings': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-settings-general': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-models': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, + 'packages/client/ui-plugins': { kind: 'none', reason: 'Browser-side inventory projection; registers nothing model-facing.' }, 'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' }, @@ -105,6 +106,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/host/directory-picker-native': { kind: 'none', reason: 'The GUI-host picking backend registers nothing model-facing.' }, 'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers nothing model-facing.' }, 'packages/host/frontend-static': { kind: 'none', reason: 'The SPA dist server answers browser asset requests and registers nothing model-facing.' }, + 'packages/host/plugin-inventory': { kind: 'none', reason: 'Host-side read-only Loader projection; registers nothing model-facing.' }, 'packages/bundle/base': { kind: 'indirect', reason: 'The bundle is a patch-list carrier; each inserted row\'s package owns its model-facing behavior.' }, 'packages/bundle/headless': { kind: 'none', reason: 'The one-shot runner submits the task as an ordinary user message; prompts and tools belong to the composed base and headless bundles.' }, 'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' }, diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts index e91661e5e4..9bfc1a1d73 100644 --- a/scripts/verify-translation-pairing.ts +++ b/scripts/verify-translation-pairing.ts @@ -19,6 +19,7 @@ import { translationPairPaths, } from './translation-pairing-record.ts' import { + languageSwitcherTargets, linksTo, parseTranslationMarkdown, parseTranslationPairingCliArgs, @@ -252,15 +253,17 @@ for (const source of [...pairAnchors].sort()) { const sourceTree = parseTranslationMarkdown(sourceContent.toString('utf8')) const zhTree = parseTranslationMarkdown(zhContent.toString('utf8')) - if (!linksTo(zhTree, basename(source))) { + const sourceSwitcherTargets = languageSwitcherTargets(source) + const zhSwitcherTargets = languageSwitcherTargets(zh) + if (!linksTo(zhTree, sourceSwitcherTargets)) { errors.push(`${zh}: missing language switcher — no link to ${basename(source)}`) } - if (requiresSourceLanguageSwitcher(source) && !linksTo(sourceTree, basename(zh))) { + if (requiresSourceLanguageSwitcher(source) && !linksTo(sourceTree, zhSwitcherTargets)) { errors.push(`${source}: missing language switcher — no link back to ${basename(zh)}`) } for (const divergence of translationStructureDiff( - translationStructureSignature(sourceTree, basename(zh)), - translationStructureSignature(zhTree, basename(source)), + translationStructureSignature(sourceTree, zhSwitcherTargets), + translationStructureSignature(zhTree, sourceSwitcherTargets), )) { errors.push(`${source} ↔ ${zh}: ${divergence}`) } diff --git a/tsconfig.base.json b/tsconfig.base.json index ff8e58e361..b373642dab 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -161,6 +161,8 @@ "@deepseek-ai/dsh-host-apiproxy/client": ["./packages/host/apiproxy/src/fetch/client.ts"], "@deepseek-ai/dsh-host-apiproxy/*": ["./packages/host/apiproxy/src/*"], "@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"], + "@deepseek-ai/dsh-host-plugin-inventory": ["./packages/host/plugin-inventory/src"], + "@deepseek-ai/dsh-host-plugin-inventory/types": ["./packages/host/plugin-inventory/src/types.ts"], "@deepseek-ai/dsh-client-ui-slots": ["./packages/client/ui-slots/src"], "@deepseek-ai/dsh-client-ui-attachment": ["./packages/client/ui-attachment/src"], "@deepseek-ai/dsh-client-ui-primitives": ["./packages/client/ui-primitives/src"], @@ -199,6 +201,7 @@ "@deepseek-ai/dsh-client-ui-settings": ["./packages/client/ui-settings/src"], "@deepseek-ai/dsh-client-ui-settings-general": ["./packages/client/ui-settings-general/src"], "@deepseek-ai/dsh-client-ui-models": ["./packages/client/ui-models/src"], + "@deepseek-ai/dsh-client-ui-plugins": ["./packages/client/ui-plugins/src"], "@deepseek-ai/dsh-client-locale": ["./packages/client/locale/src"], "@deepseek-ai/dsh-client-web": ["./packages/client/web/src"], // sdk/ folders are role-named without their npm-side sdk/jsonrpc prefixes, diff --git a/tsconfig.client.json b/tsconfig.client.json index c956867b7a..3a84926202 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -84,6 +84,7 @@ { "path": "./packages/client/ui-settings" }, { "path": "./packages/client/ui-settings-general" }, { "path": "./packages/client/ui-models" }, + { "path": "./packages/client/ui-plugins" }, { "path": "./packages/client/locale" }, { "path": "./packages/client/web" }, { "path": "./apps/web" } diff --git a/tsconfig.host.json b/tsconfig.host.json index 118078eb0d..bf82f0e965 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -281,6 +281,7 @@ { "path": "./packages/host/directory-picker-browse" }, { "path": "./packages/host/directory-picker-native" }, { "path": "./packages/host/frontend-static" }, + { "path": "./packages/host/plugin-inventory" }, { "path": "./packages/host/webserver" }, { "path": "./packages/sdk/client" }, { "path": "./packages/sdk/protocol" }, diff --git a/vitest.config.ts b/vitest.config.ts index c698057915..9a317029f5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -29,12 +29,26 @@ const windowsUnsupportedPackages = process.platform === 'win32' 'packages/bash/bash-sandbox', 'packages/bash/tool-bash', 'packages/hooks/*', - 'packages/subprocess/*', 'packages/pty/pty-local', 'packages/sandbox/sandbox-local', ] : [] +const windowsUnsupportedTests = process.platform === 'win32' + ? [ + ...windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), + 'packages/subprocess/subprocess/tests/**/*.spec.ts', + 'packages/subprocess/subprocess-local/tests/local.spec.ts', + 'packages/subprocess/subprocess-local/tests/process-inspector.spec.ts', + 'packages/subprocess/subprocess-local/tests/spawn.spec.ts', + 'packages/subprocess/subprocess-local/tests/terminal.spec.ts', + ] + : [] + +const windowsUnsupportedCoveragePackages = process.platform === 'win32' + ? [...windowsUnsupportedPackages, 'packages/subprocess/*'] + : [] + // Windows-only packages: their sources execute exclusively on win32 (koffi // loads Win32 libraries), so the Linux coverage lane can never cover them. // The Windows dev/CI lane exercises them through the probe/runner suites; the @@ -92,6 +106,7 @@ const coverageExemptExcludes = coverageExemptRaw === '1' const processBoundTests = [ 'packages/session/session-persistence-jsonl/tests/jsonl.spec.ts', 'packages/subagent/subagent-acp/tests/subagent-acp.spec.ts', + 'packages/subprocess/subprocess-local/tests/process-exit.spec.ts', 'packages/subprocess/subprocess-local/tests/spawn.spec.ts', 'packages/context/time-context/tests/time-context.spec.ts', 'packages/llm/llm-pi-ai/tests/adapter.spec.ts', @@ -105,7 +120,7 @@ export default defineConfig({ setupFiles: ['./scripts/test-invariants.ts'], // .tsx: client component specs (jsdom via per-file @vitest-environment pragma). include: testIncludes, - exclude: windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), + exclude: windowsUnsupportedTests, // One coverage invocation aggregates both projects. Every suite forks for // Node stability; process-bound suites stay separate for inventory control. projects: [ @@ -121,7 +136,7 @@ export default defineConfig({ setupFiles: ['./scripts/test-invariants.ts'], include: testIncludes, exclude: [ - ...windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), + ...windowsUnsupportedTests, ...processBoundTests, ...coverageExemptExcludes, ], @@ -136,7 +151,7 @@ export default defineConfig({ setupFiles: ['./scripts/test-invariants.ts'], include: processBoundTests, exclude: [ - ...windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), + ...windowsUnsupportedTests, ...coverageExemptExcludes, ], }, @@ -239,7 +254,7 @@ export default defineConfig({ 'packages/interaction/commands/src/index.ts', 'packages/interaction/commands/src/invariant.ts', 'packages/session/session-projection/src/index.ts', - ...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`), + ...windowsUnsupportedCoveragePackages.map(path => `${path}/src/**/*.ts`), ...windowsOnlyCoverageExclusions, ...windowsRunnerCoverageExclusions, ...pwshCoverageExclusions,