From 757c7212dd16053b45c3002cb499ae9bd8e6e53e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 9 Aug 2026 22:32:44 +0800 Subject: [PATCH 01/31] Refine documentation homepage positioning --- docs/user/index.i18n.yaml | 4 ++-- docs/user/index.md | 14 +++++++------- docs/user/index.zh.md | 14 +++++++------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/user/index.i18n.yaml b/docs/user/index.i18n.yaml index 619c549462..670a3c8033 100644 --- a/docs/user/index.i18n.yaml +++ b/docs/user/index.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/index.md -index.md: e9a1f03785c7472c47550ec59ea0165d28d3d9a6 -index.zh.md: aba42d79d36e7f5c2e6833f609e48f7b2a79f813 +index.md: bf656e391273b828abe67bc0741f2efe7de957c6 +index.zh.md: 1d4a45a1423604d60ec9fba76431f9b7f0844044 diff --git a/docs/user/index.md b/docs/user/index.md index e9a1f03785..bf656e3912 100644 --- a/docs/user/index.md +++ b/docs/user/index.md @@ -2,7 +2,7 @@ layout: home hero: name: DeepSeek Harness - text: Plugin-based agent development framework + text: Plugin-based Coding Agent tagline: Built on the Cordis microkernel; everything is a plugin actions: - theme: brand @@ -12,12 +12,12 @@ hero: text: Develop plugins link: /en/develop/basic/ features: - - title: Plugin architecture - details: Built on the Cordis plugin system. Every capability is registered by a plugin, takes effect when loaded, and is reverted when unloaded. - - title: Configuration as composition - details: One cordis.yml determines the agent's complete capability set. Change a model or add a tool by editing configuration. - - title: Ready to use - details: Includes LLM calls, file access, Bash execution, subagent delegation, and the rest of the core toolchain. Copy a template to get started. + - title: Microkernel + details: The kernel manages plugin lifecycles, events, and dependencies without containing product-specific capabilities. + - title: Plugin-first + details: Models, tools, sessions, and storage are provided by plugins that work together through events. + - title: Composable + details: Select, replace, or extend capabilities through configuration without modifying the Agent Loop. --- # DeepSeek Harness diff --git a/docs/user/index.zh.md b/docs/user/index.zh.md index aba42d79d3..1d4a45a142 100644 --- a/docs/user/index.zh.md +++ b/docs/user/index.zh.md @@ -2,7 +2,7 @@ layout: home hero: name: DeepSeek Harness - text: 插件化 agent(智能体)开发框架 + text: 插件化 Coding Agent tagline: 基于 Cordis 微内核,一切皆插件 actions: - theme: brand @@ -12,12 +12,12 @@ hero: text: 开发插件 link: /develop/basic/ features: - - title: 插件化架构 - details: 基于 Cordis 插件系统,所有能力通过插件注册,加载即生效、卸载即还原。 - - title: 配置即组合 - details: 一个 cordis.yml 决定整个 agent 的能力组合——换模型、加工具,只需改一行配置。 - - title: 开箱即用 - details: 内置 LLM(大语言模型)调用、文件读写、Bash 执行、subagent 委派等完整工具链,复制模板即可运行。 + - title: 微内核 + details: 内核只负责插件生命周期、事件通信和依赖管理,不包含具体业务能力。 + - title: 插件化 + details: 模型、工具、会话和存储都由插件提供,并通过事件协作。 + - title: 自由组合 + details: 通过配置选择、替换或扩展能力,不需要修改 Agent Loop。 --- # DeepSeek Harness From 8de6df19d9763491b0a2c47f026909853ae9ed2a Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 10 Aug 2026 18:37:30 +0800 Subject: [PATCH 02/31] feat(workflow): show durable run records in Chat --- .../2026-07-05-dynamic-workflows.i18n.yaml | 4 +- .../feature/2026-07-05-dynamic-workflows.md | 5 +- .../2026-07-05-dynamic-workflows.zh.md | 5 +- ...10-durable-workflow-runs-in-chat.i18n.yaml | 6 + ...026-08-10-durable-workflow-runs-in-chat.md | 45 ++ ...-08-10-durable-workflow-runs-in-chat.zh.md | 45 ++ ...apse-workflow-to-foreground-core.i18n.yaml | 4 +- ...12-collapse-workflow-to-foreground-core.md | 12 +- ...collapse-workflow-to-foreground-core.zh.md | 12 +- apps/web/tests/assembled-boot.ts | 1 + .../snapshots/workflow-run/ui.expected.md | 55 ++ apps/web/tests/workflow-run.e2e.ts | 170 ++++++ apps/web/tsconfig.json | 3 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 3 +- docs/config-catalog.zh.md | 3 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 18 +- docs/event-producer-consumer.zh.md | 20 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 14 +- docs/module-graph.zh.md | 14 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 50 ++ docs/persistence-catalog.zh.md | 50 ++ docs/subsystems/workflow.i18n.yaml | 4 +- docs/subsystems/workflow.md | 60 +- docs/subsystems/workflow.zh.md | 60 +- knip.json | 10 + packages/bundle/web-app/cordis.patch.yml | 5 + packages/bundle/web-app/package.json | 1 + packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 1 + packages/client/README.zh.md | 1 + .../client/ui-workflow-run/README.i18n.yaml | 6 + packages/client/ui-workflow-run/README.md | 35 ++ packages/client/ui-workflow-run/README.zh.md | 35 ++ packages/client/ui-workflow-run/package.json | 73 +++ .../src/client/WorkflowRunPanel.module.css | 250 +++++++++ .../src/client/WorkflowRunPanel.tsx | 235 ++++++++ .../ui-workflow-run/src/client/index.ts | 38 ++ .../ui-workflow-run/src/client/locales.ts | 49 ++ .../src/client/workflow-definition.ts | 200 +++++++ .../ui-workflow-run/src/css-modules.d.ts | 6 + packages/client/ui-workflow-run/src/index.ts | 4 + .../client/ui-workflow-run/src/invariant.ts | 24 + .../tests/workflow-run.spec.tsx | 526 ++++++++++++++++++ packages/client/ui-workflow-run/tsconfig.json | 42 ++ .../client/ui-workflow-run/tsdown.config.ts | 3 + .../workflow/tool-workflow/README.i18n.yaml | 4 +- packages/workflow/tool-workflow/README.md | 5 + packages/workflow/tool-workflow/README.zh.md | 5 + packages/workflow/tool-workflow/package.json | 6 + packages/workflow/tool-workflow/src/index.ts | 157 +++++- .../workflow/tool-workflow/src/invariant.ts | 164 +++++- packages/workflow/tool-workflow/src/types.ts | 64 +++ .../tool-workflow/tests/invariant.spec.ts | 199 +++++++ .../tool-workflow/tests/tool-workflow.spec.ts | 225 +++++++- packages/workflow/tool-workflow/tsconfig.json | 3 + packages/workflow/workflow/README.i18n.yaml | 4 +- packages/workflow/workflow/README.md | 2 + packages/workflow/workflow/README.zh.md | 2 + packages/workflow/workflow/package.json | 5 + packages/workflow/workflow/src/index.ts | 6 +- .../workflow/workflow/src/runtime-types.ts | 49 ++ packages/workflow/workflow/src/types.ts | 54 +- packages/workflow/workflow/tsconfig.json | 3 + pnpm-lock.yaml | 46 ++ scripts/type-equiv.manifest.json | 4 +- .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 3 + tsconfig.client.json | 1 + tsconfig.host.json | 1 + 73 files changed, 3013 insertions(+), 227 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md create mode 100644 .agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md create mode 100644 apps/web/tests/snapshots/workflow-run/ui.expected.md create mode 100644 apps/web/tests/workflow-run.e2e.ts create mode 100644 packages/client/ui-workflow-run/README.i18n.yaml create mode 100644 packages/client/ui-workflow-run/README.md create mode 100644 packages/client/ui-workflow-run/README.zh.md create mode 100644 packages/client/ui-workflow-run/package.json create mode 100644 packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css create mode 100644 packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx create mode 100644 packages/client/ui-workflow-run/src/client/index.ts create mode 100644 packages/client/ui-workflow-run/src/client/locales.ts create mode 100644 packages/client/ui-workflow-run/src/client/workflow-definition.ts create mode 100644 packages/client/ui-workflow-run/src/css-modules.d.ts create mode 100644 packages/client/ui-workflow-run/src/index.ts create mode 100644 packages/client/ui-workflow-run/src/invariant.ts create mode 100644 packages/client/ui-workflow-run/tests/workflow-run.spec.tsx create mode 100644 packages/client/ui-workflow-run/tsconfig.json create mode 100644 packages/client/ui-workflow-run/tsdown.config.ts create mode 100644 packages/workflow/tool-workflow/src/types.ts create mode 100644 packages/workflow/tool-workflow/tests/invariant.spec.ts create mode 100644 packages/workflow/workflow/src/runtime-types.ts diff --git a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml index d5ff80bd97..2760ff3361 100644 --- a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.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-05-dynamic-workflows.md -2026-07-05-dynamic-workflows.md: 3e491478286eb77b56872fcbbdd5ebb6b62a5545 -2026-07-05-dynamic-workflows.zh.md: 7888d83f981a96ac5eb31d5ca6f1f8d0b4930ec7 +2026-07-05-dynamic-workflows.md: 287b0031a5fecaaa815befa3c7b792c3179f1dae +2026-07-05-dynamic-workflows.zh.md: 8b63498fd7f82159bfc0cc3b5d29f3a151d84338 diff --git a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md index 3e49147828..287b0031a5 100644 --- a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md @@ -40,6 +40,8 @@ The engine exposes an in-process `MessageChannel` test path because main-process A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, await, `try/finally` dispose, abort-bridge `exec.signal`, non-`completed` → `isError`. Render intent: a `generic` card titled by the call's `meta.name` parameter (presentation is a pure function of args). The tool description IS the model-facing authoring spec. The usage policy ships with the tool as its own `tool:` prompt section (explicit-ask-only guidance — tool guidance lives in tool plugins, never in the deployment persona); the harness has no ultracode-style effort gate. +For a top-level tool execution, the same consumer also writes the run and actual member lifecycle into the calling parent Session as four log-only `tool-workflow/*` events. The recording path observes rather than controls execution: its first append failure disables later writes for that run and leaves a legal prefix without changing the tool result. [`ui-workflow-run`](../../../../packages/client/ui-workflow-run/README.md) rebuilds those facts through the Conversation Node engine as a separate keyed Chat row; the existing generic tool row remains its own presentation owner. The detailed persistence, replay, disclosure, and live-navigation decision lives in [durable workflow runs in Chat](2026-08-10-durable-workflow-runs-in-chat.md). + ### The foundation: structured output on the subagent seam `SubagentStartRequest.outputSchema` is implemented by `dsh-subagent-inprocess` for both in-process backends. Each structured child receives its own scoped capture tool, instruction, and enforcement registrations on `child.ctx`; concurrent children can use different schemas without sharing mutable policy, and disposing the child removes the entire attachment. @@ -60,7 +62,6 @@ Worker-side logic runs through an in-process `MessageChannel` so V8 coverage mea - **Nested `workflow()`**, **token `budget`**, and the `effort`/`isolation`/`agentType` agent options (each rejects loud with a message naming it deferred). - **An overall run wall-clock timeout** — cancellation always frees the caller (result settles within the grace), so a cap on total run time is a policy knob for the background redesign, not a correctness need here. - **Engine hardening beyond worker threads**: an isolated-vm or separate-process engine behind the same seam (actual sandboxing; memory limits). -- **Human-interface progress UI** over the `workflow/*` events (a `/workflows`-style view); the events exist for it. - **ACP-backend structured output** and **`toolFilter`** (both still capability-gated `false`). ## Alternatives considered @@ -77,4 +78,4 @@ Worker-side logic runs through an in-process `MessageChannel` so V8 coverage mea ## Consequences -Fan-out plans now live in rerunnable scripts, and `outputSchema` provides authoritative structured child results. Each run pays worker startup and message-port RPC costs, but host startup stays non-blocking, cancellation can terminate the worker, and serialization enforces the value boundary. Worker threads are not a security boundary. Invalid options fail rather than degrading to Claude Code's `null`; consumers retain control through the run handle while observers receive snapshots only. +Fan-out plans now live in rerunnable scripts, and `outputSchema` provides authoritative structured child results. Each run pays worker startup and message-port RPC costs, but host startup stays non-blocking, cancellation can terminate the worker, and serialization enforces the value boundary. Worker threads are not a security boundary. Invalid options fail rather than degrading to Claude Code's `null`; consumers retain control through the run handle while observers receive snapshots only. Top-level Web users also receive a durable, replayable workflow record without widening the execution seam or coupling the original tool card to workflow-specific UI. diff --git a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md index 7888d83f98..8b63498fd7 100644 --- a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md +++ b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md @@ -40,6 +40,8 @@ harness 可以将一个任务委派给一个子 agent(`dsh-tool-subagent`) 一个 `workflow` 工具,镜像 `dsh-tool-subagent` 的同步形态:启动、await、`try/finally` dispose、abort 桥接 `exec.signal`、非 `completed` → `isError`。渲染意图:一张以调用的 `meta.name` 参数为标题的 `generic` 卡片(展示是参数的纯函数)。工具描述即面向模型的编写规范。使用策略以工具自身的 `tool:` 提示词段落随工具发布(显式请求才使用的引导——工具引导存在于工具插件中,从不在部署 persona 中);harness 没有 ultracode 风格的 effort 门控。 +对于顶层工具执行,同一消费方还会把运行及真正开始过的成员生命周期写入调用方父 Session,形成四类 log-only `tool-workflow/*` 事件。记录路径只观察、不控制执行:第一次 append 失败会禁用本运行后续写入并留下合法前缀,不改变工具结果。[`ui-workflow-run`](../../../../packages/client/ui-workflow-run/README.md) 通过 Conversation Node 引擎重建这些事实,形成独立 keyed Chat 行;现有 generic 工具行继续拥有自己的展示。持久化、回放、折叠与实时导航的详细决策见 [Chat 中的持久工作流运行](2026-08-10-durable-workflow-runs-in-chat.md)。 + ### 基础:subagent seam 上的结构化输出 `SubagentStartRequest.outputSchema` 由 `dsh-subagent-inprocess` 为两个进程内后端实现。每个结构化子 agent 在 `child.ctx` 上获得自己的作用域捕获工具、指令和强制注册;并发子 agent 可以使用不同的 schema 而不共享可变策略,dispose 子 agent 时移除整个附件。 @@ -60,7 +62,6 @@ worker 侧逻辑通过进程内 `MessageChannel` 运行,使 V8 覆盖率能够 - **嵌套 `workflow()`**、**token `budget`**,以及 `effort`/`isolation`/`agentType` agent 选项(每个都会明确拒绝,并在消息中注明其已延迟实现)。 - **整体运行的挂钟超时**:取消总能释放调用方(result 在宽限期内 settle),因此总运行时间上限是后台重设计的策略旋钮,不是此处的正确性需求。 - **超越 worker 线程的引擎加固**:在同一 seam 背后使用 isolated-vm 或独立进程引擎(真正的沙箱化;内存限制)。 -- **面向人类界面的进度 UI**(基于 `workflow/*` 事件的 `/workflows` 风格视图);事件已为此而存在。 - **ACP(Agent Client Protocol)后端结构化输出**和 **`toolFilter`**(两者仍以能力标志 `false` 门控)。 ## 曾考虑的替代方案 @@ -77,4 +78,4 @@ worker 侧逻辑通过进程内 `MessageChannel` 运行,使 V8 覆盖率能够 ## 后果 -扇出计划现在存在于可重运行的脚本中,`outputSchema` 提供权威的结构化子 agent 结果。每次运行付出 worker 启动和消息端口 RPC 成本,但宿主启动保持非阻塞,取消可以终止 worker,序列化强制执行值边界。worker 线程不是安全边界。无效选项会失败而非退化为 Claude Code 的 `null`;消费方通过 run handle 保持控制权,观察者仅接收快照。 +扇出计划现在存在于可重运行的脚本中,`outputSchema` 提供权威的结构化子 agent 结果。每次运行付出 worker 启动和消息端口 RPC 成本,但宿主启动保持非阻塞,取消可以终止 worker,序列化强制执行值边界。worker 线程不是安全边界。无效选项会失败而非退化为 Claude Code 的 `null`;消费方通过 run handle 保持控制权,观察者仅接收快照。顶层 Web 用户还会得到持久、可回放的工作流记录,同时不扩宽执行 seam,也不把原工具卡耦合到工作流专属 UI。 diff --git a/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml new file mode 100644 index 0000000000..4b44acecd9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.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-10-durable-workflow-runs-in-chat.md +2026-08-10-durable-workflow-runs-in-chat.md: 791a81e9e304a11f45557197ac1f97184132ccab +2026-08-10-durable-workflow-runs-in-chat.zh.md: e6c87f61a144cebc0282055c8ae315d9068616fd diff --git a/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md new file mode 100644 index 0000000000..791a81e9e3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md @@ -0,0 +1,45 @@ +# Agent Note: Durable workflow runs in Chat + +Status: implemented + +English | [中文](2026-08-10-durable-workflow-runs-in-chat.zh.md) + +## Problem + +The ordinary workflow tool row owns the model call and final tool result, but those two records do not explain which members actually started, how they were grouped, whether each member completed, failed, or was cancelled, or what remained unfinished when a process stopped. Live `workflow/*` events expose those facts only inside the current process, so a refresh or later Session open loses the run history. + +The Web Client already assembles business-owned Conversation Nodes from durable Session events. Workflow history therefore needs a producer that can correlate one accepted run with its calling Session, a minimal durable protocol that remains meaningful as a prefix, and an independent renderer that does not take ownership away from the existing tool card. + +## Decision + +`dsh-tool-workflow` projects every top-level accepted run into the calling Agent's Session. `tool-workflow/run-start` records the stable `runId` and validated name; matching workflow member events record the member sequence, exact label, optional exact phase, child Session id, and outcome; `tool-workflow/run-end` records the stop reason only after the result exists and `run.dispose()` has reached quiescence. Nested transport executions run normally but write no workflow record because they do not own an independent Chat row. + +Recording is observational. The first failed Session append disables all later writes for that run, logs one warning, and never changes cancellation, result mapping, or disposal. Each possible failure leaves either no record or a legal continuous prefix: a started run may lack later members or its ending, and a started member may lack its ending. The package invariant rejects duplicate run starts, invalid or reused positive member sequences, unpaired or repeated member endings, a run ending while members remain open, and every update after a run ending on both cold load and live append. + +The workflow package exposes browser-safe run and observation vocabulary through `@deepseek-ai/dsh-workflow/types`; live `Agent` requests and control handles remain Host-only. `@deepseek-ai/dsh-tool-workflow/types` owns the four Session events. Client code imports only these type faces, so the Host and Client TypeScript programs share the durable contract without merging Host Cordis context. + +`ui-workflow-run` registers one `workflow-run` Conversation Definition and one keyed Chat renderer. Every event independently yields the same `runId`; run-start initializes State, later events update it in log order, and an update-only history tail remains pending until prepend supplies the unique start. The final node keeps the engine-owned key and anchors at run-start, placing it after the original tool call while preserving one React parent from running through terminal state. + +The renderer gives each level a distinct visual responsibility. The run uses a 32-pixel module-platform background row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column. Phases exist only when a member actually starts and group by the exact phase string; an omitted phase and the empty string retain distinct identities and localized names. Member settlement changes status without removing or reordering the member. A closed Turn or Step turns missing run or member endings into interrupted presentation; a durable ending remains authoritative when present. + +Navigation is derived from two current authorities rather than persisted. A member row is interactive only while its durable member state is running and the current ordinary Session list contains the same id with `origin: 'subagent'`, `parentId` equal to the displayed parent, and `running: true`. Underlined member text is the only visible affordance; keyboard focus draws a two-pixel business-primary ring around the name area, and the fixed status label remains the lifecycle word rather than an action instruction. The renderer invokes only the injected ordinary `sessions.open(id)` callback. Addressed-only, remote, wrong-parent, and terminal members remain visible but static. + +The [seven-state Figma reference](https://www.figma.com/design/tguwzZRmHCjbq58mfsqT0M?node-id=5-2) fixes the information hierarchy for running expanded/collapsed, completed history/expanded, failed plus cancelled, interrupted recovery, and dark narrow presentation. Repository `DisclosureRow`, `StateDot`, icons, semantic tokens, and keyed-node behavior remain the implementation authority; the reference introduces no runtime field or state owner. + +## Verification + +Package tests cover top-level and nested eligibility, zero-member and concurrent runs, disposal-before-ending order, all four append-failure prefixes, and cold/live invariant rejection. Conversation tests compare complete replace, update-only prepend, and live append; they cover exact phase identity, terminal and interrupted status, disclosure state, list-fact navigation, and HMR removal and re-registration. The shipped Web replay uses the existing workflow parent and child model fixtures to exercise the real worker, spawn provider, Session persistence, browser bundle, running child navigation, terminal retention, original tool-row coexistence, narrow dark tokens, and refresh reconstruction. + +## Alternatives considered + +**Append workflow content inside the existing tool card.** Rejected because `ui-tool` and the tool definition own that row's presentation and interaction. A workflow-specific appendix would couple two independently keyed business lifecycles and revive the removed post-tool attachment model. + +**Persist a server-side projection or add a workflow wire channel.** Rejected because Session events already provide persistence, live delivery, pagination, and gap repair. Another service, cache, or transport would duplicate the same facts and create a second lifecycle owner. + +**Render declared phases or infer a static workflow graph from script text.** Rejected because only member-start events prove work happened. `meta.phases`, `phase()` narration, branches, and script syntax do not describe one authoritative runtime topology. + +**Keep terminal child navigation.** Rejected because the workflow record proves historical identity, not current accessibility. Cold or remote Session opening needs a separate catalog and authorization contract; this node grants no such promise. + +## Consequences + +Workflow progress survives refresh and process recovery in the same log as its parent conversation, while execution ownership remains with the workflow run holder and the original tool card remains unchanged. The durable protocol adds four small events and one package-owned invariant; first-write failure intentionally sacrifices later observation rather than workflow correctness. Browser State is derived per loaded window, disclosure choices remain local, and navigation can disappear as list facts change. The design shows only actual runtime members and statuses, giving up static graph visualization, outputs, logs, controls, and terminal-member opening. diff --git a/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md new file mode 100644 index 0000000000..e6c87f61a1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md @@ -0,0 +1,45 @@ +# Agent Note: Chat 中的持久工作流运行 + +Status: implemented + +[English](2026-08-10-durable-workflow-runs-in-chat.md) | 中文 + +## 问题 + +普通工作流工具行拥有模型调用与最终工具结果,但这两条记录无法说明哪些成员真正开始、如何分组、各成员是完成、失败还是取消,也无法说明进程停止时哪些工作尚未结束。实时 `workflow/*` 事件只存在于当前进程,因此刷新或稍后重新打开 Session 会丢失运行历史。 + +Web Client 已经能够从持久 Session 事件组装由业务拥有的 Conversation Node。工作流历史因此需要:能够把一次已接受运行关联到调用 Session 的生产方、作为前缀也始终有意义的最小持久协议,以及不夺走现有工具卡所有权的独立 renderer。 + +## 决策 + +`dsh-tool-workflow` 把每个已接受的顶层运行投影到调用 Agent 的 Session。`tool-workflow/run-start` 记录稳定 `runId` 与已校验名称;匹配的工作流成员事件记录成员序号、精确标签、可选精确阶段、子 Session id 与结果;只有在结果已取得且 `run.dispose()` 完全停稳后,`tool-workflow/run-end` 才记录停止原因。嵌套 transport 执行照常运行,但不会写工作流记录,因为它不拥有独立 Chat 行。 + +记录只供观察。任一次 Session append 首次失败后,本运行会停止所有后续写入、只记录一次告警,并且绝不改变取消、结果映射或 dispose。每种失败位置都留下空记录或合法连续前缀:已开始运行可以缺少后续成员或运行终点,已开始成员也可以缺少成员终点。包 invariant 会在冷加载与实时 append 时拒绝重复运行 start、无效或复用的正成员序号、无配对或重复成员 end、仍有开放成员时结束运行,以及运行结束后的任何更新。 + +workflow 包通过 `@deepseek-ai/dsh-workflow/types` 提供浏览器安全的运行与观察词汇;包含活跃 `Agent` 的请求和控制句柄继续只属于 Host。`@deepseek-ai/dsh-tool-workflow/types` 拥有四类 Session 事件。Client 只导入这些类型 face,因此 Host 与 Client TypeScript 程序共享持久合同,而不会合并 Host Cordis Context。 + +`ui-workflow-run` 注册一个 `workflow-run` Conversation Definition 和一个 keyed Chat renderer。每条事件都能独立给出同一 `runId`;run-start 初始化 State,后续事件按日志顺序更新;只有 update 的历史尾页会保持 pending,直到 prepend 补入唯一 start。最终节点保留引擎拥有的 key,并以 run-start 锚定在原工具调用之后,从运行中到终态始终保留同一个 React 父级。 + +renderer 为每一层分配不同视觉职责。运行使用 32 像素 module-platform 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。阶段只在成员真正开始时出现,并按精确阶段字符串分组;字段缺省与空字符串保留不同身份和本地化名称。成员结算只改变状态,不删除或重排成员。所属 Turn 或 Step 关闭时,缺少运行或成员终点会显示为已中断;存在持久终点时仍以它为权威。 + +导航从两个当前权威派生,不写入持久记录。只有持久成员状态仍为运行中,且当前普通 Session 列表包含同一 id、`origin: 'subagent'`、`parentId` 等于当前父 Session、`running: true` 时,成员行才可交互。带下划线的成员文字是唯一可见提示;键盘聚焦时,名称区显示 2 像素 business-primary 焦点环,固定状态列继续只表达生命周期,而不写动作说明。renderer 只调用注入的普通 `sessions.open(id)` 回调。仅地址化、远程、父级不符或终态成员继续可见,但保持静态。 + +[七状态 Figma 参考](https://www.figma.com/design/tguwzZRmHCjbq58mfsqT0M?node-id=5-2)固定运行展开/收起、完成历史/展开、失败与取消、恢复后中断以及暗色窄列的信息层级。仓库的 `DisclosureRow`、`StateDot`、图标、语义 token 和 keyed-node 行为仍是实现权威;参考稿不引入运行时字段或状态 owner。 + +## 验证 + +包测试覆盖顶层与嵌套准入、零成员与并发运行、先 dispose 后写终点的顺序、四个 append 失败前缀,以及冷/实时 invariant 拒绝。Conversation 测试比较完整 replace、只有 update 的 prepend 和实时 append,并覆盖精确阶段身份、终态与中断状态、disclosure 状态、列表事实导航、HMR 移除与重新注册。shipped Web replay 复用现有工作流父/子模型 fixture,驱动真实 worker、spawn provider、Session 持久化、浏览器 bundle、运行中子级导航、终态保留、原工具行并存、暗色窄列 token 与刷新重建。 + +## 曾考虑的替代方案 + +**把工作流内容附加到现有工具卡。** 拒绝,因为 `ui-tool` 与工具定义拥有该行的展示和交互。工作流专属 appendix 会耦合两个独立 keyed 业务生命周期,并恢复已移除的工具后附加模型。 + +**持久化服务端 projection 或新增 workflow wire 通道。** 拒绝,因为 Session 事件已经提供持久化、实时传输、分页和 gap repair。另一个 service、cache 或 transport 会复制同一事实并建立第二个生命周期 owner。 + +**展示声明阶段,或从脚本文本推断静态工作流图。** 拒绝,因为只有成员 start 事件能证明工作真正发生。`meta.phases`、`phase()` 叙述、分支和脚本语法都不是一次运行的权威拓扑。 + +**保留终态子级导航。** 拒绝,因为工作流记录证明历史身份,不证明当前可访问性。冷 Session 或远程 Session 的打开需要独立目录与授权合同;本节点不作这种承诺。 + +## 后果 + +工作流进度与父对话保存在同一日志中,能跨刷新与进程恢复;执行所有权仍属于工作流 run holder,原工具卡保持不变。持久协议增加四类小事件和一个包所有的 invariant;首次写入失败会刻意牺牲后续观察,而不是牺牲工作流正确性。浏览器 State 按已加载窗口派生,disclosure 选择保持本地,导航会随列表事实消失。设计只展示真实运行成员与状态,并放弃静态图、输出、日志、控制操作和终态成员打开。 diff --git a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml index 9ade4e5770..cc9f18fbef 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.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/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md -2026-07-12-collapse-workflow-to-foreground-core.md: 5fc46584f83eb5307ff16f3353b56951b928aef3 -2026-07-12-collapse-workflow-to-foreground-core.zh.md: 0b4c73e5df973215b10166f3dc2bbd525cc8231b +2026-07-12-collapse-workflow-to-foreground-core.md: 9151d9fb72a97aadf040fbdc13b5e0a4943f2f30 +2026-07-12-collapse-workflow-to-foreground-core.zh.md: c9eafe83e931de7aec4ec39e2471f0669c73609d diff --git a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md index 5fc46584f8..9151d9fb72 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md +++ b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md @@ -6,15 +6,11 @@ English | [中文](2026-07-12-collapse-workflow-to-foreground-core.zh.md) ## Problem -The workflow capability executes foreground JavaScript that composes subagents, but it also carries an unconsumed progress-observation system. No production listener subscribes to any of the six `workflow/*` events; listeners exist only in workflow tests. Nevertheless the seam defines run/phase/agent outcome payloads, the worker sends phase/log/agent lifecycle protocol messages, the host forwards them through a `liveAgents` pairing ledger, and the engine maintains run ids solely to correlate those notifications. +The workflow capability carries an observe-only lifecycle beside its execution handle. That surface can look removable because the script still completes without a UI listener, but it is the only provider-neutral source of the actual members that started, their exact labels and phases, and their paired outcomes. -The progress vocabulary is not merely unused; it cannot serve its only named future owner without redesign. `WorkflowRunInfo` contains `{id, meta}` but no parent agent, session, or tool-call identity, while the model-facing tool never exposes the run id. A global ACP listener could not route an event to the correct client session. `meta.phases` is never consulted, `phase(title)` does not validate against it, phase `detail`/`model` and agent `label`/`phase` feed only events, and `whenToUse` is validated and copied but never rendered or selected. `phase()` and `log()` still cross the worker boundary despite having no receiver. +The top-level `dsh-tool-workflow` consumer now uses those events to write four minimal `tool-workflow/*` facts into the calling parent Session, and `ui-workflow-run` rebuilds them into a durable Chat node. The consumer deliberately owns the projection because it alone holds the calling Agent, knows whether the tool execution is top-level, and can keep recording failure separate from workflow execution. `WorkflowRun.id` and `meta` therefore correlate live engine events with that exact durable record rather than duplicating presentation state. -The live handle repeats event-era data after those observers disappear. `WorkflowRun.id` has no non-event consumer, while the tool reads `run.meta.name` only to render a value it already owns as `args.meta.name`; neither belongs on the execution/cancellation handle. - -Cancellation also has two public channels for one synchronous start. `WorkflowStartRequest.signal` is passed to the worker host, while the sole production caller separately bridges the same signal to `WorkflowRun.cancel()`. Because `start()` returns the run before control can yield, there is no readiness window that requires request-time cancellation; the duplicate signal adds host listener/disarm state without closing a race. - -`WorkflowError.fatal` is the same speculative branch in miniature: every production construction is fatal, `fatal: false` exists only in tests, and combinators already distinguish workflow failures with `instanceof`. +Deleting the event vocabulary, member labels or phases, or run identity would remove the current replay and navigation result rather than merely simplify unused scaffolding. The rejected proposal below remains useful as the contraction to avoid; [durable workflow runs in Chat](../../implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md) owns the present consumer and boundaries. ## Proposal @@ -24,7 +20,7 @@ Amend the implemented dynamic-workflow Agent Note and update the seam/tool/worke ## Alternatives considered -**Keep the prebuilt observation vocabulary for a future UI.** The current shape resembles Claude Code dynamic-workflow metadata, and the host deliberately pairs each forwarded agent start with either the worker's end or a synthesized terminal end. Removing it gives up compatibility-by-shape and makes progress UI a new design task, but the existing payloads still lack routable ownership, so balanced lifecycles alone cannot make the named ACP owner viable without redesign. +**Move durable recording into the workflow engine.** The engine knows run and member lifecycle but does not own the calling parent Session or the top-level-versus-nested tool boundary. Giving it those facts would couple a provider seam to one consumer and make recording failure part of engine execution. The tool-owned projection adds the missing ownership without widening worker messages or the service contract. ## Acceptance criteria diff --git a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md index 0b4c73e5df..c9eafe83e9 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md @@ -6,15 +6,11 @@ Status: rejected — 工作流进度是有意设计的观测接口面;应通 ## 问题 -工作流能力在前台执行用于编排 subagent 的 JavaScript,但它同时携带了一套无人消费的进度观测系统。没有任何生产环境的监听器订阅六个 `workflow/*` 事件中的任何一个;监听器仅存在于工作流测试中。尽管如此,seam 定义了 run/phase/agent(智能体)outcome 载荷,worker 发送 phase/log/agent 生命周期协议消息,host 通过一个 `liveAgents` 配对账本转发它们,引擎维护 run id 仅仅是为了关联这些通知。 +工作流能力在执行句柄之外还携带一套只供观察的生命周期。脚本即使没有 UI 监听器也能完成,因此这套界面看似可删除;但它是唯一与提供方无关、能够报告真正开始过的成员、精确标签与阶段以及配对结果的事实来源。 -这套进度词汇不仅仅是未被使用;它在不经重新设计的情况下也无法服务于其唯一已命名的未来消费方。`WorkflowRunInfo` 包含 `{id, meta}` 但没有父 agent、会话或工具调用标识,而面向模型的工具也从不暴露 run id。一个全局 ACP(Agent Client Protocol)监听器无法将事件路由到正确的客户端会话。`meta.phases` 从未被查询,`phase(title)` 不对其做校验,phase 的 `detail`/`model` 和 agent 的 `label`/`phase` 仅供事件消费,`whenToUse` 被校验和复制但从未被渲染或用于选择。`phase()` 和 `log()` 仍然跨越 worker 边界,尽管没有接收方。 +顶层 `dsh-tool-workflow` 消费方现在利用这些事件,把四类最小 `tool-workflow/*` 事实写入调用方父 Session;`ui-workflow-run` 再把它们重建为持久 Chat 节点。投影由消费方拥有,因为只有它同时持有调用 Agent、知道工具执行是顶层还是嵌套,并能让记录故障与工作流执行隔离。`WorkflowRun.id` 与 `meta` 因此用于把实时引擎事件关联到该条精确持久记录,而不是复制展示状态。 -这些观测者移除后,live handle 仍重复携带事件机制所需的数据。`WorkflowRun.id` 没有非事件消费方,而工具读取 `run.meta.name` 只是为了渲染一个它已经以 `args.meta.name` 形式持有的值;两者都不属于执行/取消 handle。 - -取消机制也为一个同步启动提供了两条公开通道。`WorkflowStartRequest.signal` 被传递给 worker host,而唯一的生产调用方另外将同一个 signal 桥接到 `WorkflowRun.cancel()`。因为 `start()` 在控制权让出之前就返回了 run,不存在需要请求时取消的就绪窗口;重复的 signal 增加了 host 的 listener/disarm 状态却没有封堵任何竞态。 - -`WorkflowError.fatal` 是同一种推测性分支的微缩版:所有生产环境的构造都是 fatal 的,`fatal: false` 仅存在于测试中,组合子已经通过 `instanceof` 区分工作流失败。 +删除事件词汇、成员标签或阶段、运行身份,会移除当前回放和导航结果,而不再只是清理未使用脚手架。下方提案继续记录应避免的收缩;[Chat 中的持久工作流运行](../../implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md)拥有当前消费方与边界。 ## 提案 @@ -24,7 +20,7 @@ Status: rejected — 工作流进度是有意设计的观测接口面;应通 ## 曾考虑的替代方案 -**为未来 UI 保留预建的观测词汇。** 当前形态类似 Claude Code 的动态工作流元数据,host 有意地将每个转发的 agent start 与 worker 的 end 或一个合成的终止 end 配对。移除它意味着放弃形态兼容性,使进度 UI 成为一项全新的设计任务;但现有载荷仍缺少可路由的归属信息,因此仅靠平衡的生命周期也无法在不重新设计的情况下让已命名的 ACP 消费方可行。 +**把持久记录移入工作流引擎。** 引擎知道运行与成员生命周期,却不拥有调用方父 Session,也不知道顶层与嵌套工具边界。把这些事实交给引擎会让提供方 seam 耦合到单一消费方,并使记录故障进入引擎执行域。由工具拥有的投影补齐了缺失所有权,同时不扩展 worker 消息或 service 合同。 ## 验收标准 diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 631196c652..5883d62d72 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -27,6 +27,7 @@ const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [ { id: '@deepseek-ai/dsh-client-ui-sidebar', bundlePath: 'packages/client/ui-sidebar/lib/client.js', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-conversation', bundlePath: 'packages/client/ui-conversation/lib/client.js', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-tool', bundlePath: 'packages/client/ui-tool/lib/client.js', url: '/plugins/ui-tool.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-ui-conversation'] }, + { id: '@deepseek-ai/dsh-client-ui-workflow-run', bundlePath: 'packages/client/ui-workflow-run/lib/client.js', url: '/plugins/ui-workflow-run.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation'] }, { id: '@deepseek-ai/dsh-client-ui-workspace', bundlePath: 'packages/client/ui-workspace/lib/client.js', diff --git a/apps/web/tests/snapshots/workflow-run/ui.expected.md b/apps/web/tests/snapshots/workflow-run/ui.expected.md new file mode 100644 index 0000000000..7a2e1cfd13 --- /dev/null +++ b/apps/web/tests/snapshots/workflow-run/ui.expected.md @@ -0,0 +1,55 @@ +- banner: + - navigation "Session hierarchy": + - button "Use the workflow tool exactly" [disabled] + - button "1 subagent": + - text: 1 subagent + - img + - img + - text: 标准模式 + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: "Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): phase('Run') const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') return { reply } After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool. {{clock}}" +- button "Copy": + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Think The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:": + - img + - img + - text: "Think The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:" +- button "Tool call workflow ·": + - img + - img + - text: Tool call workflow · +- button "snapshot-flow 1 members Completed" [expanded]: + - img + - text: snapshot-flow 1 members Completed +- button "Run 1 members Completed 1" [expanded]: + - img + - text: Run 1 members Completed 1 +- text: Reply with exactly the word WF_CHILD_OK and not… Completed +- button "Think The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop.": + - img + - img + - text: Think The workflow returned successfully with the reply "WF_CHILD_OK". Now I need to reply with exactly "WORKFLOW_DONE" and stop. +- paragraph: WORKFLOW_DONE +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- button "Back to bottom": + - img +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "3% of context used" +- button "Send message" [disabled] +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 47% Input 6.6K tok · Output 227 tok diff --git a/apps/web/tests/workflow-run.e2e.ts b/apps/web/tests/workflow-run.e2e.ts new file mode 100644 index 0000000000..cacefa75a4 --- /dev/null +++ b/apps/web/tests/workflow-run.e2e.ts @@ -0,0 +1,170 @@ +// Keyless shipped-Web acceptance for the durable workflow Conversation Node. +// Reuses the existing recorded workflow parent/child model fixtures; the real +// workflow tool, worker, subagent provider, Session log, browser plugin graph, +// and navigation all execute during replay. +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + fixtureUserPrompts, launchWebScaffold, watchConsole, webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { + connectFreshWorkspace, newEnglishPage, REPO_ROOT, saveFailureShot, +} from './support.ts' + +const MODE = webSnapshotMode() +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/workflow-run', import.meta.url)) +const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const PARENT_FIXTURE = join(REPO_ROOT, 'examples/acp-agent/tests/snapshots/workflow-run/session.jsonl') +const CHILD_FIXTURE = join(REPO_ROOT, 'examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl') +const CHILD_PROMPT = 'Reply with exactly the word WF_CHILD_OK and nothing else.' + +describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let prompt: string + + const waitForParentSettlement = (): Promise => new Promise((resolve, reject) => { + let dispose = (): void => {} + dispose = scaffold.ctx.on('session/event', (session: Session, event: SessionEvent) => { + if (event.type !== 'turn/end' || session.header.origin === 'subagent') return + dispose() + void (async () => { + await scaffold.ctx.agents.get(session.id)?.whenIdle() + await scaffold.ctx.sessions.flush(session) + resolve(session.id) + })().catch(reject) + }) + }) + + beforeAll(async () => { + const prompts = fixtureUserPrompts(await readFile(PARENT_FIXTURE, 'utf8')) + expect(prompts).toHaveLength(1) + prompt = prompts[0]! + scaffold = await launchWebScaffold({ + replayFixture: PARENT_FIXTURE, + replayChildFixtures: [CHILD_FIXTURE], + paceMs: 25, + }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('shows the live member, opens its local child, then retains the settled record beside the tool row', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-workflow-run-live')) + const settled = waitForParentSettlement() + const input = page.locator('textarea').first() + await input.fill(prompt) + await input.press('Enter') + + const workflow = page.getByRole('button', { name: /^snapshot-flow/ }) + await workflow.waitFor({ timeout: 30_000 }) + expect(await workflow.getAttribute('aria-expanded')).toBe('true') + const phase = page.getByRole('button', { name: /^Run/ }) + await phase.waitFor({ timeout: 15_000 }) + await phase.click() + const member = page.getByRole('button', { name: /^Open Reply with exactly the word/ }) + await member.waitFor({ timeout: 15_000 }) + await member.focus() + + const lightColor = await member.locator('[data-member-label]').evaluate(element => getComputedStyle(element).color) + await page.setViewportSize({ width: 560, height: 800 }) + await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') }) + const darkNarrow = await page.locator('[data-workflow-run]').evaluate((element) => { + const panel = element as HTMLElement + panel.style.width = '356px' + const label = element.querySelector('[data-member-label]') + const labelWrap = element.querySelector('[data-member-label-wrap]') + const status = element.querySelector('[data-member-status-text]') + const runHeader = element.querySelector('[data-run-header]') + const phaseHeader = element.querySelector('[data-phase-header]') + return { + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + color: label === null ? '' : getComputedStyle(label).color, + decoration: label === null ? '' : getComputedStyle(label).textDecorationLine, + focusWidth: labelWrap === null ? '' : getComputedStyle(labelWrap).outlineWidth, + statusWidth: status?.getBoundingClientRect().width ?? 0, + statusFontSize: status === null ? '' : getComputedStyle(status).fontSize, + runHeight: runHeader?.getBoundingClientRect().height ?? 0, + phaseHeight: phaseHeader?.getBoundingClientRect().height ?? 0, + } + }) + expect(darkNarrow.clientWidth).toBe(356) + expect(darkNarrow.scrollWidth).toBeLessThanOrEqual(darkNarrow.clientWidth) + expect(darkNarrow.color).not.toBe(lightColor) + expect(darkNarrow.decoration).toContain('underline') + expect(Number.parseFloat(darkNarrow.focusWidth)).toBeGreaterThanOrEqual(2) + expect(darkNarrow.statusWidth).toBe(64) + expect(darkNarrow.statusFontSize).toBe('13px') + expect(darkNarrow.runHeight).toBe(32) + expect(darkNarrow.phaseHeight).toBe(32) + await page.locator('[data-workflow-run]').evaluate((element) => { + (element as HTMLElement).style.removeProperty('width') + document.body.removeAttribute('data-ds-dark-theme') + }) + await page.setViewportSize({ width: 1280, height: 800 }) + + await member.click() + await page.getByText(CHILD_PROMPT, { exact: true }).waitFor({ timeout: 15_000 }) + + const sessions = page.getByRole('tree', { name: 'Sessions' }) + await sessions.getByRole('treeitem', { name: /Use the workflow tool exactly/ }).click() + await settled + + expect(await page.locator('[data-chat-flow-kind="tool-call"]').count()).toBeGreaterThanOrEqual(1) + expect(await page.locator('[data-chat-flow-kind="workflow-run"]').count()).toBe(1) + const terminalWorkflow = page.getByRole('button', { name: /^snapshot-flow/ }) + await terminalWorkflow.waitFor() + if (await terminalWorkflow.getAttribute('aria-expanded') !== 'true') await terminalWorkflow.click() + const terminalPhase = page.getByRole('button', { name: /^Run/ }) + await terminalPhase.waitFor() + if (await terminalPhase.getAttribute('aria-expanded') !== 'true') await terminalPhase.click() + await page.getByText(CHILD_PROMPT, { exact: false }).waitFor() + await expect.poll( + () => page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count(), + { timeout: 10_000 }, + ).toBe(0) + }, 90_000) + + it('rebuilds the terminal record from history after reload', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-workflow-run-history')) + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + const workflow = page.getByRole('button', { name: /^snapshot-flow/ }) + await workflow.waitFor({ timeout: 15_000 }) + expect(await workflow.getAttribute('aria-expanded')).toBe('false') + await workflow.click() + const phase = page.getByRole('button', { name: /^Run/ }) + await phase.waitFor() + await phase.click() + await page.getByText(CHILD_PROMPT, { exact: false }).waitFor() + expect(await page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count()).toBe(0) + + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + }, 60_000) + + it('stays clean and owns only its one golden', async () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index da25f0cc59..9fe5a0575d 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -82,7 +82,8 @@ "tests/chat-continuous-conversation.e2e.ts", "tests/composer-tab-geometry.e2e.ts", "tests/complex-history.perf.ts", - "tests/pwsh-terminal.e2e.ts" + "tests/pwsh-terminal.e2e.ts", + "tests/workflow-run.e2e.ts" ], "references": [ { diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 09c961ef69..9a0d47093e 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: 51c6ae46eeca1279390c9d9315a6161edd2de618 -config-catalog.zh.md: dc93f5b4b55b07c52c58405ba4793c2c6eca28df +config-catalog.md: d9b70eb15865b45d0d8251789d6d661cd9747024 +config-catalog.zh.md: 4974a7e53c60507c2dced9a93cb5e2a2ba0ed850 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 51c6ae46ee..d9b70eb158 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2412,7 +2412,7 @@ export interface Config { } ``` -Source: [`packages/workflow/tool-workflow/src/index.ts:27`](../packages/workflow/tool-workflow/src/index.ts) +Source: [`packages/workflow/tool-workflow/src/index.ts:34`](../packages/workflow/tool-workflow/src/index.ts) ## `@deepseek-ai/dsh-tools` @@ -2725,6 +2725,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) - `@deepseek-ai/dsh-client-ui-tool` ([`packages/client/ui-tool/src/index.ts`](../packages/client/ui-tool/src/index.ts)) - `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-workflow-run` ([`packages/client/ui-workflow-run/src/index.ts`](../packages/client/ui-workflow-run/src/index.ts)) - `@deepseek-ai/dsh-client-ui-workspace` ([`packages/client/ui-workspace/src/index.ts`](../packages/client/ui-workspace/src/index.ts)) - `@deepseek-ai/dsh-command-compact` — requires `commands` · `compact` ([`packages/compact/command-compact/src/index.ts`](../packages/compact/command-compact/src/index.ts)) - `@deepseek-ai/dsh-command-feedback` — requires `commands` ([`packages/feedback/command-feedback/src/index.ts`](../packages/feedback/command-feedback/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index dc93f5b4b5..4974a7e53c 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2413,7 +2413,7 @@ export interface Config { } ``` -来源:[`packages/workflow/tool-workflow/src/index.ts:27`](../packages/workflow/tool-workflow/src/index.ts) +来源:[`packages/workflow/tool-workflow/src/index.ts:34`](../packages/workflow/tool-workflow/src/index.ts) ## `@deepseek-ai/dsh-tools` @@ -2726,6 +2726,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-theme`([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) - `@deepseek-ai/dsh-client-ui-tool`([`packages/client/ui-tool/src/index.ts`](../packages/client/ui-tool/src/index.ts)) - `@deepseek-ai/dsh-client-ui-trajectory`([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-workflow-run`([`packages/client/ui-workflow-run/src/index.ts`](../packages/client/ui-workflow-run/src/index.ts)) - `@deepseek-ai/dsh-client-ui-workspace`([`packages/client/ui-workspace/src/index.ts`](../packages/client/ui-workspace/src/index.ts)) - `@deepseek-ai/dsh-command-compact` — 需要 `commands` · `compact`([`packages/compact/command-compact/src/index.ts`](../packages/compact/command-compact/src/index.ts)) - `@deepseek-ai/dsh-command-feedback` — 需要 `commands`([`packages/feedback/command-feedback/src/index.ts`](../packages/feedback/command-feedback/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index a2caf7b784..16178a7986 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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/event-producer-consumer.md -event-producer-consumer.md: b78171ce51931f02a3f39ef98104ea9dedc27360 -event-producer-consumer.zh.md: c044385bf91559f5c4f82d99601642b932066e7f +event-producer-consumer.md: 0963d996b50a363d434ede40a877cb89d8ba9923 +event-producer-consumer.zh.md: d258f775e8724d18f64ea4ed77c39ae74a73f45c diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b78171ce51..0963d996b5 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -30,9 +30,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:64`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:105`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | @@ -50,12 +50,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:161`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | -| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | -| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | -| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | -| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:60`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:53`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:45`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | +| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | +| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | +| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | +| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | ## Non-harness or undeclared event strings seen in package source @@ -64,7 +64,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `commands/changed` | `runtime` (`emit`) | `ui-command` | | `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` | | `credentials/changed` | `runtime` (`emit`) | `ui-models` | -| `internal/dispatch` | - | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) | +| `internal/dispatch` | - | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `loader`, [`lsp-local`](../packages/lsp/lsp-local), `modules`, `webserver` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index c044385bf9..d258f775e8 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -32,9 +32,9 @@ | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:64`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:105`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | @@ -51,13 +51,13 @@ | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:149`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:161`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | -| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | -| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | -| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | -| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:60`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:53`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:45`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:182`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | +| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | +| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | +| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | ## Non-harness or undeclared event strings seen in package source @@ -66,7 +66,7 @@ | `commands/changed` | `runtime` (`emit`) | `ui-command` | | `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` | | `credentials/changed` | `runtime` (`emit`) | `ui-models` | -| `internal/dispatch` | - | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) | +| `internal/dispatch` | - | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `loader`, [`lsp-local`](../packages/lsp/lsp-local), `modules`, `webserver` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets)、`gateway` | | `internal/status` | - | [`agent`](../packages/core/agent) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index d478f79cd1..bb2181029d 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: 6763ca6e84a5cc2e5e776a56e5cfc20b710017aa -module-graph.zh.md: 339345b39a8225c34ecf0ba73efff34d4b940046 +module-graph.md: bf1b3cb36de9071ca1d4d6d0b8795a28435916c6 +module-graph.zh.md: 3deb62f24c0c319922f80d33f5a7d726bf0a2d9f diff --git a/docs/module-graph.md b/docs/module-graph.md index 6763ca6e84..bf1b3cb36d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -169,6 +169,7 @@ flowchart TD pkg_client_ui_theme["client-ui-theme"] pkg_client_ui_tool["client-ui-tool"] pkg_client_ui_trajectory["client-ui-trajectory"] + pkg_client_ui_workflow_run["client-ui-workflow-run"] pkg_client_ui_workspace["client-ui-workspace"] pkg_client_web["client-web"] pkg_client_web_react["client-web-react"] @@ -969,6 +970,7 @@ flowchart TD pkg_tool_workflow --> pkg_agent pkg_tool_workflow --> pkg_invariants pkg_tool_workflow --> pkg_llm + pkg_tool_workflow --> pkg_session pkg_tool_workflow --> pkg_system_prompt pkg_tool_workflow --> pkg_tools pkg_tool_workflow --> pkg_workflow @@ -1160,6 +1162,15 @@ flowchart TD pkg_client_ui_tool --> pkg_client_ui_primitives pkg_client_ui_tool --> pkg_client_ui_slots pkg_client_ui_tool --> pkg_invariants + pkg_client_ui_workflow_run --> pkg_client_locale + pkg_client_ui_workflow_run --> pkg_client_runtime + pkg_client_ui_workflow_run --> pkg_client_ui_conversation + pkg_client_ui_workflow_run --> pkg_client_ui_primitives + pkg_client_ui_workflow_run --> pkg_client_ui_slots + pkg_client_ui_workflow_run --> pkg_invariants + pkg_client_ui_workflow_run --> pkg_session + pkg_client_ui_workflow_run --> pkg_tool_workflow + pkg_client_ui_workflow_run --> pkg_workflow pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_bash_env @@ -1412,7 +1423,7 @@ flowchart TD | [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | -| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | @@ -1440,6 +1451,7 @@ flowchart TD | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`jsonrpc`](../packages/scaffold/server) | `scaffold` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`sdk-client`](../packages/scaffold/client) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 339345b39a..3deb62f24c 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -171,6 +171,7 @@ flowchart TD pkg_client_ui_theme["client-ui-theme"] pkg_client_ui_tool["client-ui-tool"] pkg_client_ui_trajectory["client-ui-trajectory"] + pkg_client_ui_workflow_run["client-ui-workflow-run"] pkg_client_ui_workspace["client-ui-workspace"] pkg_client_web["client-web"] pkg_client_web_react["client-web-react"] @@ -971,6 +972,7 @@ flowchart TD pkg_tool_workflow --> pkg_agent pkg_tool_workflow --> pkg_invariants pkg_tool_workflow --> pkg_llm + pkg_tool_workflow --> pkg_session pkg_tool_workflow --> pkg_system_prompt pkg_tool_workflow --> pkg_tools pkg_tool_workflow --> pkg_workflow @@ -1162,6 +1164,15 @@ flowchart TD pkg_client_ui_tool --> pkg_client_ui_primitives pkg_client_ui_tool --> pkg_client_ui_slots pkg_client_ui_tool --> pkg_invariants + pkg_client_ui_workflow_run --> pkg_client_locale + pkg_client_ui_workflow_run --> pkg_client_runtime + pkg_client_ui_workflow_run --> pkg_client_ui_conversation + pkg_client_ui_workflow_run --> pkg_client_ui_primitives + pkg_client_ui_workflow_run --> pkg_client_ui_slots + pkg_client_ui_workflow_run --> pkg_invariants + pkg_client_ui_workflow_run --> pkg_session + pkg_client_ui_workflow_run --> pkg_tool_workflow + pkg_client_ui_workflow_run --> pkg_workflow pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_bash_env @@ -1414,7 +1425,7 @@ flowchart TD | [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | -| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | @@ -1442,6 +1453,7 @@ flowchart TD | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`jsonrpc`](../packages/scaffold/server) | `scaffold` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`sdk-client`](../packages/scaffold/client) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index ee0b5cbdd6..a8b6842eb6 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-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/persistence-catalog.md -persistence-catalog.md: f44569d3bacec0a832f4b4bca6acf4abb0846a0d -persistence-catalog.zh.md: 21ed29a3da2587a604ec90d201030fd644fc5bd4 +persistence-catalog.md: 34803a69f11a964ccc6da3e74eb678fe398f94c8 +persistence-catalog.zh.md: 3146712e342519fdf687d5b18bd1c95c8c0f0a37 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index f44569d3ba..34803a69f1 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -716,6 +716,56 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) +### `tool-workflow/*` + +#### `tool-workflow/agent-end` — log-only + +```ts persistence-catalog +/** + * Records one member settlement. + * @param data - run identity, paired member sequence, and outcome. + */ +'tool-workflow/agent-end': ToolWorkflowAgentEndData +``` + +Source: [`packages/workflow/tool-workflow/src/types.ts:57`](../packages/workflow/tool-workflow/src/types.ts) + +#### `tool-workflow/agent-start` — log-only + +```ts persistence-catalog +/** + * Records one published workflow member. + * @param data - run identity, member sequence, display identity, and child Session. + */ +'tool-workflow/agent-start': ToolWorkflowAgentStartData +``` + +Source: [`packages/workflow/tool-workflow/src/types.ts:52`](../packages/workflow/tool-workflow/src/types.ts) + +#### `tool-workflow/run-end` — log-only + +```ts persistence-catalog +/** + * Closes one workflow record after cleanup. + * @param data - stable run identity and terminal reason. + */ +'tool-workflow/run-end': ToolWorkflowRunEndData +``` + +Source: [`packages/workflow/tool-workflow/src/types.ts:62`](../packages/workflow/tool-workflow/src/types.ts) + +#### `tool-workflow/run-start` — log-only + +```ts persistence-catalog +/** + * Opens one top-level workflow record. + * @param data - stable run identity and display name. + */ +'tool-workflow/run-start': ToolWorkflowRunStartData +``` + +Source: [`packages/workflow/tool-workflow/src/types.ts:47`](../packages/workflow/tool-workflow/src/types.ts) + ### `turn/*` #### `turn/end` — log-only diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 21ed29a3da..3146712e34 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -718,6 +718,56 @@ export type SessionEvent = { 来源:[`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) +### `tool-workflow/*` + +#### `tool-workflow/agent-end` — log-only + +```ts persistence-catalog +/** + * Records one member settlement. + * @param data - run identity, paired member sequence, and outcome. + */ +'tool-workflow/agent-end': ToolWorkflowAgentEndData +``` + +来源:[`packages/workflow/tool-workflow/src/types.ts:57`](../packages/workflow/tool-workflow/src/types.ts) + +#### `tool-workflow/agent-start` — log-only + +```ts persistence-catalog +/** + * Records one published workflow member. + * @param data - run identity, member sequence, display identity, and child Session. + */ +'tool-workflow/agent-start': ToolWorkflowAgentStartData +``` + +来源:[`packages/workflow/tool-workflow/src/types.ts:52`](../packages/workflow/tool-workflow/src/types.ts) + +#### `tool-workflow/run-end` — log-only + +```ts persistence-catalog +/** + * Closes one workflow record after cleanup. + * @param data - stable run identity and terminal reason. + */ +'tool-workflow/run-end': ToolWorkflowRunEndData +``` + +来源:[`packages/workflow/tool-workflow/src/types.ts:62`](../packages/workflow/tool-workflow/src/types.ts) + +#### `tool-workflow/run-start` — log-only + +```ts persistence-catalog +/** + * Opens one top-level workflow record. + * @param data - stable run identity and display name. + */ +'tool-workflow/run-start': ToolWorkflowRunStartData +``` + +来源:[`packages/workflow/tool-workflow/src/types.ts:47`](../packages/workflow/tool-workflow/src/types.ts) + ### `turn/*` #### `turn/end` — log-only diff --git a/docs/subsystems/workflow.i18n.yaml b/docs/subsystems/workflow.i18n.yaml index b18eeced08..3100aaeddc 100644 --- a/docs/subsystems/workflow.i18n.yaml +++ b/docs/subsystems/workflow.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/workflow.md -workflow.md: 22dcaad608cc2ca7f407b8837fc3856abcc43555 -workflow.zh.md: 7ccd47f414ad574f2daa8e74f6cfb65abfbe06c2 +workflow.md: b651a5459d4ff8c71de223ca2b51dca997ab86bf +workflow.zh.md: 0fd32675c8612dfeee1dbce7cd8e9977bbe330ef diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 22dcaad608..b651a5459d 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -6,7 +6,7 @@ The workflow seam lets an agent run a model-written orchestration SCRIPT that st Service Definition: [dsh-workflow](../../packages/workflow/workflow) (`ctx.workflows` + the vocabulary below). The Service provider is [dsh-workflow-workerthread](../../packages/workflow/workflow-workerthread) (a `node:worker_threads` engine — one worker per run, the script's vm context inside it); the model-facing Consumer is [dsh-tool-workflow](../../packages/workflow/tool-workflow). The proposal and rationale: [the dynamic-workflows Agent Note](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md). -Source: [`packages/workflow/workflow/src/types.ts`](../../packages/workflow/workflow/src/types.ts) +Sources: browser-safe vocabulary in [`packages/workflow/workflow/src/types.ts`](../../packages/workflow/workflow/src/types.ts), Host request and live-run handles in [`runtime-types.ts`](../../packages/workflow/workflow/src/runtime-types.ts). ## The start request @@ -15,33 +15,23 @@ What a caller asks for when starting a run. The ordinary workflow tool builds th ```ts type-equiv /** * What a caller asks for when starting a workflow run. `meta` and `args` are - * plain JSON DATA by the seam contract (the tool builds both from the model's schema-validated call; - * the engine validates `meta` against its schema and rejects loud - * before anything runs) — an engine never evaluates script text to obtain - * them. `parent` is REQUIRED — every `agent()` the script spawns is - * attributed to it (cwd, lineage, depth flow through the subagent seam). + * plain JSON data by the seam contract. `parent` is required because every + * `agent()` spawned by the script is attributed to that live Agent. */ interface WorkflowStartRequest { /** The plain-JS script body (top-level await allowed; ends with `return `). */ script: string - /** The workflow's identity fields as plain JSON data, validated by the engine. */ + /** The workflow's identity block, as plain JSON data (shape-validated by the engine). */ meta: WorkflowMeta /** Optional input exposed verbatim to the script as the `args` global. */ args?: unknown - /** - * Optional engine-wide child-provider override for this run. The workflow - * script cannot observe or replace it; omission uses the engine's configured - * provider. - */ + /** Optional engine-wide child-provider override for this run. */ subagentProvider?: string - /** - * Optional per-run total-child ceiling. Implementations reject values above - * their deployment ceiling before publishing the run. - */ + /** Optional per-run total-child ceiling. */ maxTotalAgents?: number /** The agent on whose behalf the run executes (parent of every child). */ parent: Agent - /** Cancels the run when aborted (the tool's `exec.signal`). */ + /** Cancels the run when aborted. */ signal?: AbortSignal } ``` @@ -76,7 +66,7 @@ The outcome of one run, resolved by `WorkflowRun.result`. `value` is the script' ```ts type-equiv /** - * The outcome of one run, resolved by {@link WorkflowRun.result}. `value` is + * The outcome resolved by a live workflow run. `value` is * the script's materialized return value (plain host-realm JSON data; `null` * when the script returned `undefined`) — meaningful only for `completed`. * A non-`completed` reason carries the failure in `error`; the consumer maps @@ -106,19 +96,17 @@ The handle the consumer holds while a script executes. The consumer awaits `resu ```ts type-equiv /** - * Holder-owned live workflow. `result` never rejects and settles within the - * engine's cancellation grace; failures resolve through `stopReason`. Consumers - * may cancel and must call idempotent `dispose()` on every path to await bounded - * script settlement and child quiescence. + * Holder-owned live workflow. `result` never rejects; consumers may cancel + * and must call idempotent `dispose()` to await script and child quiescence. */ interface WorkflowRun { readonly id: WorkflowRunId - /** The validated meta block (available before the body runs). */ + /** The validated meta block available before the script body runs. */ readonly meta: WorkflowMeta readonly result: Promise - /** Cancel the run: children abort, pending hooks reject, the script dies at its next await (or is force-settled at the grace). */ + /** Cancel the run and its children. */ cancel(reason?: string): void - /** Cancel + bounded-grace settle; safe to call on every path (idempotent). */ + /** Cancel if needed and await bounded settlement and cleanup. */ dispose(): Promise } ``` @@ -131,6 +119,14 @@ Hook misuse inside a script — bad arguments, unknown/deferred `agent()` option The `workflow/*` events (`workflow/start`, `workflow/phase`, `workflow/log`, `workflow/agent-start`, `workflow/agent-end`, `workflow/end` — see the [events catalog](#cordis-surface)) are **observe-only** emits carrying DATA SNAPSHOTS: every payload starts with `WorkflowRunInfo` (id + meta), never the live `WorkflowRun`, so a subscriber cannot gain `cancel`/`dispose`, and `workflow/end` deliberately omits the result value (a listener observing outcomes must not receive a mutable alias of the caller's result). Every emit is per-listener contained — a throwing subscriber is logged, never propagated, and cannot starve the listeners registered after it — and every listener receives its own payload clone, so mutating it corrupts neither the engine nor other listeners; the containment mirrors `subagent/start`/`subagent/end`. +## Durable Chat records + +The top-level `dsh-tool-workflow` consumer projects display facts into its calling parent Session without changing execution ownership. It writes `tool-workflow/run-start` after a run is accepted, pairs member start and end by `runId + seq`, and writes `tool-workflow/run-end` only after the result is known and disposal reaches quiescence. Nested transport calls write no record. The first append failure disables later writes for that run, so the log remains empty or a legal continuous prefix and the tool result is unchanged. + +`dsh-tool-workflow/invariant` validates the same protocol before live commit and when a Session is loaded: one start per run, positive unique member sequences, paired member endings, no run ending with open members, and no updates after the run ending. A missing member ending or run ending at the log tail is valid interruption evidence rather than corruption. + +`dsh-client-ui-workflow-run` folds the four events through the Conversation Node engine into one `workflow-run` Chat node anchored at the run-start sequence, after the original workflow tool node. Phase groups come only from actual member starts and preserve exact strings, including the distinction between an omitted phase and `''`. Closed Locations turn missing terminal facts into interrupted presentation. The 32-pixel run row uses module-platform background, persistent chevrons, and inline dot plus status text; 32-pixel phase rows keep title and count in the main area and precise aggregate status in a fixed tail without another dot; members use a 16-pixel dot slot and fixed 64-pixel lifecycle column. Underlined names alone mark navigation while the member and current list both prove a running same-parent local subagent. + @@ -155,7 +151,7 @@ Workflow Service Definition contract. Invalid requests throw before publication; abstract start(request: WorkflowStartRequest): WorkflowRun ``` -Source: [`packages/workflow/workflow/src/index.ts:159`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:157`](../../packages/workflow/workflow/src/index.ts) @@ -181,7 +177,7 @@ One `agent()` call settled (clean result, child failure, or run cancellation). P 'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:81`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:79`](../../packages/workflow/workflow/src/index.ts) @@ -202,7 +198,7 @@ One `agent()` call established a published child run. Paired with Events['workfl 'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:70`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:68`](../../packages/workflow/workflow/src/index.ts) @@ -223,7 +219,7 @@ A workflow run settled (any stop reason). Fired when WorkflowRun.result resolves 'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:91`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:89`](../../packages/workflow/workflow/src/index.ts) @@ -241,7 +237,7 @@ The script emitted a narration line (a `log(message)` call). 'workflow/log'(info: WorkflowRunInfo, message: string): void ``` -Source: [`packages/workflow/workflow/src/index.ts:60`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:58`](../../packages/workflow/workflow/src/index.ts) @@ -260,7 +256,7 @@ The script entered a phase (a `phase(title)` call) — progress grouping for obs 'workflow/phase'(info: WorkflowRunInfo, title: string): void ``` -Source: [`packages/workflow/workflow/src/index.ts:53`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:51`](../../packages/workflow/workflow/src/index.ts) @@ -278,5 +274,5 @@ A workflow run started — the script's meta block validated, the body about to 'workflow/start'(info: WorkflowRunInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:45`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:43`](../../packages/workflow/workflow/src/index.ts) diff --git a/docs/subsystems/workflow.zh.md b/docs/subsystems/workflow.zh.md index 7ccd47f414..0fd32675c8 100644 --- a/docs/subsystems/workflow.zh.md +++ b/docs/subsystems/workflow.zh.md @@ -6,7 +6,7 @@ Service Definition:[dsh-workflow](../../packages/workflow/workflow)(`ctx.workflows` + 下文词汇)。Service provider 是 [dsh-workflow-workerthread](../../packages/workflow/workflow-workerthread)(一个 `node:worker_threads` 引擎——每个 run 一个 worker,脚本的 vm 上下文位于其中);面向模型的 Consumer 是 [dsh-tool-workflow](../../packages/workflow/tool-workflow)。提案与设计理由见 [dynamic-workflows Agent Note](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md)。 -源码:[`packages/workflow/workflow/src/types.ts`](../../packages/workflow/workflow/src/types.ts) +源码:浏览器安全词汇位于 [`packages/workflow/workflow/src/types.ts`](../../packages/workflow/workflow/src/types.ts),Host 请求与活跃运行句柄位于 [`runtime-types.ts`](../../packages/workflow/workflow/src/runtime-types.ts)。 ## 启动请求 @@ -15,33 +15,23 @@ Service Definition:[dsh-workflow](../../packages/workflow/workflow)(`ctx.wor ```ts type-equiv /** * What a caller asks for when starting a workflow run. `meta` and `args` are - * plain JSON DATA by the seam contract (the tool builds both from the model's schema-validated call; - * the engine validates `meta` against its schema and rejects loud - * before anything runs) — an engine never evaluates script text to obtain - * them. `parent` is REQUIRED — every `agent()` the script spawns is - * attributed to it (cwd, lineage, depth flow through the subagent seam). + * plain JSON data by the seam contract. `parent` is required because every + * `agent()` spawned by the script is attributed to that live Agent. */ interface WorkflowStartRequest { /** The plain-JS script body (top-level await allowed; ends with `return `). */ script: string - /** The workflow's identity fields as plain JSON data, validated by the engine. */ + /** The workflow's identity block, as plain JSON data (shape-validated by the engine). */ meta: WorkflowMeta /** Optional input exposed verbatim to the script as the `args` global. */ args?: unknown - /** - * Optional engine-wide child-provider override for this run. The workflow - * script cannot observe or replace it; omission uses the engine's configured - * provider. - */ + /** Optional engine-wide child-provider override for this run. */ subagentProvider?: string - /** - * Optional per-run total-child ceiling. Implementations reject values above - * their deployment ceiling before publishing the run. - */ + /** Optional per-run total-child ceiling. */ maxTotalAgents?: number /** The agent on whose behalf the run executes (parent of every child). */ parent: Agent - /** Cancels the run when aborted (the tool's `exec.signal`). */ + /** Cancels the run when aborted. */ signal?: AbortSignal } ``` @@ -76,7 +66,7 @@ interface WorkflowMeta { ```ts type-equiv /** - * The outcome of one run, resolved by {@link WorkflowRun.result}. `value` is + * The outcome resolved by a live workflow run. `value` is * the script's materialized return value (plain host-realm JSON data; `null` * when the script returned `undefined`) — meaningful only for `completed`. * A non-`completed` reason carries the failure in `error`; the consumer maps @@ -106,19 +96,17 @@ interface WorkflowResult { ```ts type-equiv /** - * Holder-owned live workflow. `result` never rejects and settles within the - * engine's cancellation grace; failures resolve through `stopReason`. Consumers - * may cancel and must call idempotent `dispose()` on every path to await bounded - * script settlement and child quiescence. + * Holder-owned live workflow. `result` never rejects; consumers may cancel + * and must call idempotent `dispose()` to await script and child quiescence. */ interface WorkflowRun { readonly id: WorkflowRunId - /** The validated meta block (available before the body runs). */ + /** The validated meta block available before the script body runs. */ readonly meta: WorkflowMeta readonly result: Promise - /** Cancel the run: children abort, pending hooks reject, the script dies at its next await (or is force-settled at the grace). */ + /** Cancel the run and its children. */ cancel(reason?: string): void - /** Cancel + bounded-grace settle; safe to call on every path (idempotent). */ + /** Cancel if needed and await bounded settlement and cleanup. */ dispose(): Promise } ``` @@ -131,6 +119,14 @@ interface WorkflowRun { `workflow/*` 事件(`workflow/start`、`workflow/phase`、`workflow/log`、`workflow/agent-start`、`workflow/agent-end`、`workflow/end`,见[事件目录](#cordis-surface))是**仅供观察**的 emit,携带数据快照:每个 payload 以 `WorkflowRunInfo`(id + meta)开头,而非活跃的 `WorkflowRun`,因此订阅者无法获得 `cancel`/`dispose`;`workflow/end` 刻意省略 result value(观察结果的监听器不得收到调用方 result 的可变别名)。每次 emit 对每个监听器隔离:抛出异常的订阅者被记录日志但不传播,不会饿死在它之后注册的监听器;每个监听器收到自己的 payload 克隆,因此修改它既不会损坏引擎也不会影响其他监听器。这种隔离方式与 `subagent/start`/`subagent/end` 一致。 +## 持久 Chat 记录 + +顶层 `dsh-tool-workflow` 消费方把展示事实投影到调用它的父 Session,同时不改变执行所有权。运行接受后写 `tool-workflow/run-start`,以 `runId + seq` 配对成员开始与结束,并且只在结果已取得且 dispose 完全停稳后写 `tool-workflow/run-end`。嵌套 transport 调用不写记录。第一次 append 失败会禁用本运行后续写入,因此日志保持为空或合法连续前缀,工具结果不变。 + +`dsh-tool-workflow/invariant` 会在实时提交前和 Session 加载时校验同一协议:每个运行只有一个 start,成员序号为正且唯一,成员 end 必须配对,仍有开放成员时不能结束运行,运行结束后不能继续更新。日志尾部缺少成员 end 或 run end 是有效的中断证据,不是损坏。 + +`dsh-client-ui-workflow-run` 通过 Conversation Node 引擎把四类事件折叠为一个 `workflow-run` Chat 节点,以 run-start 序号锚定在原工作流工具节点之后。阶段组只来自真正开始过的成员,并保留精确字符串,包括字段缺省与 `''` 的区别。Location 关闭时,缺失终点会显示为已中断。32 像素运行行使用 module-platform 背景、常驻 chevron 与内联状态点加文字;32 像素阶段行在主区显示标题和计数,在固定尾部精确显示聚合状态且不重复状态点;成员使用 16 像素状态点槽和固定 64 像素生命周期列。只有成员状态与当前列表同时证明它是同父级、仍运行的本地 subagent 时,带下划线名称才标记普通 Session 导航。 + @@ -155,7 +151,7 @@ Workflow Service Definition contract. Invalid requests throw before publication; abstract start(request: WorkflowStartRequest): WorkflowRun ``` -Source: [`packages/workflow/workflow/src/index.ts:159`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:157`](../../packages/workflow/workflow/src/index.ts) @@ -181,7 +177,7 @@ One `agent()` call settled (clean result, child failure, or run cancellation). P 'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:81`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:79`](../../packages/workflow/workflow/src/index.ts) @@ -202,7 +198,7 @@ One `agent()` call established a published child run. Paired with Events['workfl 'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:70`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:68`](../../packages/workflow/workflow/src/index.ts) @@ -223,7 +219,7 @@ A workflow run settled (any stop reason). Fired when WorkflowRun.result resolves 'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:91`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:89`](../../packages/workflow/workflow/src/index.ts) @@ -241,7 +237,7 @@ The script emitted a narration line (a `log(message)` call). 'workflow/log'(info: WorkflowRunInfo, message: string): void ``` -Source: [`packages/workflow/workflow/src/index.ts:60`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:58`](../../packages/workflow/workflow/src/index.ts) @@ -260,7 +256,7 @@ The script entered a phase (a `phase(title)` call) — progress grouping for obs 'workflow/phase'(info: WorkflowRunInfo, title: string): void ``` -Source: [`packages/workflow/workflow/src/index.ts:53`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:51`](../../packages/workflow/workflow/src/index.ts) @@ -278,5 +274,5 @@ A workflow run started — the script's meta block validated, the body about to 'workflow/start'(info: WorkflowRunInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:45`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:43`](../../packages/workflow/workflow/src/index.ts) diff --git a/knip.json b/knip.json index 249d21d857..e8dac8a2f1 100644 --- a/knip.json +++ b/knip.json @@ -156,6 +156,16 @@ "tests/**/*.tsx" ] }, + "packages/client/ui-workflow-run": { + "entry": [ + "tests/**/*.spec.tsx" + ], + "project": [ + "src/**/*.ts", + "src/**/*.tsx", + "tests/**/*.tsx" + ] + }, "packages/client/web-react": { "entry": [ "tests/**/*.spec.tsx" diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 216eb13199..dc399b9736 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -149,6 +149,11 @@ - id: ui-tool name: '@deepseek-ai/dsh-client-ui-tool' + # Durable workflow lifecycle as an independent Chat node after the + # existing generic workflow tool row. + - id: ui-workflow-run + name: '@deepseek-ai/dsh-client-ui-workflow-run' + # Turn tail: the produced-files row under each closing assistant message. # Remove this entry to turn the surface off; the tail hole renders empty. - id: ui-deliverables diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 4f8b8d4318..7d665f4893 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -59,6 +59,7 @@ "@deepseek-ai/dsh-client-ui-subagent": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", + "@deepseek-ai/dsh-client-ui-workflow-run": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 816f8737e7..d85bc38fd2 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: 567e10f74ae9d017abef1d876401a958eb80fcfd -README.zh.md: ad6a9fb199c4118b864b80a466ddef40676b7169 +README.md: 6b3904c1e97a5a3da4864731aa624b3afbf5d027 +README.zh.md: 9f71ffa04fb80f0fd6d62b1d4b23f0ea1474c107 diff --git a/packages/client/README.md b/packages/client/README.md index 567e10f74a..6b3904c1e9 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -23,6 +23,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces. | | [`ui-conversation/`](ui-conversation/README.md) | Presents the active conversation and its input surface. | | [`ui-tool/`](ui-tool/README.md) | Composes Tool call trees and keyed per-Tool views. | +| [`ui-workflow-run/`](ui-workflow-run/README.md) | Replays durable workflow runs as nested Chat disclosures with live-only child navigation. | | [`ui-goal/`](ui-goal/README.md) | Presents and manages the current goal. | | [`ui-trajectory/`](ui-trajectory/README.md) | Presents alternate views of agent activity. | | [`ui-command/`](ui-command/README.md) | Provides session-aware command discovery and dispatch. | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index ad6a9fb199..9f71ffa04f 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -23,6 +23,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-workspace/`](ui-workspace/README.md) | 提供 Workspace 选择与创建界面。 | | [`ui-conversation/`](ui-conversation/README.md) | 展示当前会话及其输入界面。 | | [`ui-tool/`](ui-tool/README.md) | 编排工具调用树和按工具键控的视图。 | +| [`ui-workflow-run/`](ui-workflow-run/README.md) | 把持久工作流运行回放为 Chat 嵌套折叠项,并只为实时子 Session 提供导航。 | | [`ui-goal/`](ui-goal/README.md) | 展示和管理当前目标。 | | [`ui-trajectory/`](ui-trajectory/README.md) | 提供 agent(智能体)活动的其他视图。 | | [`ui-command/`](ui-command/README.md) | 提供会话感知的命令发现与分发。 | diff --git a/packages/client/ui-workflow-run/README.i18n.yaml b/packages/client/ui-workflow-run/README.i18n.yaml new file mode 100644 index 0000000000..3d6294e997 --- /dev/null +++ b/packages/client/ui-workflow-run/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-workflow-run/README.md +README.md: 66539e0c16ac4102f9e1fe881106e6881b36a7d5 +README.zh.md: a803857af24802e8a4645c4d5aca56c04424c85e diff --git a/packages/client/ui-workflow-run/README.md b/packages/client/ui-workflow-run/README.md new file mode 100644 index 0000000000..66539e0c16 --- /dev/null +++ b/packages/client/ui-workflow-run/README.md @@ -0,0 +1,35 @@ +# @deepseek-ai/dsh-client-ui-workflow-run + +English | [中文](README.zh.md) + +The browser plugin that reconstructs durable top-level workflow runs as independent Chat nodes. It consumes the four `tool-workflow/*` Session events owned by [`dsh-tool-workflow`](../../workflow/tool-workflow/README.md), registers one `ConversationNodeDefinition`, and renders through the keyed `conversation.chat.node` slot without changing the existing workflow tool card. + +## Durable state and replay + +`tool-workflow/run-start` creates one Context keyed by `runId`; member starts, member endings, and the run ending update that Context in log order. A history tail containing only updates remains pending until an older page supplies the unique start, after which prepend, complete replay, and live append produce the same state. A closed Turn or Step with missing terminal events presents the affected run or members as interrupted without changing the tool result. + +Phase groups come only from members that actually started. Exact phase strings share a group, an omitted phase is distinct from the empty string, and settlement changes status without removing or reordering members. + +## Presentation and navigation + +The run and each phase have independent disclosure state. The run uses a 32-pixel `--dsw-alias-bg-module-platform` row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column. A running run initially expands; a terminal run loaded from history initially collapses. Local choices survive data updates while the keyed node remains mounted and reset only on a full remount. + +A member opens a child Session only while every current fact agrees: the member is running, the child id is in the ordinary Session list, the row has `origin: 'subagent'`, its `parentId` is the current Session, and the list row is still running. Underlined member text is the only visible navigation affordance; keyboard focus draws a two-pixel business-primary ring around the name area, while status copy remains `Running`. The component calls only the injected ordinary `sessions.open(id)` action; remote, addressed-only, wrong-parent, or terminal rows remain non-interactive. + +## Composition + +The package registers its Definition, locale dictionary, and `workflow-run` renderer as Cordis effects. Removing the client entry retracts all three contributions. The shipped Web bundle includes the plugin after `ui-conversation` and `ui-tool`. + +## Model Experience + +None, as this package renders durable Session facts for humans and adds no prompt, tool schema, request content, or model-visible result. + +#### KV Cache effect + +None. + +## Known Limitations and Deferred Work + +- Only top-level calls through `dsh-tool-workflow` produce these records; nested Code Mode calls and direct `WorkflowService` consumers do not. +- Navigation is intentionally live-only. Terminal members remain visible for review but never expose a cold-session opener from this node. +- The node shows run, phase, member identity, and status only; scripts, outputs, errors, logs, usage, static topology, and controls remain outside this surface. diff --git a/packages/client/ui-workflow-run/README.zh.md b/packages/client/ui-workflow-run/README.zh.md new file mode 100644 index 0000000000..a803857af2 --- /dev/null +++ b/packages/client/ui-workflow-run/README.zh.md @@ -0,0 +1,35 @@ +# @deepseek-ai/dsh-client-ui-workflow-run + +[English](README.md) | 中文 + +这个浏览器插件把持久化的顶层工作流运行重建为独立 Chat 节点。它消费由 [`dsh-tool-workflow`](../../workflow/tool-workflow/README.md) 拥有的四类 `tool-workflow/*` Session 事件,注册一个 `ConversationNodeDefinition`,并通过 keyed `conversation.chat.node` slot 渲染,不改变现有工作流工具卡。 + +## 持久状态与回放 + +`tool-workflow/run-start` 以 `runId` 创建唯一 Context;成员开始、成员结束和运行结束事件按日志顺序更新该 Context。只有 update 的历史尾页会保持 pending,直到更早页面补入唯一 start;此后 prepend、完整回放和实时 append 得到相同状态。若所属 Turn 或 Step 已关闭但终点事件缺失,界面把相应运行或成员显示为已中断,而不改写工具结果。 + +阶段组只来自真正开始过的成员。完全相同的阶段字符串归入同一组,字段缺省与空字符串保持不同身份;成员结算只改变状态,不删除或重排成员。 + +## 展示与导航 + +运行和每个阶段分别拥有本地 disclosure 状态。运行使用 32 像素 `--dsw-alias-bg-module-platform` 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。运行中记录首次挂载时展开,从历史加载的终态记录首次挂载时折叠。只要 keyed 节点仍挂载,本地选择就在数据更新时保持;只有完整 remount 才重新初始化。 + +只有所有实时事实同时成立时,成员才可打开子 Session:成员仍在运行、子 id 位于普通 Session 列表、列表行为 `origin: 'subagent'`、`parentId` 等于当前 Session,且列表行仍标记运行。带下划线的成员文字是唯一可见导航提示;键盘聚焦时,名称区显示 2 像素 business-primary 焦点环,右侧状态仍只显示“运行中”。组件只调用注入的普通 `sessions.open(id)`;远程、仅地址化、父级不符或终态的行都不可交互。 + +## 装配 + +本包把 Definition、locale 字典和 `workflow-run` renderer 都注册为 Cordis effect;移除客户端 entry 会撤销三者。shipped Web bundle 在 `ui-conversation` 与 `ui-tool` 之后装配该插件。 + +## Model Experience + +无,因为本包只为人类展示持久 Session 事实,不增加 prompt、工具 schema、请求内容或模型可见结果。 + +#### KV Cache effect + +无。 + +## Known Limitations and Deferred Work + +- 只有经 `dsh-tool-workflow` 发起的顶层调用会生成这些记录;嵌套 Code Mode 调用和直接 `WorkflowService` 消费方不会生成。 +- 导航刻意只面向实时运行。终态成员继续保留供复盘,但本节点永不为其提供冷 Session 入口。 +- 节点只显示运行、阶段、成员身份与状态;脚本、输出、错误、日志、用量、静态拓扑和控制操作都不属于本界面。 diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json new file mode 100644 index 0000000000..149bd99906 --- /dev/null +++ b/packages/client/ui-workflow-run/package.json @@ -0,0 +1,73 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-workflow-run", + "description": "Durable workflow-run Conversation Node and nested member disclosure for dsh web", + "version": "0.0.1", + "private": true, + "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" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-conversation" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "dependencies": { + "react": "^18.2.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-tool-workflow": "^0.0.1", + "@deepseek-ai/dsh-workflow": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-tool-workflow": "workspace:^", + "@deepseek-ai/dsh-workflow": "workspace:^", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css new file mode 100644 index 0000000000..0f069ac77b --- /dev/null +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css @@ -0,0 +1,250 @@ +.root { + width: 100%; + min-width: 0; +} + +.runHeader { + box-sizing: border-box; + display: flex; + align-items: center; + gap: 6px; + width: 100%; + min-width: 0; + height: 32px; + padding: 0 8px; + border-radius: 8px; + background: var(--dsw-alias-bg-module-platform); + cursor: pointer; +} + +.runHeader:focus-visible { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: -2px; +} + +.runLeading { + display: inline-flex; + flex: none; + width: 16px; + height: 16px; + align-items: center; + justify-content: center; + color: var(--dsw-alias-label-tertiary); +} + +.runTitle { + overflow: hidden; + flex: none; + max-width: 42%; + color: var(--dsw-alias-label-secondary); + font-size: 14px; + font-weight: 510; + line-height: 24px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.runSummary { + overflow: hidden; + flex: 1; + min-width: 0; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.statusTail { + display: inline-flex; + flex: none; + height: 20px; + align-items: center; + gap: 4px; + overflow: hidden; + font-size: 11px; + font-weight: 510; + line-height: 16px; + color: var(--dsw-alias-label-secondary); + white-space: nowrap; +} + +.phaseHeader { + box-sizing: border-box; + display: flex; + align-items: center; + gap: 6px; + width: 100%; + min-width: 0; + height: 32px; + cursor: pointer; +} + +.phaseHeader:focus-visible { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: -2px; + border-radius: 4px; +} + +.phaseLeading { + display: inline-flex; + flex: none; + width: 16px; + height: 16px; + align-items: center; + justify-content: center; + color: var(--dsw-alias-label-tertiary); +} + +.phaseTitle { + flex: none; + color: var(--dsw-alias-label-secondary); + font-size: 14px; + line-height: 24px; + white-space: nowrap; +} + +.phaseCount { + overflow: hidden; + flex: 1; + min-width: 0; + color: var(--dsw-alias-label-tertiary); + font-size: 13px; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.phaseStatus { + overflow: hidden; + flex: none; + width: 132px; + color: var(--dsw-alias-label-secondary); + font-size: 13px; + line-height: 20px; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +.separator { + flex: none; + width: 2px; + height: 2px; + border-radius: 50%; + background: var(--dsw-alias-label-tertiary); +} + +.phaseList { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + padding: 4px 0 0 16px; +} + +.phase { + min-width: 0; +} + +.members { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + padding: 0 0 0 16px; +} + +.memberRow, +.memberButton { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + min-width: 0; + min-height: 24px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--dsw-alias-label-secondary); + font: inherit; + text-align: left; +} + +.memberButton { + cursor: pointer; +} + +.memberButton .memberLabel { + color: var(--dsw-alias-state-business-primary); + text-decoration: underline; + text-underline-position: from-font; +} + +.dotSlot { + display: inline-flex; + flex: none; + width: 16px; + height: 24px; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.memberButton:focus-visible { + outline: none; +} + +.memberButton:focus-visible .memberLabelWrap { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: -1px; +} + +.memberLabelWrap { + display: flex; + overflow: hidden; + flex: 1; + min-width: 0; + height: 24px; + align-items: center; + padding: 0 2px; + border-radius: 4px; +} + +.memberLabel { + overflow: hidden; + flex: 1; + min-width: 0; + color: var(--dsw-alias-label-secondary); + font-size: 14px; + line-height: 24px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.memberStatus { + flex: none; + overflow: hidden; + width: 64px; + color: var(--dsw-alias-label-secondary); + font-size: 13px; + line-height: 20px; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +.empty { + color: var(--dsw-alias-label-tertiary); + font-size: 13px; + line-height: 20px; + padding: 0; +} + +@media (max-width: 560px) { + .phaseList, + .members { + padding-left: 12px; + } +} diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx new file mode 100644 index 0000000000..313bb06c97 --- /dev/null +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx @@ -0,0 +1,235 @@ +import { useMemo, useState, type KeyboardEvent } from 'react' +import { + IconChevronDownOutline14, IconChevronRightOutline14, StateDot, type StateDotState, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { WorkflowRunKey } from './locales.ts' +import type { + WorkflowRunMemberData, WorkflowRunPhaseData, WorkflowRunStatus, +} from './workflow-definition.ts' +import css from './WorkflowRunPanel.module.css' + +/** Navigation action injected from the plugin's own SessionsService access. */ +export interface WorkflowRunInjected { + readonly openSession: (id: SessionId) => void +} + +/** Complete keyed Chat renderer props. */ +export type WorkflowRunPanelProps = + PropsRuntime<'conversation.chat.node', 'workflow-run'> + & PropsLocale<'workflowRun'> + & WorkflowRunInjected + +const STATUS_KEYS = { + running: 'status.running', + completed: 'status.completed', + failed: 'status.failed', + cancelled: 'status.cancelled', + interrupted: 'status.interrupted', +} as const satisfies Record + +function dotState(status: WorkflowRunStatus): StateDotState { + switch (status) { + case 'running': return 'ongoing' + case 'completed': return 'done' + case 'failed': return 'error' + case 'cancelled': + case 'interrupted': return 'warning' + /* v8 ignore next -- WorkflowRunStatus is closed and every variant is handled above. */ + default: return status satisfies never + } +} + +function readablePhase(phase: string | null, t: WorkflowRunPanelProps['t']): string { + if (phase === null) return t('phase.unassigned') + return phase === '' ? t('phase.empty') : phase +} + +function readableMember(label: string, t: WorkflowRunPanelProps['t']): string { + return label === '' ? t('member.empty') : label +} + +function statusCount( + status: WorkflowRunStatus, + count: number, + t: WorkflowRunPanelProps['t'], +): string { + return t(`statusCount.${status}`, { count }) +} + +function phaseStatusSummary(members: readonly WorkflowRunMemberData[], t: WorkflowRunPanelProps['t']): string { + const counts = new Map() + for (const member of members) counts.set(member.status, (counts.get(member.status) ?? 0) + 1) + const count = (status: WorkflowRunStatus): number => counts.get(status) ?? 0 + const active = (['running', 'failed', 'cancelled', 'interrupted'] as const) + .filter(status => count(status) > 0) + if (active.length === 0) return statusCount('completed', count('completed'), t) + const visible = active.includes('interrupted') && count('completed') > 0 + ? ['completed' as const, ...active] + : active + return visible.map(status => statusCount(status, count(status), t)).join(' · ') +} + +function handleDisclosureKey(event: KeyboardEvent, onToggle: () => void): void { + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + onToggle() +} + +function RunHeader({ count, name, onToggle, open, status, t }: { + readonly count: number + readonly name: string + readonly onToggle: () => void + readonly open: boolean + readonly status: WorkflowRunStatus + readonly t: WorkflowRunPanelProps['t'] +}) { + return ( +
{ handleDisclosureKey(event, onToggle) }} + > + + {open ? : } + + {t('run.title', { name })} + + {t('run.members', { count })} + + + {t(STATUS_KEYS[status])} + +
+ ) +} + +function MemberRow({ member, navigable, openSession, t }: { + readonly member: WorkflowRunMemberData + readonly navigable: boolean + readonly openSession: WorkflowRunInjected['openSession'] + readonly t: WorkflowRunPanelProps['t'] +}) { + const name = readableMember(member.label, t) + const content = ( + <> + + {name} + {t(STATUS_KEYS[member.status])} + + ) + if (!navigable) { + return
{content}
+ } + return ( + + ) +} + +function PhaseSection({ phase, navigable, openSession, t }: { + readonly phase: WorkflowRunPhaseData + readonly navigable: ReadonlySet + readonly openSession: WorkflowRunInjected['openSession'] + readonly t: WorkflowRunPanelProps['t'] +}) { + const [open, setOpen] = useState(false) + const toggle = (): void => { setOpen(value => !value) } + return ( +
+
{ handleDisclosureKey(event, toggle) }} + > + + {open ? : } + + {readablePhase(phase.phase, t)} + + {t('run.members', { count: phase.members.length })} + {phaseStatusSummary(phase.members, t)} +
+ {open && ( +
+ {phase.members.map(member => ( + + ))} +
+ )} +
+ ) +} + +/** Render one durable workflow run with independent run and phase disclosure. */ +export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t }: WorkflowRunPanelProps) { + const [open, setOpen] = useState(() => node.data.status === 'running') + const sessions = useSessions(value => value) + const navigable = useMemo(() => { + const ordinary = new Set(sessions.ids) + const result = new Set() + for (const phase of node.data.phases) { + for (const member of phase.members) { + const summary = sessions.byId[member.childId] + if (member.status === 'running' + && ordinary.has(member.childId) + && summary?.origin === 'subagent' + && summary.parentId === sessionId + && summary.running) { + result.add(member.childId) + } + } + } + return result + }, [node.data.phases, sessionId, sessions]) + return ( +
+ { setOpen(value => !value) }} + /> + {open && ( +
+ {node.data.phases.length === 0 + ? {t('run.empty')} + : node.data.phases.map(phase => ( + + ))} +
+ )} +
+ ) +} diff --git a/packages/client/ui-workflow-run/src/client/index.ts b/packages/client/ui-workflow-run/src/client/index.ts new file mode 100644 index 0000000000..8f8a2c5480 --- /dev/null +++ b/packages/client/ui-workflow-run/src/client/index.ts @@ -0,0 +1,38 @@ +/** Browser plugin for durable workflow-run Conversation Nodes. */ + +import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { WorkflowRunPanel, type WorkflowRunInjected } from './WorkflowRunPanel.tsx' +import { en, NS, type WorkflowRunKey, zh } from './locales.ts' +import { workflowRunDefinition } from './workflow-definition.ts' + +export type { WorkflowRunInjected, WorkflowRunPanelProps } from './WorkflowRunPanel.tsx' +export type { + WorkflowRunChatData, WorkflowRunMemberData, WorkflowRunPhaseData, WorkflowRunStatus, +} from './workflow-definition.ts' +export type { WorkflowRunKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Durable workflow-run node copy. */ + workflowRun: WorkflowRunKey + } +} + +/** Required services for Definition, keyed renderer, navigation, and copy. */ +export const inject = ['conversationEvents', 'slots', 'sessions', 'locale'] + +/** Register the workflow Definition, dictionary, and keyed Chat renderer. */ +export function apply(ctx: ClientContext): void { + ctx.conversationEvents.register(workflowRunDefinition) + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workflow-run: dictionaries') + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ + name: 'conversation.chat.node', + key: 'workflow-run', + locale: NS, + inject: (): WorkflowRunInjected => ({ + openSession: (id: SessionId) => { ctx.sessions.open(id) }, + }), + }, WorkflowRunPanel)) +} diff --git a/packages/client/ui-workflow-run/src/client/locales.ts b/packages/client/ui-workflow-run/src/client/locales.ts new file mode 100644 index 0000000000..71a7c2aa9b --- /dev/null +++ b/packages/client/ui-workflow-run/src/client/locales.ts @@ -0,0 +1,49 @@ +/** `workflowRun` namespace dictionaries. */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'workflowRun' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'run.title': '{name}', + 'run.members': '{count} 个成员', + 'run.empty': '没有启动成员', + 'phase.unassigned': '未分阶段', + 'phase.empty': '空阶段名', + 'statusCount.running': '运行中 {count}', + 'statusCount.completed': '已完成 {count}', + 'statusCount.failed': '失败 {count}', + 'statusCount.cancelled': '已取消 {count}', + 'statusCount.interrupted': '已中断 {count}', + 'member.empty': '空成员名', + 'member.open': '打开 {name}', + 'status.running': '运行中', + 'status.completed': '已完成', + 'status.failed': '失败', + 'status.cancelled': '已取消', + 'status.interrupted': '已中断', +} + +/** English dictionary (same key set). */ +export const en: Record = { + 'run.title': '{name}', + 'run.members': '{count} members', + 'run.empty': 'No members started', + 'phase.unassigned': 'Unphased', + 'phase.empty': 'Empty phase name', + 'statusCount.running': 'Running {count}', + 'statusCount.completed': 'Completed {count}', + 'statusCount.failed': 'Failed {count}', + 'statusCount.cancelled': 'Cancelled {count}', + 'statusCount.interrupted': 'Interrupted {count}', + 'member.empty': 'Empty member name', + 'member.open': 'Open {name}', + 'status.running': 'Running', + 'status.completed': 'Completed', + 'status.failed': 'Failed', + 'status.cancelled': 'Cancelled', + 'status.interrupted': 'Interrupted', +} + +/** Union of this namespace's dictionary keys. */ +export type WorkflowRunKey = keyof typeof zh diff --git a/packages/client/ui-workflow-run/src/client/workflow-definition.ts b/packages/client/ui-workflow-run/src/client/workflow-definition.ts new file mode 100644 index 0000000000..e6a4d2fec0 --- /dev/null +++ b/packages/client/ui-workflow-run/src/client/workflow-definition.ts @@ -0,0 +1,200 @@ +import type { + ChatConversationViewNode, ConversationLocation, ConversationNodeContext, + ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { + ToolWorkflowAgentEndData, ToolWorkflowAgentStartData, +} from '@deepseek-ai/dsh-tool-workflow/types' +import type { WorkflowAgentOutcome, WorkflowStopReason } from '@deepseek-ai/dsh-workflow/types' + +/** Status shown for a workflow, phase, or member. */ +export type WorkflowRunStatus = 'running' | 'completed' | 'failed' | 'cancelled' | 'interrupted' + +/** Final renderer data for one member. */ +export interface WorkflowRunMemberData { + readonly seq: number + readonly label: string + readonly childId: SessionId + readonly status: WorkflowRunStatus +} + +/** Final renderer data for one exact phase identity. */ +export interface WorkflowRunPhaseData { + readonly key: string + /** `null` is the absent field; the empty string remains a distinct identity. */ + readonly phase: string | null + readonly status: WorkflowRunStatus + readonly members: readonly WorkflowRunMemberData[] +} + +/** Final keyed Chat payload for one workflow run. */ +export interface WorkflowRunChatData { + readonly name: string + readonly status: WorkflowRunStatus + readonly memberCount: number + readonly phases: readonly WorkflowRunPhaseData[] +} + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Durable top-level workflow run and all members that actually started. */ + 'workflow-run': WorkflowRunChatData + } +} + +interface WorkflowMemberState extends ToolWorkflowAgentStartData { + readonly outcome?: WorkflowAgentOutcome +} + +interface WorkflowState { + readonly name: string + readonly stopReason?: WorkflowStopReason + readonly members: readonly WorkflowMemberState[] +} + +/** + * Build a collision-free phase key preserving absent versus empty identity. + * @param phase - exact phase string, or null for an omitted field. + * @returns the stable renderer key for that phase identity. + */ +export function workflowPhaseKey(phase: string | null): string { + return phase === null ? 'missing' : `value:${phase.length}:${phase}` +} + +function statusFromStopReason(stopReason: WorkflowStopReason): WorkflowRunStatus { + switch (stopReason) { + case 'completed': return 'completed' + case 'cancelled': return 'cancelled' + case 'error': return 'failed' + /* v8 ignore next -- WorkflowStopReason is closed and every variant is handled above. */ + default: return stopReason satisfies never + } +} + +function statusFromOutcome(outcome: WorkflowAgentOutcome): WorkflowRunStatus { + switch (outcome) { + case 'completed': return 'completed' + case 'cancelled': return 'cancelled' + case 'failed': return 'failed' + /* v8 ignore next -- WorkflowAgentOutcome is closed and every variant is handled above. */ + default: return outcome satisfies never + } +} + +function locationClosed(location: ConversationLocation | undefined): boolean { + if (location === undefined) return false + if (location.kind === 'step') { + return location.step.status === 'closed' || location.turn.status === 'closed' + } + return location.kind === 'turn' && location.turn.status === 'closed' +} + +function aggregateStatus(members: readonly WorkflowRunMemberData[]): WorkflowRunStatus { + if (members.some(member => member.status === 'running')) return 'running' + if (members.some(member => member.status === 'failed')) return 'failed' + if (members.some(member => member.status === 'cancelled')) return 'cancelled' + if (members.some(member => member.status === 'interrupted')) return 'interrupted' + return 'completed' +} + +function projectWorkflow( + context: ConversationNodeContext, +): WorkflowRunChatData | undefined { + const state = context.state + if (state === undefined) return undefined + const interrupted = state.stopReason === undefined + && locationClosed(context.start?.location ?? context.matches[0]?.location) + const phases = new Map() + for (const member of state.members) { + const phase = member.phase === undefined ? null : member.phase + const key = workflowPhaseKey(phase) + let group = phases.get(key) + if (group === undefined) { + group = { phase, members: [] } + phases.set(key, group) + } + group.members.push({ + seq: member.seq, + label: member.label, + childId: member.childId, + status: member.outcome === undefined + ? interrupted ? 'interrupted' : 'running' + : statusFromOutcome(member.outcome), + }) + } + const projectedPhases = [...phases].map(([key, phase]) => ({ + key, + phase: phase.phase, + status: aggregateStatus(phase.members), + members: phase.members, + })) + return { + name: state.name, + status: state.stopReason === undefined + ? interrupted ? 'interrupted' : 'running' + : statusFromStopReason(state.stopReason), + memberCount: state.members.length, + phases: projectedPhases, + } +} + +function updateAgentStart(state: WorkflowState, data: ToolWorkflowAgentStartData): WorkflowState { + return { ...state, members: [...state.members, data] } +} + +function updateAgentEnd(state: WorkflowState, data: ToolWorkflowAgentEndData): WorkflowState { + return { + ...state, + members: state.members.map(member => member.seq === data.seq + ? { ...member, outcome: data.outcome } + : member), + } +} + +/** Durable workflow event family folded into one keyed Chat node. */ +export const workflowRunDefinition: ConversationNodeDefinition = { + kind: 'workflow-run', + match: (event) => { + if (event.type === 'tool-workflow/run-start') return { id: String(event.data.runId), role: 'start' } + if (event.type === 'tool-workflow/agent-start' + || event.type === 'tool-workflow/agent-end' + || event.type === 'tool-workflow/run-end') { + return { id: String(event.data.runId), role: 'update' } + } + return null + }, + start: (_context, match) => { + if (match.event.type !== 'tool-workflow/run-start') { + throw new Error('workflow-run start requires tool-workflow/run-start') + } + return { name: match.event.data.name, members: [] } + }, + update: (context, match) => { + if (match.event.type === 'tool-workflow/agent-start') { + return updateAgentStart(context.state, match.event.data) + } + if (match.event.type === 'tool-workflow/agent-end') { + return updateAgentEnd(context.state, match.event.data) + } + if (match.event.type === 'tool-workflow/run-end') { + return { ...context.state, stopReason: match.event.data.stopReason } + } + return context.state + }, + buildViewNode: (context, target): ChatConversationViewNode | null => { + if (target !== 'chat') return null + const data = projectWorkflow(context) + if (data === undefined || context.start === undefined) return null + return { + key: context.key, + kind: 'workflow-run', + id: context.id, + target: 'chat', + anchorSeq: context.start.event.seq, + location: context.start.location, + visibility: 'visible', + data, + } + }, +} diff --git a/packages/client/ui-workflow-run/src/css-modules.d.ts b/packages/client/ui-workflow-run/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-workflow-run/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-workflow-run/src/index.ts b/packages/client/ui-workflow-run/src/index.ts new file mode 100644 index 0000000000..3678bc9f9f --- /dev/null +++ b/packages/client/ui-workflow-run/src/index.ts @@ -0,0 +1,4 @@ +/** Durable workflow-run UI plugin, node half. */ + +/** Host plugin body; the feature is entirely browser-side. */ +export function apply(): void {} diff --git a/packages/client/ui-workflow-run/src/invariant.ts b/packages/client/ui-workflow-run/src/invariant.ts new file mode 100644 index 0000000000..7e5bfa2211 --- /dev/null +++ b/packages/client/ui-workflow-run/src/invariant.ts @@ -0,0 +1,24 @@ +/** Package-owned invariant companion for the workflow-run UI plugin. */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-workflow-run' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-workflow-run-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the browser plugin contributes one effect-owned + * Conversation Definition, keyed renderer, and dictionary; tests prove their + * disposal and the Host tool package owns the durable event invariant. + */ +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-workflow-run/tests/workflow-run.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx new file mode 100644 index 0000000000..3b7a2b3f79 --- /dev/null +++ b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx @@ -0,0 +1,526 @@ +// @vitest-environment jsdom +import { Context, Service } from 'cordis' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + ConversationEventRegistry, ConversationNodeAssembler, SlotsService, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { + ChatConversationViewNode, ConversationEventInput, ConversationMatch, ConversationNodeDefinition, + ConversationViewDefinition, ConversationViewNode, SessionId, SessionListState, +} from '@deepseek-ai/dsh-client-runtime/client' +import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { + WorkflowRunPanel, type WorkflowRunInjected, type WorkflowRunPanelProps, +} from '../src/client/WorkflowRunPanel.tsx' +import { apply, inject } from '../src/client/index.ts' +import { zh } from '../src/client/locales.ts' +import { + workflowRunDefinition, type WorkflowRunChatData, +} from '../src/client/workflow-definition.ts' +import { apply as applyNode } from '../src/index.ts' +import { apply as applyInvariant } from '../src/invariant.ts' +import type {} from '../src/client/index.ts' + +afterEach(cleanup) + +const PARENT_ID = 'parent' as SessionId +const CHILD_ID = 'child-1' as SessionId + +interface ChatSnapshot { + readonly nodes: ReadonlyMap +} + +class TestEventDefinitions { + entries(): readonly ConversationNodeDefinition[] { return [workflowRunDefinition] } + fallbackEntry(): undefined { return undefined } +} + +class TestViewDefinitions { + entries(): readonly ConversationViewDefinition[] { return [chatViewDefinition] } +} + +const chatViewDefinition: ConversationViewDefinition = { + target: 'chat', + create: () => { + let nodes = new Map() + const snapshot = (): ChatSnapshot => ({ nodes }) + return { + empty: snapshot(), + replace: ({ nodes: values }) => { + nodes = new Map(values.map(node => [node.key, node])) + return snapshot() + }, + apply: ({ upserts }) => { + nodes = new Map(nodes) + for (const node of upserts) nodes.set(node.key, node) + return snapshot() + }, + } + }, +} + +function at(seq: number, type: string, data: unknown): ConversationEventInput { + return { event: { seq, time: seq * 100, type, data } as ConversationEventInput['event'], view: undefined } +} + +function matched(input: ConversationEventInput, role: ConversationMatch['role']): ConversationMatch { + return { ...input, role, location: { kind: 'unresolved' } } +} + +function assembler(entries: readonly ConversationEventInput[], hasMore = false): ConversationNodeAssembler { + const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions()) + value.replaceWindow(entries, hasMore) + value.flush() + return value +} + +function workflowData(value: ConversationNodeAssembler): WorkflowRunChatData | undefined { + const snapshot = value.snapshot('chat') as ChatSnapshot + return [...snapshot.nodes.values()][0]?.data as WorkflowRunChatData | undefined +} + +function completeEvents(): ConversationEventInput[] { + return [ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'tool-workflow/run-start', { runId: 'run-1', name: 'audit' }), + at(4, 'tool-workflow/agent-start', { + runId: 'run-1', seq: 1, label: 'first', phase: '', childId: 'child-1', + }), + at(5, 'tool-workflow/agent-start', { + runId: 'run-1', seq: 2, label: 'second', childId: 'child-2', + }), + at(6, 'tool-workflow/agent-end', { runId: 'run-1', seq: 1, outcome: 'completed' }), + at(7, 'tool-workflow/agent-end', { runId: 'run-1', seq: 2, outcome: 'failed' }), + at(8, 'tool-workflow/run-end', { runId: 'run-1', stopReason: 'error' }), + at(9, 'step/end', { turn: 1, step: 1 }), + at(10, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + ] +} + +describe('workflow-run Conversation Definition', () => { + it('groups exact phase identities in first-member order and preserves terminal members', () => { + const value = assembler(completeEvents()) + const data = workflowData(value) + expect(data).toEqual({ + name: 'audit', + status: 'failed', + memberCount: 2, + phases: [ + { + key: 'value:0:', phase: '', status: 'completed', + members: [{ seq: 1, label: 'first', childId: 'child-1', status: 'completed' }], + }, + { + key: 'missing', phase: null, status: 'failed', + members: [{ seq: 2, label: 'second', childId: 'child-2', status: 'failed' }], + }, + ], + }) + const node = [...(value.snapshot('chat') as ChatSnapshot).nodes.values()][0]! + expect(node.anchorSeq).toBe(3) + expect(node.kind).toBe('workflow-run') + }) + + it('keeps an update-only tail pending until prepend supplies the unique start', () => { + const tail = completeEvents().slice(3) + const value = assembler(tail, true) + expect(workflowData(value)).toBeUndefined() + value.prepend(completeEvents().slice(0, 3), false) + value.flush() + expect(workflowData(value)).toEqual(workflowData(assembler(completeEvents()))) + }) + + it('produces the same final data through live append as complete replay', () => { + const events = completeEvents() + const value = assembler(events.slice(0, 3)) + for (const event of events.slice(3)) value.append(event) + value.flush() + expect(workflowData(value)).toEqual(workflowData(assembler(events))) + }) + + it('shows missing terminal facts as interrupted only after the owning Location closes', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'tool-workflow/run-start', { runId: 'run-1', name: 'audit' }), + at(4, 'tool-workflow/agent-start', { + runId: 'run-1', seq: 1, label: 'worker', childId: 'child-1', + }), + ]) + expect(workflowData(value)?.status).toBe('running') + value.append(at(5, 'step/end', { turn: 1, step: 1 })) + value.flush() + expect(workflowData(value)).toMatchObject({ + status: 'interrupted', + phases: [{ members: [{ status: 'interrupted' }] }], + }) + }) + + it('retains a zero-member run as its own completed node', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'tool-workflow/run-start', { runId: 'empty', name: 'empty' }), + at(4, 'tool-workflow/run-end', { runId: 'empty', stopReason: 'completed' }), + ]) + expect(workflowData(value)).toEqual({ + name: 'empty', status: 'completed', memberCount: 0, phases: [], + }) + }) + + it('folds same-phase cancellation and a turn-level interruption', () => { + const cancelled = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'tool-workflow/run-start', { runId: 'cancelled', name: 'cancelled' }), + at(3, 'tool-workflow/agent-start', { + runId: 'cancelled', seq: 1, label: 'one', phase: 'Research', childId: 'child-1', + }), + at(4, 'tool-workflow/agent-start', { + runId: 'cancelled', seq: 2, label: 'two', phase: 'Research', childId: 'child-2', + }), + at(5, 'tool-workflow/agent-end', { runId: 'cancelled', seq: 1, outcome: 'cancelled' }), + at(6, 'tool-workflow/agent-end', { runId: 'cancelled', seq: 2, outcome: 'completed' }), + at(7, 'tool-workflow/run-end', { runId: 'cancelled', stopReason: 'cancelled' }), + ]) + expect(workflowData(cancelled)).toMatchObject({ + status: 'cancelled', + phases: [{ phase: 'Research', status: 'cancelled', members: [{ status: 'cancelled' }, { status: 'completed' }] }], + }) + + const interruptedTurn = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'tool-workflow/run-start', { runId: 'turn', name: 'turn' }), + at(3, 'tool-workflow/agent-start', { + runId: 'turn', seq: 1, label: 'open', childId: 'child-1', + }), + at(4, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + ]) + expect(workflowData(interruptedTurn)?.status).toBe('interrupted') + }) + + it('handles session/unresolved placement and defensive Definition calls', () => { + const sessionLevel = assembler([ + at(1, 'tool-workflow/run-start', { runId: 'session', name: 'session' }), + at(2, 'tool-workflow/agent-start', { + runId: 'session', seq: 1, label: 'open', childId: 'child-1', + }), + ]) + expect(workflowData(sessionLevel)?.status).toBe('running') + + const invalidStart = matched(at(1, 'tool-workflow/agent-start', { + runId: 'direct', seq: 1, label: 'member', childId: 'child-1', + }), 'start') + const emptyContext: Parameters[0] = { + key: 'workflow-run:direct', kind: 'workflow-run', id: 'direct', + matches: [invalidStart], start: invalidStart, state: undefined, current: new Map(), + } + const reader: Parameters[2] = { previous: () => undefined } + expect(() => workflowRunDefinition.start(emptyContext, invalidStart, reader)) + .toThrow('workflow-run start requires tool-workflow/run-start') + + const start = matched(at(2, 'tool-workflow/run-start', { runId: 'direct', name: 'direct' }), 'start') + const startedContext = { ...emptyContext, matches: [start], start } + const state = workflowRunDefinition.start(startedContext, start, reader) + const updateContext: Parameters[0] = { ...startedContext, state } + const unrelated = matched(at(3, 'turn/start', { turn: 1 }), 'update') + expect(workflowRunDefinition.update(updateContext, unrelated)).toBe(state) + expect(workflowRunDefinition.buildViewNode(updateContext, 'trajectory')).toBeNull() + expect(workflowRunDefinition.buildViewNode({ + ...updateContext, matches: [], start: undefined, + }, 'chat')).toBeNull() + const directNode = workflowRunDefinition.buildViewNode(updateContext, 'chat') as ChatConversationViewNode | null + if (directNode === null) throw new Error('expected direct workflow Chat node') + expect(directNode.kind).toBe('workflow-run') + expect((directNode.data as WorkflowRunChatData).status).toBe('running') + }) +}) + +function node(data: WorkflowRunChatData): WorkflowRunPanelProps['node'] { + return { + key: '12:workflow-runrun-1', + kind: 'workflow-run', + id: 'run-1', + target: 'chat', + anchorSeq: 3, + location: { kind: 'unresolved' }, + visibility: 'visible', + data, + } +} + +const phase = (overrides: Partial = {}): WorkflowRunChatData['phases'][number] => ({ + key: 'missing', + phase: null, + status: 'running', + members: [{ seq: 1, label: 'worker', childId: 'child-1' as SessionId, status: 'running' }], + ...overrides, +}) + +const listState = (overrides: Partial = {}): SessionListState => ({ + ids: [PARENT_ID, CHILD_ID], + byId: { + [PARENT_ID]: { + id: PARENT_ID, displayTitle: 'parent', running: true, blank: false, updatedAt: 0, + }, + [CHILD_ID]: { + id: CHILD_ID, displayTitle: 'child', parentId: PARENT_ID, origin: 'subagent', + running: true, blank: false, updatedAt: 0, + }, + }, + current: PARENT_ID, + phase: 'ready', + subagentsByParent: {}, + currentAddress: undefined, + ...overrides, +}) + +function panelProps(data: WorkflowRunChatData, sessions = listState(), openSession = vi.fn()): WorkflowRunPanelProps { + return { + node: node(data), + sessionId: PARENT_ID, + useSessions: selector => selector(sessions), + useSession: (() => undefined) as WorkflowRunPanelProps['useSession'], + useProjection: () => undefined, + useInput: () => { throw new Error('unused') }, + inputActions: { setDraft: () => {}, submit: () => {} } as unknown as WorkflowRunPanelProps['inputActions'], + useWorkspaces: (() => undefined) as WorkflowRunPanelProps['useWorkspaces'], + useTurnData: () => undefined, + selectedCallId: undefined, + cwd: undefined, + openFile: () => {}, + inspectCall: () => {}, + forkAt: () => {}, + loadImage: () => Promise.reject(new Error('unused')), + fileMentions: () => undefined, + openSession, + t: makeTranslate(zh), + } +} + +describe('WorkflowRunPanel', () => { + it('defaults running runs open, terminal history closed, and keeps the current choice across data updates', () => { + const running: WorkflowRunChatData = { + name: 'audit', status: 'running', memberCount: 1, phases: [phase()], + } + const view = render() + expect(screen.getByText('未分阶段')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: /^audit/ })) + expect(screen.queryByText('未分阶段')).toBeNull() + + const terminal: WorkflowRunChatData = { ...running, status: 'completed' } + view.rerender() + expect(screen.queryByText('未分阶段')).toBeNull() + + cleanup() + render() + expect(screen.queryByText('未分阶段')).toBeNull() + }) + + it('supports root keyboard disclosure and renders a zero-member running state', () => { + render() + const header = screen.getByRole('button', { name: /^keyboard/ }) + expect(header.getAttribute('aria-expanded')).toBe('true') + fireEvent.keyDown(header, { key: 'ArrowDown' }) + expect(header.getAttribute('aria-expanded')).toBe('true') + fireEvent.keyDown(header, { key: 'Enter' }) + expect(header.getAttribute('aria-expanded')).toBe('false') + fireEvent.keyDown(header, { key: ' ' }) + expect(header.getAttribute('aria-expanded')).toBe('true') + expect(screen.getByText('Research')).toBeTruthy() + expect(screen.getByText('运行中 1')).toBeTruthy() + const phaseHeader = screen.getByRole('button', { name: /Research/ }) + fireEvent.keyDown(phaseHeader, { key: 'ArrowDown' }) + expect(phaseHeader.getAttribute('aria-expanded')).toBe('false') + fireEvent.keyDown(phaseHeader, { key: 'Enter' }) + expect(phaseHeader.getAttribute('aria-expanded')).toBe('true') + fireEvent.keyDown(phaseHeader, { key: ' ' }) + expect(phaseHeader.getAttribute('aria-expanded')).toBe('false') + + cleanup() + render() + expect(screen.getByText('没有启动成员')).toBeTruthy() + }) + + it('keeps phase disclosure independent and preserves empty versus absent names', () => { + render() + fireEvent.click(screen.getByRole('button', { name: /空阶段名/ })) + expect(screen.getByText('空成员名')).toBeTruthy() + expect(screen.queryByText('second')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) + expect(screen.getByText('second')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: /空阶段名/ })) + expect(screen.queryByText('空成员名')).toBeNull() + expect(screen.getByText('second')).toBeTruthy() + }) + + it('covers the Figma completed, failed/cancelled, and interrupted state boards', () => { + const completed: WorkflowRunChatData = { + name: 'repo-audit', status: 'completed', memberCount: 1, + phases: [phase({ + status: 'completed', + members: [{ seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' }], + })], + } + const completedView = render() + const completedHeader = screen.getByRole('button', { name: /^repo-audit/ }) + expect(completedHeader.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(completedHeader) + expect(completedHeader.getAttribute('aria-expanded')).toBe('true') + completedView.unmount() + + const mixed: WorkflowRunChatData = { + name: 'repo-audit', status: 'failed', memberCount: 2, + phases: [phase({ + status: 'failed', + members: [ + { seq: 1, label: 'failed', childId: 'child-1' as SessionId, status: 'failed' }, + { seq: 2, label: 'cancelled', childId: 'child-2' as SessionId, status: 'cancelled' }, + ], + })], + } + const mixedView = render() + fireEvent.click(screen.getByRole('button', { name: /^repo-audit/ })) + fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) + expect(screen.getByText('失败 1 · 已取消 1')).toBeTruthy() + expect([...mixedView.container.querySelectorAll('[data-member-status]')] + .map(row => row.getAttribute('data-member-status'))).toEqual(['failed', 'cancelled']) + expect(mixedView.container.querySelectorAll('[data-state="error"]')).toHaveLength(2) + expect(mixedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(1) + mixedView.unmount() + + const interrupted: WorkflowRunChatData = { + name: 'repo-audit', status: 'interrupted', memberCount: 2, + phases: [ + phase({ + status: 'interrupted', + members: [ + { seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' }, + { seq: 2, label: 'interrupted', childId: 'child-2' as SessionId, status: 'interrupted' }, + ], + }), + phase({ + key: 'interrupted-only', phase: 'Interrupted only', status: 'interrupted', + members: [{ + seq: 3, label: 'interrupted', childId: 'child-3' as SessionId, status: 'interrupted', + }], + }), + ], + } + const interruptedView = render() + fireEvent.click(screen.getByRole('button', { name: /^repo-audit/ })) + expect(screen.getByText('已完成 1 · 已中断 1')).toBeTruthy() + expect(interruptedView.container.querySelector('[data-run-status="interrupted"]')).toBeTruthy() + expect(interruptedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(1) + }) + + it('opens only a running ordinary-list subagent proven to have this parent', () => { + const data: WorkflowRunChatData = { + name: 'audit', status: 'running', memberCount: 1, phases: [phase()], + } + const openSession = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) + fireEvent.click(screen.getByRole('button', { name: '打开 worker' })) + expect(openSession).toHaveBeenCalledWith('child-1') + }) + + it.each([ + ['not in ordinary list', listState({ ids: [PARENT_ID] }), 'running'], + ['remote row', listState({ byId: { + ...listState().byId, + [CHILD_ID]: { ...listState().byId[CHILD_ID]!, origin: undefined }, + } }), 'running'], + ['wrong parent', listState({ byId: { + ...listState().byId, + [CHILD_ID]: { ...listState().byId[CHILD_ID]!, parentId: 'other' as SessionId }, + } }), 'running'], + ['list terminal', listState({ byId: { + ...listState().byId, + [CHILD_ID]: { ...listState().byId[CHILD_ID]!, running: false }, + } }), 'running'], + ['member terminal', listState(), 'completed'], + ] as const)('does not navigate when %s', (_name, sessions, memberStatus) => { + const data: WorkflowRunChatData = { + name: 'audit', status: 'running', memberCount: 1, + phases: [phase({ + status: memberStatus === 'running' ? 'running' : 'completed', + members: [{ + seq: 1, label: 'worker', childId: 'child-1' as SessionId, status: memberStatus, + }], + })], + } + render() + fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) + expect(screen.queryByRole('button', { name: '打开 worker' })).toBeNull() + cleanup() + }) +}) + +class TestSessions extends Service { + readonly opened: SessionId[] = [] + constructor(ctx: Context) { super(ctx, 'sessions') } + open(id: SessionId): void { this.opened.push(id) } +} + +describe('plugin lifecycle', () => { + it('registers and removes the Definition and keyed renderer with its fiber', async () => { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + await ctx.plugin(ConversationEventRegistry).await() + await ctx.plugin(TestSessions).await() + ctx.slots.register({ + name: 'root', + children: { 'conversation.chat.node': { kind: 'keyed', scope: 'session' } }, + } as never, () => null) + await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(ctx.conversationEvents.entries().map(entry => entry.kind)).toEqual(['workflow-run']) + expect(ctx.slots.entries('conversation.chat.node')).toHaveLength(1) + const entry = ctx.slots.entries('conversation.chat.node')[0]! + const face = entry.inject?.() as unknown as WorkflowRunInjected + face.openSession(CHILD_ID) + expect((ctx.sessions as unknown as TestSessions).opened).toEqual([CHILD_ID]) + await fiber.dispose() + expect(ctx.conversationEvents.entries()).toEqual([]) + expect(ctx.slots.entries('conversation.chat.node')).toEqual([]) + + const replacement = ctx.plugin({ inject: [...inject], apply }) + await replacement.await() + expect(ctx.conversationEvents.entries().map(entry => entry.kind)).toEqual(['workflow-run']) + expect(ctx.slots.entries('conversation.chat.node')).toHaveLength(1) + await replacement.dispose() + }) + + it('keeps the node half inert and registers invariant ownership', async () => { + applyNode() + const registered: string[] = [] + const ctx = new Context() + ctx.provide('invariants') + ctx.set('invariants', { + register: (pkg: string) => { registered.push(pkg); return () => {} }, + } as never) + await applyInvariant(ctx) + expect(registered).toEqual(['@deepseek-ai/dsh-client-ui-workflow-run']) + }) +}) + +void ({} as ConversationViewNode) diff --git a/packages/client/ui-workflow-run/tsconfig.json b/packages/client/ui-workflow-run/tsconfig.json new file mode 100644 index 0000000000..d86b4edef3 --- /dev/null +++ b/packages/client/ui-workflow-run/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../locale" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../core/session" + }, + { + "path": "../../workflow/workflow" + }, + { + "path": "../../workflow/tool-workflow" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-workflow-run/tsdown.config.ts b/packages/client/ui-workflow-run/tsdown.config.ts new file mode 100644 index 0000000000..c6cfded6a2 --- /dev/null +++ b/packages/client/ui-workflow-run/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-workflow-run', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/workflow/tool-workflow/README.i18n.yaml b/packages/workflow/tool-workflow/README.i18n.yaml index 209ac7758c..e50711118d 100644 --- a/packages/workflow/tool-workflow/README.i18n.yaml +++ b/packages/workflow/tool-workflow/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/workflow/tool-workflow/README.md -README.md: 29896bee0f78a1d1764c3908965325fcecbf7b53 -README.zh.md: 12e1ecd8932120c74384a289530954422ba145f2 +README.md: ba8283a6b517eea79e6c75674a906db01e4b5890 +README.zh.md: 2af8f5f8b8db2d5edf530d79dd81319846cfeeea diff --git a/packages/workflow/tool-workflow/README.md b/packages/workflow/tool-workflow/README.md index 29896bee0f..ba8283a6b5 100644 --- a/packages/workflow/tool-workflow/README.md +++ b/packages/workflow/tool-workflow/README.md @@ -12,6 +12,10 @@ Three parameters: `meta` (required identity data: `name`, `description`, and opt Collection is synchronous (like [`dsh-tool-subagent`](../../subagent/tool-subagent/README.md)): `execute` starts a run and awaits `run.result` inside a `try/finally` that always disposes the run, so the script and its children reach quiescence on every path. `exec.signal` is bridged to `run.cancel()` (including the already-aborted-before-start case). A non-`completed` stop reason maps to an `isError` result reporting the reason—never partial output as success; a parse/meta failure thrown synchronously by `start()` becomes an `isError` the model can correct from. Completion returns canonical `{ runId, agentsStarted, result }`; the Native renderer preserves the meta name, agent count, and JSON value, truncating only that projection at `maxResultChars`. +For a root transport execution (`exec.parent` absent), the tool also projects the run into the calling Agent's Session: run-start after `start()` returns, matching member starts and endings filtered by `run.id`, then run-end only after `run.result` is available and `dispose()` has reached quiescence. Nested transport calls execute normally but write no workflow record. The first failed Session append disables later recording for that run, emits one warning, and leaves either no record or a legal continuous prefix without changing the tool result or cleanup. + +The browser-safe `@deepseek-ai/dsh-tool-workflow/types` subpath owns these four log-only event payloads and their `SessionEventMap` declaration. The package invariant rejects duplicate starts, unpaired members, terminal events with open members, and updates after run-end on both cold load and live append while accepting missing terminal suffixes. + ## Render intent Decided up front (per the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)): a `generic` card titled `workflow: `, read directly from `args.meta.name` (presentation is a pure function of args and does not ask the engine to parse); the script text rides as `rawInput`. The result keeps the generic card. @@ -78,3 +82,4 @@ Append-only; newly visible content follows the reusable request prefix and does - **The parent turn blocks until the whole workflow settles** — there is no background start/poll surface, and cancellation discards partial output as an error. - **`args` must be an object and Native result text is bounded** — callers wrap top-level arrays/scalars in a field; the canonical workflow result remains complete, while JSON beyond `maxResultChars` is truncated in the model-facing projection rather than stored behind a retrieval handle. - **Workflow policy is fixed per tool registration** — provider selection, caps, and tool name are deployment config, not model-call arguments. +- **Durable records are top-level and observational** — nested Code Mode dispatches are not recorded, and a recording failure intentionally degrades to an incomplete prefix rather than changing execution. diff --git a/packages/workflow/tool-workflow/README.zh.md b/packages/workflow/tool-workflow/README.zh.md index 12e1ecd893..2af8f5f8b8 100644 --- a/packages/workflow/tool-workflow/README.zh.md +++ b/packages/workflow/tool-workflow/README.zh.md @@ -12,6 +12,10 @@ 收集是同步的(类似 [`dsh-tool-subagent`](../../subagent/tool-subagent/README.md)):`execute` 启动运行并等待 `run.result`;这些操作位于 `try/finally` 中,该结构总会 dispose(资源释放)运行,使脚本及其子 agent(智能体)在每条路径上完全停稳。`exec.signal` 会桥接到 `run.cancel()`,包括启动前已经中止的情况。非 `completed` 结束原因会映射为报告原因的 `isError` 结果,绝不会把局部输出当作成功;`start()` 同步抛出的解析/meta 失败会变成模型可据以修正的 `isError`。完成时返回规范值 `{ runId, agentsStarted, result }`;Native 渲染器保留 meta 名称、agent 数量和 JSON 值,只会在 `maxResultChars` 处截断该投影。 +对于根 transport 执行(`exec.parent` 缺省),工具还会把运行投影到调用 Agent 的 Session:`start()` 返回后写 run-start,只记录 `run.id` 匹配的成员开始与结束,并且只在 `run.result` 已取得且 `dispose()` 完全停稳后写 run-end。嵌套 transport 调用照常执行,但不写工作流记录。任一次 Session append 首次失败后,本运行会停止后续记录并只告警一次,留下空记录或合法连续前缀,同时不改变工具结果和清理。 + +浏览器安全的 `@deepseek-ai/dsh-tool-workflow/types` 子路径拥有这四类 log-only 事件 payload 及其 `SessionEventMap` 声明。包 invariant 会在冷加载和实时追加时拒绝重复 start、未配对成员、仍有开放成员的终点和 run-end 后更新,同时允许缺失终态后缀的连续前缀。 + ## 渲染意图 渲染意图预先确定(见[渲染意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)):使用一个 `generic` 卡片,标题为 `workflow: `,直接从 `args.meta.name` 读取(呈现是参数的纯函数,不要求引擎解析);脚本文本作为 `rawInput` 携带。结果继续使用 generic 卡片。 @@ -78,3 +82,4 @@ Use the tool ONLY when the user explicitly asks for a workflow or for - **父级轮次会阻塞到整个工作流结算**:没有后台启动/轮询接口,取消会把局部输出作为错误丢弃。 - **`args` 必须是对象,Native 结果文本有界**:调用方把顶层数组/标量包装到字段中;规范工作流结果保持完整,超过 `maxResultChars` 的 JSON 会在面向模型的投影中截断,而不是存储在检索句柄背后。 - **每次工具注册的工作流策略固定**:提供方选择、上限和工具名称属于部署配置,不是模型调用参数。 +- **持久记录只覆盖顶层且只供观察**:嵌套 Code Mode dispatch 不记录;记录故障会刻意退化为不完整前缀,而不改变执行。 diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index 705e4f6e08..dc1c1be709 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -15,12 +15,17 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", @@ -28,6 +33,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-workflow": "^0.0.1", diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index 6c1e9b19bb..b815a776c8 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -15,8 +15,15 @@ import z from 'schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { JsonValue } from '@deepseek-ai/dsh-session' -import type { WorkflowResult, WorkflowRun } from '@deepseek-ai/dsh-workflow' +import type { JsonValue, Session, SessionEventMap } from '@deepseek-ai/dsh-session' +import type { + WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowResult, WorkflowRun, + WorkflowRunId, WorkflowRunInfo, WorkflowStopReason, +} from '@deepseek-ai/dsh-workflow' +import type { + ToolWorkflowAgentEndData, ToolWorkflowAgentStartData, + ToolWorkflowRunEndData, ToolWorkflowRunStartData, +} from './types.ts' // Declaration merge only: makes ctx.systemPrompt visible for the section registration. import type {} from '@deepseek-ai/dsh-system-prompt' @@ -38,6 +45,114 @@ export const Config: z = z.object({ type ResolvedConfig = Required +type BufferedWorkflowEvent = + | { readonly kind: 'agent-start'; readonly info: WorkflowRunInfo; readonly agent: WorkflowAgentInfo } + | { readonly kind: 'agent-end'; readonly info: WorkflowRunInfo; readonly agent: WorkflowAgentEndInfo } + +interface WorkflowRecorder { + bind(run: WorkflowRun): void + finish(stopReason: WorkflowStopReason): void + dispose(): void +} + +interface ToolWorkflowRecordEventMap { + 'tool-workflow/run-start': ToolWorkflowRunStartData + 'tool-workflow/agent-start': ToolWorkflowAgentStartData + 'tool-workflow/agent-end': ToolWorkflowAgentEndData + 'tool-workflow/run-end': ToolWorkflowRunEndData +} + +/** Render a contained recording failure without trusting the thrown value. */ +function renderRecordingError(error: unknown): string { + try { + return String(error) + } catch { + return '[unrenderable thrown value]' + } +} + +/** + * Project one top-level workflow run into its parent Session without letting + * recording failure affect tool execution. Listeners are installed before + * `start()` so even a synchronous provider cannot outrun the recorder. + */ +function createWorkflowRecorder(ctx: Context, session: Session): WorkflowRecorder { + let runId: WorkflowRunId | undefined + let enabled = true + const buffered: BufferedWorkflowEvent[] = [] + // These four package-owned events are all log-only. Narrowing the generic + // append face here lets TypeScript discharge Session.append's conditional + // surface-options tuple once for the complete closed event set. + const appendRecord = session.append.bind(session) as ( + type: Type, + data: SessionEventMap[Type], + ) => void + + const append = ( + type: Type, + data: SessionEventMap[Type], + ): void => { + if (!enabled) return + try { + appendRecord(type, data) + } catch (error: unknown) { + enabled = false + ctx.logger.warn(`tool-workflow: disabled durable record after ${type} append failed: ${renderRecordingError(error)}`) + } + } + + const record = (event: BufferedWorkflowEvent): void => { + if (runId === undefined) { + buffered.push(event) + return + } + if (event.info.id !== runId) return + if (event.kind === 'agent-start') { + const data: ToolWorkflowAgentStartData = { + runId, + seq: event.agent.seq, + label: event.agent.label, + ...event.agent.phase === undefined ? {} : { phase: event.agent.phase }, + childId: event.agent.childId, + } + append('tool-workflow/agent-start', data) + return + } + const data: ToolWorkflowAgentEndData = { + runId, + seq: event.agent.seq, + outcome: event.agent.outcome, + } + append('tool-workflow/agent-end', data) + } + + const disposeStart = ctx.on('workflow/agent-start', (info, agent) => { + record({ kind: 'agent-start', info, agent }) + }) + const disposeEnd = ctx.on('workflow/agent-end', (info, agent) => { + record({ kind: 'agent-end', info, agent }) + }) + + return { + bind(run) { + runId = run.id + append('tool-workflow/run-start', { runId, name: run.meta.name }) + for (const event of buffered) record(event) + buffered.length = 0 + }, + finish(stopReason) { + /* v8 ignore next -- execute binds every returned run before result settlement can call finish. */ + if (runId === undefined) return + append('tool-workflow/run-end', { runId, stopReason }) + }, + dispose() { + disposeStart() + disposeEnd() + buffered.length = 0 + }, + } +} + /** * The script-authoring contract, embedded in the tool description. This IS the * model-facing spec: the meta block, the hooks and their exact semantics, and @@ -188,13 +303,23 @@ export function apply(ctx: Context, config: Config): void { // Meta/body validation failures (META_INVALID/SCRIPT_PARSE) throw // synchronously here and become isError results via the registry — the // model sees the violation list and can correct the call. - const run: WorkflowRun = ctx.workflows.start({ - script: args.script, - meta: args.meta, - ...args.args !== undefined ? { args: args.args } : {}, - parent, - signal: exec.signal, - }) + const recorder = exec.parent === undefined + ? createWorkflowRecorder(ctx, parent.session) + : undefined + let run: WorkflowRun + try { + run = ctx.workflows.start({ + script: args.script, + meta: args.meta, + ...args.args !== undefined ? { args: args.args } : {}, + parent, + signal: exec.signal, + }) + } catch (error: unknown) { + recorder?.dispose() + throw error + } + recorder?.bind(run) // Bridge the tool's abort signal to the run: if the parent step is aborted while the // script is in flight, cancel the whole run. The signal also enters the engine directly, but @@ -202,8 +327,9 @@ export function apply(ctx: Context, config: Config): void { const onAbort = (): void => { run.cancel('parent step aborted') } exec.signal.addEventListener('abort', onAbort, { once: true }) + let result: WorkflowResult | undefined try { - const result = await run.result + result = await run.result const error = stopReasonError(result) if (error !== undefined) { // Map a non-clean finish to an isError result (the registry turns a @@ -217,8 +343,15 @@ export function apply(ctx: Context, config: Config): void { } } finally { exec.signal.removeEventListener('abort', onAbort) - // Always reach run quiescence — never leak a live script or children. - await run.dispose() + try { + // Keep member listeners alive through disposal: an engine may + // synthesize cancelled member endings while reaching quiescence. + await run.dispose() + /* v8 ignore next -- WorkflowRun.result never rejects by contract, so result is assigned before finally. */ + if (result !== undefined) recorder?.finish(result.stopReason) + } finally { + recorder?.dispose() + } } }, presentCall: args => presentWorkflowCall(args), diff --git a/packages/workflow/tool-workflow/src/invariant.ts b/packages/workflow/tool-workflow/src/invariant.ts index 5f3ebc68ce..5fb14908ca 100644 --- a/packages/workflow/tool-workflow/src/invariant.ts +++ b/packages/workflow/tool-workflow/src/invariant.ts @@ -1,30 +1,158 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tool-workflow`. - * @module @deepseek-ai/dsh-tool-workflow/invariant - */ +/** Package-owned durable workflow-record invariants. @module @deepseek-ai/dsh-tool-workflow/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type {} from './types.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-workflow' /** Cordis companion plugin name. */ export const name = 'tool-workflow-invariant' -/** Service required before the companion can reserve package ownership. */ +/** Services required to validate existing and newly appended Session logs. */ export const inject = ['invariants'] -/** - * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution - * relations are owned by the capability seam it calls. - */ -const install: InvariantInstaller = () => {} +interface RunTrace { + ended: boolean + readonly members: Map +} -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ +type WorkflowTrace = Map + +/** Clone the independent fold before validating one candidate append. */ +function cloneTrace(source: WorkflowTrace): WorkflowTrace { + return new Map([...source].map(([runId, run]) => [runId, { + ended: run.ended, + members: new Map(run.members), + }])) +} + +/** Require a durable opaque identity to be a non-empty string. */ +function stringId(value: unknown, label: string, fail: InvariantFailure): string { + if (typeof value !== 'string' || value.length === 0) fail(`${label} must be a non-empty string`) + return value +} + +/** Require one workflow member's 1-based sequence identity. */ +function memberSeq(value: unknown, fail: InvariantFailure): number { + if (!Number.isSafeInteger(value) || (value as number) < 1) { + fail('tool-workflow member seq must be a positive safe integer') + } + return value as number +} + +/** Read one plain payload field without trusting restored plugin data. */ +function recordOf(event: SessionEvent, fail: InvariantFailure): Record { + const data: unknown = event.data + if (data === null || typeof data !== 'object' || Array.isArray(data)) { + fail(`${event.type} data must be a JSON object`) + } + return data as Record +} + +/** Require the named run to exist and remain open. */ +function openRun(trace: WorkflowTrace, runId: string, eventType: string, fail: InvariantFailure): RunTrace { + const run = trace.get(runId) + if (run === undefined) fail(`${eventType} has no matching tool-workflow/run-start for run ${runId}`) + if (run.ended) fail(`${eventType} appears after tool-workflow/run-end for run ${runId}`) + return run +} + +/** Advance the workflow-record fold with one relevant Session event. */ +function applyEvent(trace: WorkflowTrace, event: SessionEvent, fail: InvariantFailure): void { + if (!event.type.startsWith('tool-workflow/')) return + const data = recordOf(event, fail) + const runId = stringId(data.runId, `${event.type} runId`, fail) + + switch (event.type) { + case 'tool-workflow/run-start': { + if (typeof data.name !== 'string' || data.name.length === 0) { + fail('tool-workflow/run-start name must be a non-empty string') + } + if (trace.has(runId)) fail(`tool-workflow/run-start repeats run ${runId}`) + trace.set(runId, { ended: false, members: new Map() }) + return + } + case 'tool-workflow/agent-start': { + const run = openRun(trace, runId, event.type, fail) + const seq = memberSeq(data.seq, fail) + if (typeof data.label !== 'string') fail('tool-workflow/agent-start label must be a string') + if (data.phase !== undefined && typeof data.phase !== 'string') { + fail('tool-workflow/agent-start phase must be a string when present') + } + stringId(data.childId, 'tool-workflow/agent-start childId', fail) + if (run.members.has(seq)) fail(`tool-workflow/agent-start repeats member seq ${seq} in run ${runId}`) + run.members.set(seq, false) + return + } + case 'tool-workflow/agent-end': { + const run = openRun(trace, runId, event.type, fail) + const seq = memberSeq(data.seq, fail) + if (data.outcome !== 'completed' && data.outcome !== 'failed' && data.outcome !== 'cancelled') { + fail(`tool-workflow/agent-end outcome ${String(data.outcome)} is invalid`) + } + const ended = run.members.get(seq) + if (ended === undefined) fail(`tool-workflow/agent-end has no matching member seq ${seq} in run ${runId}`) + if (ended) fail(`tool-workflow/agent-end repeats member seq ${seq} in run ${runId}`) + run.members.set(seq, true) + return + } + case 'tool-workflow/run-end': { + const run = openRun(trace, runId, event.type, fail) + if (data.stopReason !== 'completed' && data.stopReason !== 'cancelled' && data.stopReason !== 'error') { + fail(`tool-workflow/run-end stopReason ${String(data.stopReason)} is invalid`) + } + const openMembers = [...run.members].filter(([, ended]) => !ended).map(([seq]) => seq) + if (openMembers.length > 0) { + fail(`tool-workflow/run-end leaves member seq ${openMembers.join(', ')} open in run ${runId}`) + } + run.ended = true + return + } + default: + fail(`unknown tool-workflow event type ${event.type}`) + } +} + +/** Apply one cold-load or live-append candidate through the package reporter. */ +function applyChecked(trace: WorkflowTrace, event: SessionEvent, fail: InvariantFailure): void { + applyEvent(trace, event, fail) +} + +/** Install an independent incremental fold over every attached Session. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + const traces = new WeakMap() + const staged = new WeakMap() + + const seed = (session: Session): WorkflowTrace => { + const trace: WorkflowTrace = new Map() + for (const event of session.events) applyChecked(trace, event, fail) + traces.set(session, trace) + return trace + } + /* v8 ignore next -- session/event always follows list() or session/created seeding. */ + const traceFor = (session: Session): WorkflowTrace => traces.get(session) ?? seed(session) + + for (const session of ctx.sessions.list()) seed(session) + ctx.on('session/created', (session) => { seed(session) }, { global: true }) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const [session, event] = args as [Session, SessionEvent] + const trace = cloneTrace(traceFor(session)) + applyChecked(trace, event, fail) + staged.set(event, { session, trace }) + }, { global: true }) + ctx.on('session/event', (session, event) => { + const candidate = staged.get(event) + /* v8 ignore next 2 -- internal/dispatch stages the exact session/event callback arguments. */ + if (candidate === undefined || candidate.session !== session) { + return fail('session/event reached publication without matching workflow-record validation') + } + staged.delete(event) + traces.set(session, candidate.trace) + }, { global: true }) +}, { inject: ['sessions'] }) + +/** 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/workflow/tool-workflow/src/types.ts b/packages/workflow/tool-workflow/src/types.ts new file mode 100644 index 0000000000..c184404939 --- /dev/null +++ b/packages/workflow/tool-workflow/src/types.ts @@ -0,0 +1,64 @@ +/** + * Browser-safe durable workflow-record events written by the model-facing + * workflow tool into its calling parent Session. + * + * @module @deepseek-ai/dsh-tool-workflow/types + */ + +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { + WorkflowAgentOutcome, WorkflowRunId, WorkflowStopReason, +} from '@deepseek-ai/dsh-workflow/types' + +/** Opens one durable top-level workflow run record. */ +export interface ToolWorkflowRunStartData { + readonly runId: WorkflowRunId + readonly name: string +} + +/** Records one workflow member after its child Session is published. */ +export interface ToolWorkflowAgentStartData { + readonly runId: WorkflowRunId + readonly seq: number + readonly label: string + readonly phase?: string + readonly childId: SessionId +} + +/** Settles one previously started workflow member. */ +export interface ToolWorkflowAgentEndData { + readonly runId: WorkflowRunId + readonly seq: number + readonly outcome: WorkflowAgentOutcome +} + +/** Settles one workflow run after its live resources reach quiescence. */ +export interface ToolWorkflowRunEndData { + readonly runId: WorkflowRunId + readonly stopReason: WorkflowStopReason +} + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** + * Opens one top-level workflow record. + * @param data - stable run identity and display name. + */ + 'tool-workflow/run-start': ToolWorkflowRunStartData + /** + * Records one published workflow member. + * @param data - run identity, member sequence, display identity, and child Session. + */ + 'tool-workflow/agent-start': ToolWorkflowAgentStartData + /** + * Records one member settlement. + * @param data - run identity, paired member sequence, and outcome. + */ + 'tool-workflow/agent-end': ToolWorkflowAgentEndData + /** + * Closes one workflow record after cleanup. + * @param data - stable run identity and terminal reason. + */ + 'tool-workflow/run-end': ToolWorkflowRunEndData + } +} diff --git a/packages/workflow/tool-workflow/tests/invariant.spec.ts b/packages/workflow/tool-workflow/tests/invariant.spec.ts new file mode 100644 index 0000000000..11d2fe94e7 --- /dev/null +++ b/packages/workflow/tool-workflow/tests/invariant.spec.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants' +import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' +import { WorkflowRunId, type WorkflowRunId as WorkflowRunIdType } from '@deepseek-ai/dsh-workflow/types' +import * as ToolWorkflowInvariant from '../src/invariant.ts' +import type {} from '../src/types.ts' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService, { enabled: true }) + await ctx.plugin(ToolWorkflowInvariant) + return ctx +} + +describe('durable workflow-record invariants', () => { + it('accepts interleaved complete runs and an unfinished continuous prefix', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('workflow-record-valid')) + session.append('turn/start', { turn: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const first = WorkflowRunId('first') + const second = WorkflowRunId('second') + const third = WorkflowRunId('third') + session.append('tool-workflow/run-start', { runId: first, name: 'first' }) + session.append('tool-workflow/run-start', { runId: second, name: 'second' }) + session.append('tool-workflow/agent-start', { + runId: second, seq: 1, label: '', phase: '', childId: SessionId('child'), + }) + session.append('tool-workflow/run-end', { runId: first, stopReason: 'completed' }) + session.append('tool-workflow/agent-end', { runId: second, seq: 1, outcome: 'cancelled' }) + session.append('tool-workflow/run-end', { runId: second, stopReason: 'cancelled' }) + session.append('tool-workflow/run-start', { runId: third, name: 'third' }) + session.append('tool-workflow/agent-start', { + runId: third, seq: 1, label: 'failed', childId: SessionId('failed-child'), + }) + session.append('tool-workflow/agent-end', { runId: third, seq: 1, outcome: 'failed' }) + session.append('tool-workflow/run-end', { runId: third, stopReason: 'error' }) + session.append('tool-workflow/run-start', { runId: WorkflowRunId('prefix'), name: 'prefix' }) + expect(() => session.append('tool-workflow/agent-start', { + runId: WorkflowRunId('prefix'), seq: 1, label: 'open', childId: SessionId('open-child'), + })).not.toThrow() + }) + + it('rejects a malformed candidate before commit and keeps the fold reusable', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('workflow-record-invalid')) + const runId = WorkflowRunId('run') + session.append('tool-workflow/run-start', { runId, name: 'run' }) + const before = session.seq + expect(() => session.append('tool-workflow/agent-end', { + runId, seq: 1, outcome: 'completed', + })).toThrow(expect.objectContaining>({ + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-tool-workflow', + })) + expect(session.seq).toBe(before) + expect(() => session.append('tool-workflow/run-end', { + runId, stopReason: 'completed', + })).not.toThrow() + }) + + type Mutation = (session: Session, runId: WorkflowRunIdType) => void + const appendRaw = (session: Session, type: string, data: unknown): void => { + const append = session.append.bind(session) as (eventType: string, eventData: unknown) => unknown + append(type, data) + } + const invalidCases: readonly [string, Mutation, RegExp][] = [ + ['null event data', (session) => { + appendRaw(session, 'tool-workflow/run-start', null) + }, /data must be a JSON object/], + ['primitive event data', (session) => { + appendRaw(session, 'tool-workflow/run-start', 1) + }, /data must be a JSON object/], + ['array event data', (session) => { + appendRaw(session, 'tool-workflow/run-start', []) + }, /data must be a JSON object/], + ['numeric run id', (session) => { + session.append('tool-workflow/agent-start', { + runId: 1 as never, seq: 1, label: 'bad', childId: SessionId('child'), + }) + }, /runId must be a non-empty string/], + ['empty run id', (session) => { + session.append('tool-workflow/agent-start', { + runId: WorkflowRunId(''), seq: 1, label: 'bad', childId: SessionId('child'), + }) + }, /runId must be a non-empty string/], + ['empty run name', (session) => { + session.append('tool-workflow/run-start', { runId: WorkflowRunId('empty-name'), name: '' }) + }, /name must be a non-empty string/], + ['non-string run name', (session) => { + session.append('tool-workflow/run-start', { runId: WorkflowRunId('bad-name'), name: 1 as never }) + }, /name must be a non-empty string/], + ['duplicate run', (session, runId) => { + session.append('tool-workflow/run-start', { runId, name: 'again' }) + }, /repeats run/], + ['missing run', (session) => { + session.append('tool-workflow/agent-start', { + runId: WorkflowRunId('missing'), seq: 1, label: 'bad', childId: SessionId('child'), + }) + }, /no matching tool-workflow\/run-start/], + ['non-positive member seq', (session, runId) => { + session.append('tool-workflow/agent-start', { + runId, seq: 0, label: 'bad', childId: SessionId('child'), + }) + }, /positive safe integer/], + ['non-integer member seq', (session, runId) => { + session.append('tool-workflow/agent-start', { + runId, seq: 1.5, label: 'bad', childId: SessionId('child'), + }) + }, /positive safe integer/], + ['non-string member label', (session, runId) => { + session.append('tool-workflow/agent-start', { + runId, seq: 1, label: 1 as never, childId: SessionId('child'), + }) + }, /label must be a string/], + ['non-string member phase', (session, runId) => { + session.append('tool-workflow/agent-start', { + runId, seq: 1, label: 'bad', phase: 1 as never, childId: SessionId('child'), + }) + }, /phase must be a string/], + ['empty child id', (session, runId) => { + session.append('tool-workflow/agent-start', { + runId, seq: 1, label: 'bad', childId: SessionId(''), + }) + }, /childId must be a non-empty string/], + ['duplicate member start', (session, runId) => { + session.append('tool-workflow/agent-start', { + runId, seq: 1, label: 'one', childId: SessionId('child'), + }) + session.append('tool-workflow/agent-start', { + runId, seq: 1, label: 'two', childId: SessionId('child-2'), + }) + }, /repeats member seq/], + ['invalid member outcome', (session, runId) => { + session.append('tool-workflow/agent-start', { + runId, seq: 1, label: 'one', childId: SessionId('child'), + }) + session.append('tool-workflow/agent-end', { runId, seq: 1, outcome: 'unknown' as never }) + }, /outcome unknown is invalid/], + ['duplicate member end', (session, runId) => { + session.append('tool-workflow/agent-start', { + runId, seq: 1, label: 'one', childId: SessionId('child'), + }) + session.append('tool-workflow/agent-end', { runId, seq: 1, outcome: 'completed' }) + session.append('tool-workflow/agent-end', { runId, seq: 1, outcome: 'completed' }) + }, /repeats member seq/], + ['run end with an open member', (session, runId) => { + session.append('tool-workflow/agent-start', { + runId, seq: 1, label: 'open', childId: SessionId('child'), + }) + session.append('tool-workflow/run-end', { runId, stopReason: 'completed' }) + }, /leaves member seq 1 open/], + ['invalid run stop reason', (session, runId) => { + session.append('tool-workflow/run-end', { runId, stopReason: 'unknown' as never }) + }, /stopReason unknown is invalid/], + ['event after run end', (session, runId) => { + session.append('tool-workflow/run-end', { runId, stopReason: 'completed' }) + session.append('tool-workflow/agent-start', { + runId, seq: 1, label: 'late', childId: SessionId('child'), + }) + }, /appears after/], + ['unknown workflow event', (session, runId) => { + appendRaw(session, 'tool-workflow/unknown', { runId }) + }, /unknown tool-workflow event type/], + ] + + it.each(invalidCases)('rejects %s', async (_name, mutate, pattern) => { + const ctx = await setup() + const session = ctx.sessions.create() + const runId = WorkflowRunId('run') + session.append('tool-workflow/run-start', { runId, name: 'run' }) + expect(() => { mutate(session, runId) }).toThrow(pattern) + }) + + it('validates existing cold history while allowing an unfinished prefix', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const valid = ctx.sessions.create(SessionId('workflow-record-cold-valid')) + valid.append('tool-workflow/run-start', { runId: WorkflowRunId('valid'), name: 'valid' }) + valid.append('tool-workflow/agent-start', { + runId: WorkflowRunId('valid'), seq: 1, label: 'open', childId: SessionId('child'), + }) + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(ToolWorkflowInvariant)).resolves.toBeDefined() + + const brokenCtx = new Context() + await brokenCtx.plugin(SessionStore) + const broken = brokenCtx.sessions.create(SessionId('workflow-record-cold-invalid')) + broken.append('tool-workflow/run-start', { runId: WorkflowRunId('broken'), name: 'broken' }) + broken.append('tool-workflow/run-end', { runId: WorkflowRunId('broken'), stopReason: 'completed' }) + broken.append('tool-workflow/agent-start', { + runId: WorkflowRunId('broken'), seq: 1, label: 'late', childId: SessionId('late'), + }) + await brokenCtx.plugin(InvariantService, { enabled: true }) + await expect(brokenCtx.plugin(ToolWorkflowInvariant)).rejects.toThrow(/appears after/) + }) +}) diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index a61862cffd..ab1fd05a8d 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -3,15 +3,18 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' -import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import type { ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow' -import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow' +import type { + WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowResult, WorkflowRun, + WorkflowRunId as WorkflowRunIdType, WorkflowStartRequest, +} from '@deepseek-ai/dsh-workflow' import { CallId } from '@deepseek-ai/dsh-llm' import SubagentService from '@deepseek-ai/dsh-subagent' import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' import * as toolWorkflow from '../src/index.ts' -import { SessionId } from '@deepseek-ai/dsh-session' +import { Session, SessionId } from '@deepseek-ai/dsh-session' const testToolSignal = new AbortController().signal @@ -20,30 +23,62 @@ class StubEngine extends WorkflowService { requests: WorkflowStartRequest[] = [] cancels: string[] = [] disposed = 0 + disposeBarrier: Promise | undefined settle!: (result: WorkflowResult) => void + readonly settlements = new Map void>() startError: Error | undefined + emitMemberDuringStart = false start(request: WorkflowStartRequest): WorkflowRun { if (this.startError) throw this.startError this.requests.push(request) + const id = WorkflowRunId(`run-${this.requests.length}`) const result = new Promise((resolve) => { this.settle = resolve }) + this.settlements.set(id, this.settle) + if (this.emitMemberDuringStart) { + const info = { id, meta: request.meta } + const member = { seq: 1, label: 'synchronous', childId: SessionId('sync-child') } + this.emitWorkflowEvent('workflow/agent-start', info, member) + this.emitWorkflowEvent('workflow/agent-end', info, { ...member, outcome: 'completed' }) + } request.signal?.addEventListener('abort', () => { this.settle({ value: null, stopReason: 'cancelled', error: 'signal', agentsStarted: 0 }) }, { once: true }) return { - id: WorkflowRunId('run-1'), - meta: { name: 'stub-flow', description: 'd' }, + id, + meta: request.meta, result, cancel: (reason?: string) => { this.cancels.push(reason ?? 'cancelled') this.settle({ value: null, stopReason: 'cancelled', ...reason !== undefined ? { error: reason } : {}, agentsStarted: 0 }) }, - dispose: () => { + dispose: async () => { this.disposed += 1 - return Promise.resolve() + await this.disposeBarrier + this.settlements.delete(id) }, } } + + settleRun(id: WorkflowRunIdType, result: WorkflowResult): void { + const settle = this.settlements.get(id) + if (settle === undefined) throw new Error(`unknown stub workflow ${id}`) + settle(result) + } + + agentStart(id: WorkflowRunIdType, agent: WorkflowAgentInfo): void { + this.emitWorkflowEvent('workflow/agent-start', { + id, + meta: this.requests[Number(String(id).slice(4)) - 1]!.meta, + }, agent) + } + + agentEnd(id: WorkflowRunIdType, agent: WorkflowAgentEndInfo): void { + this.emitWorkflowEvent('workflow/agent-end', { + id, + meta: this.requests[Number(String(id).slice(4)) - 1]!.meta, + }, agent) + } } async function setup(config?: { toolName?: string; maxResultChars?: number }) { @@ -53,14 +88,19 @@ async function setup(config?: { toolName?: string; maxResultChars?: number }) { await ctx.plugin(StubEngine) await ctx.plugin(toolWorkflow, config ?? {}) const engine = ctx.workflows as StubEngine - const parent = { id: SessionId('caller'), options: {} } as unknown as Agent - return { ctx, engine, parent } + const session = Session.create(SessionId('caller')) + const parent = { id: session.id, options: {}, session } as unknown as Agent + return { ctx, engine, parent, session } } const SCRIPT = 'return 1' const META = { name: 'audit', description: 'd' } -function execute(ctx: Context, args: unknown, extra?: { agent?: Agent; signal?: AbortSignal }): Promise { +function execute(ctx: Context, args: unknown, extra?: { + agent?: Agent + signal?: AbortSignal + parent?: ToolExecutionToken +}): Promise { return ctx.tools.execute({ signal: testToolSignal, callId: CallId('call-1'), @@ -68,6 +108,7 @@ function execute(ctx: Context, args: unknown, extra?: { agent?: Agent; signal?: arguments: args, ...extra?.agent ? { agent: extra.agent } : {}, ...extra?.signal ? { signal: extra.signal } : {}, + ...extra?.parent ? { parent: extra.parent } : {}, }) } @@ -90,6 +131,167 @@ describe('dsh-tool-workflow', () => { expect(engine.disposed).toBe(1) }) + it('records one top-level run and its members in the calling Session after cleanup', async () => { + const { ctx, engine, parent, session } = await setup() + const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) }) + const runId = WorkflowRunId('run-1') + engine.agentStart(runId, { + seq: 1, + label: '', + phase: '', + childId: SessionId('child-1'), + }) + engine.agentEnd(runId, { + seq: 1, + label: '', + phase: '', + childId: SessionId('child-1'), + outcome: 'completed', + }) + engine.settleRun(runId, { value: 1, stopReason: 'completed', agentsStarted: 1 }) + expect((await pending).isError).toBe(false) + expect(engine.disposed).toBe(1) + expect(session.events.map(event => [event.type, event.data])).toEqual([ + ['tool-workflow/run-start', { runId: 'run-1', name: 'audit' }], + ['tool-workflow/agent-start', { + runId: 'run-1', seq: 1, label: '', phase: '', childId: 'child-1', + }], + ['tool-workflow/agent-end', { runId: 'run-1', seq: 1, outcome: 'completed' }], + ['tool-workflow/run-end', { runId: 'run-1', stopReason: 'completed' }], + ]) + }) + + it('writes run-end only after run disposal reaches quiescence', async () => { + const { ctx, engine, parent, session } = await setup() + const barrier = Promise.withResolvers() + engine.disposeBarrier = barrier.promise + const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) }) + engine.settleRun(WorkflowRunId('run-1'), { + value: null, stopReason: 'completed', agentsStarted: 0, + }) + await vi.waitFor(() => { expect(engine.disposed).toBe(1) }) + expect(session.events.map(event => event.type)).toEqual(['tool-workflow/run-start']) + barrier.resolve(undefined) + expect((await pending).isError).toBe(false) + expect(session.events.map(event => event.type)).toEqual([ + 'tool-workflow/run-start', 'tool-workflow/run-end', + ]) + }) + + it('records zero-member and concurrent runs independently', async () => { + const { ctx, engine, parent, session } = await setup() + const first = execute(ctx, { script: SCRIPT, meta: { ...META, name: 'first' } }, { agent: parent }) + const second = execute(ctx, { script: SCRIPT, meta: { ...META, name: 'second' } }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(2) }) + const secondId = WorkflowRunId('run-2') + engine.agentStart(secondId, { + seq: 1, label: 'member', childId: SessionId('child-2'), + }) + engine.agentEnd(secondId, { + seq: 1, label: 'member', childId: SessionId('child-2'), outcome: 'failed', + }) + engine.settleRun(WorkflowRunId('run-1'), { value: null, stopReason: 'completed', agentsStarted: 0 }) + engine.settleRun(secondId, { value: null, stopReason: 'error', error: 'child failed', agentsStarted: 1 }) + expect((await first).isError).toBe(false) + expect((await second).isError).toBe(true) + expect(session.events.filter(event => event.type === 'tool-workflow/agent-start')) + .toHaveLength(1) + expect(session.events.filter(event => event.type === 'tool-workflow/run-end').map(event => event.data)) + .toEqual([ + { runId: 'run-1', stopReason: 'completed' }, + { runId: 'run-2', stopReason: 'error' }, + ]) + }) + + it('buffers synchronous member events until start returns the run identity', async () => { + const { ctx, engine, parent, session } = await setup() + engine.emitMemberDuringStart = true + const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) }) + engine.settleRun(WorkflowRunId('run-1'), { + value: null, stopReason: 'completed', agentsStarted: 1, + }) + expect((await pending).isError).toBe(false) + expect(session.events.map(event => event.type)).toEqual([ + 'tool-workflow/run-start', + 'tool-workflow/agent-start', + 'tool-workflow/agent-end', + 'tool-workflow/run-end', + ]) + }) + + it('does not record nested transport executions', async () => { + const { ctx, engine, parent, session } = await setup() + const pending = execute(ctx, { script: SCRIPT, meta: META }, { + agent: parent, + parent: Symbol('outer') as ToolExecutionToken, + }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) }) + engine.settleRun(WorkflowRunId('run-1'), { value: null, stopReason: 'completed', agentsStarted: 0 }) + expect((await pending).isError).toBe(false) + expect(session.events).toEqual([]) + }) + + it.each([ + 'tool-workflow/run-start', + 'tool-workflow/agent-start', + 'tool-workflow/agent-end', + 'tool-workflow/run-end', + ] as const)('isolates a first append failure at %s and preserves a valid prefix', async (failedType) => { + const { ctx, engine, parent, session } = await setup() + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + const append = session.append.bind(session) + session.append = ((type: Parameters[0], data: never) => { + if (type === failedType) throw new Error(`injected ${failedType} failure`) + return append(type, data) + }) as Session['append'] + + const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) }) + const runId = WorkflowRunId('run-1') + engine.agentStart(runId, { + seq: 1, label: 'member', childId: SessionId('child-1'), + }) + engine.agentEnd(runId, { + seq: 1, label: 'member', childId: SessionId('child-1'), outcome: 'completed', + }) + engine.settleRun(runId, { value: null, stopReason: 'completed', agentsStarted: 1 }) + expect((await pending).isError).toBe(false) + expect(engine.disposed).toBe(1) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain(failedType) + const types = session.events.map(event => event.type) + const expectedPrefixes = { + 'tool-workflow/run-start': [], + 'tool-workflow/agent-start': ['tool-workflow/run-start'], + 'tool-workflow/agent-end': ['tool-workflow/run-start', 'tool-workflow/agent-start'], + 'tool-workflow/run-end': [ + 'tool-workflow/run-start', 'tool-workflow/agent-start', 'tool-workflow/agent-end', + ], + } as const + expect(types).toEqual(expectedPrefixes[failedType]) + }) + + it('contains an append failure whose thrown value cannot be rendered', async () => { + const { ctx, engine, parent, session } = await setup() + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + session.append = () => { + throw { toString: () => { throw new Error('coercion trap') } } + } + const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) }) + engine.settleRun(WorkflowRunId('run-1'), { + value: null, stopReason: 'completed', agentsStarted: 0, + }) + expect((await pending).isError).toBe(false) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain('[unrenderable thrown value]') + }) + it('maps a non-completed stop reason to an isError result (and still disposes)', async () => { const { ctx, engine, parent } = await setup() const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent }) @@ -251,7 +453,8 @@ describe('dsh-tool-workflow', () => { }) await ctx.plugin(WorkerWorkflowEngine, { disposeGraceMs: 30 }) await ctx.plugin(toolWorkflow, {}) - const parent = { id: SessionId('caller'), options: {} } as unknown as Agent + const session = Session.create(SessionId('caller')) + const parent = { id: session.id, options: {}, session } as unknown as Agent const controller = new AbortController() const pending = execute(ctx, { script: 'await new Promise(() => {})\nreturn 1', diff --git a/packages/workflow/tool-workflow/tsconfig.json b/packages/workflow/tool-workflow/tsconfig.json index c08ae597f2..1344946d35 100644 --- a/packages/workflow/tool-workflow/tsconfig.json +++ b/packages/workflow/tool-workflow/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../core/agent" }, + { + "path": "../../core/session" + }, { "path": "../../llm/llm" }, diff --git a/packages/workflow/workflow/README.i18n.yaml b/packages/workflow/workflow/README.i18n.yaml index e56067bf2d..4650b30acd 100644 --- a/packages/workflow/workflow/README.i18n.yaml +++ b/packages/workflow/workflow/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/workflow/workflow/README.md -README.md: 0de661423206cc71eb4669bc8ddb2419202bcb4a -README.zh.md: 62abd00c013d054f4111a2db2ce72c58d3514087 +README.md: f1b101159e656d7d76812c95c020fe6b3f48115e +README.zh.md: 6d85c3b7e847b8c6176d4c1938805c22c543678b diff --git a/packages/workflow/workflow/README.md b/packages/workflow/workflow/README.md index 0de6614232..f1b101159e 100644 --- a/packages/workflow/workflow/README.md +++ b/packages/workflow/workflow/README.md @@ -6,6 +6,8 @@ The workflow seam (`ctx.workflows`) executes a model-written orchestration scrip `@deepseek-ai/dsh-workflow-workerthread` is the current engine and `@deepseek-ai/dsh-tool-workflow` is the model-facing consumer. A future process or sandbox engine can replace the implementation without changing the tool. +The package root is the Host face. The browser-safe `@deepseek-ai/dsh-workflow/types` subpath contains run identities, metadata, results, and observe-only lifecycle payloads without importing `Agent`, Cordis services, or Host context declarations; Host-only `WorkflowStartRequest` and `WorkflowRun` live behind the package root. + ## Service and run contract `WorkflowService.start(request): WorkflowRun` validates enough synchronously to reject a malformed meta block, unparseable script, unavailable provider route, or unsupported per-run limit before a run exists. Once returned, `WorkflowRun.result` never rejects: execution failures resolve with `stopReason: 'error'`, and cancellation resolves with `cancelled` within the engine's bounded grace. diff --git a/packages/workflow/workflow/README.zh.md b/packages/workflow/workflow/README.zh.md index 62abd00c01..6d85c3b7e8 100644 --- a/packages/workflow/workflow/README.zh.md +++ b/packages/workflow/workflow/README.zh.md @@ -6,6 +6,8 @@ `@deepseek-ai/dsh-workflow-workerthread` 是当前引擎,`@deepseek-ai/dsh-tool-workflow` 是面向模型的消费方。未来的进程或沙箱引擎可以替换实现,而无需更改工具。 +包根是 Host face。浏览器安全的 `@deepseek-ai/dsh-workflow/types` 子路径包含运行身份、元数据、结果和仅供观察的生命周期 payload,不导入 `Agent`、Cordis service 或 Host Context 声明;Host 专用的 `WorkflowStartRequest` 与 `WorkflowRun` 只从包根提供。 + ## 服务与运行约定 `WorkflowService.start(request): WorkflowRun` 会同步完成足够多的校验,在运行创建前拒绝格式错误的 meta 块、无法解析的脚本、不可用的提供方路由或不受支持的单次运行限制。返回后,`WorkflowRun.result` 绝不拒绝:执行失败以 `stopReason: 'error'` 兑现,取消则在引擎有限的宽限时间内以 `cancelled` 兑现。 diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index 53ef7e6f6e..c916add4fe 100644 --- a/packages/workflow/workflow/package.json +++ b/packages/workflow/workflow/package.json @@ -15,12 +15,17 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", diff --git a/packages/workflow/workflow/src/index.ts b/packages/workflow/workflow/src/index.ts index 526da15ad8..e7ad0d38e1 100644 --- a/packages/workflow/workflow/src/index.ts +++ b/packages/workflow/workflow/src/index.ts @@ -10,10 +10,9 @@ import type { WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowResultInfo, - WorkflowRun, WorkflowRunInfo, - WorkflowStartRequest, } from './types.ts' +import type { WorkflowRun, WorkflowStartRequest } from './runtime-types.ts' export { WorkflowRunId } from './types.ts' export type { @@ -24,11 +23,10 @@ export type { WorkflowPhase, WorkflowResult, WorkflowResultInfo, - WorkflowRun, WorkflowRunInfo, - WorkflowStartRequest, WorkflowStopReason, } from './types.ts' +export type { WorkflowRun, WorkflowStartRequest } from './runtime-types.ts' declare module 'cordis' { interface Context { diff --git a/packages/workflow/workflow/src/runtime-types.ts b/packages/workflow/workflow/src/runtime-types.ts new file mode 100644 index 0000000000..2e3525f9c3 --- /dev/null +++ b/packages/workflow/workflow/src/runtime-types.ts @@ -0,0 +1,49 @@ +/** + * Host-only workflow request and live-run handles. The browser-safe durable + * vocabulary remains in `./types` so Client programs never import Agent or + * host Cordis context declarations. + * + * @module @deepseek-ai/dsh-workflow + */ + +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { + WorkflowMeta, WorkflowResult, WorkflowRunId, +} from './types.ts' + +/** + * What a caller asks for when starting a workflow run. `meta` and `args` are + * plain JSON data by the seam contract. `parent` is required because every + * `agent()` spawned by the script is attributed to that live Agent. + */ +export interface WorkflowStartRequest { + /** The plain-JS script body (top-level await allowed; ends with `return `). */ + script: string + /** The workflow's identity block, as plain JSON data (shape-validated by the engine). */ + meta: WorkflowMeta + /** Optional input exposed verbatim to the script as the `args` global. */ + args?: unknown + /** Optional engine-wide child-provider override for this run. */ + subagentProvider?: string + /** Optional per-run total-child ceiling. */ + maxTotalAgents?: number + /** The agent on whose behalf the run executes (parent of every child). */ + parent: Agent + /** Cancels the run when aborted. */ + signal?: AbortSignal +} + +/** + * Holder-owned live workflow. `result` never rejects; consumers may cancel + * and must call idempotent `dispose()` to await script and child quiescence. + */ +export interface WorkflowRun { + readonly id: WorkflowRunId + /** The validated meta block available before the script body runs. */ + readonly meta: WorkflowMeta + readonly result: Promise + /** Cancel the run and its children. */ + cancel(reason?: string): void + /** Cancel if needed and await bounded settlement and cleanup. */ + dispose(): Promise +} diff --git a/packages/workflow/workflow/src/types.ts b/packages/workflow/workflow/src/types.ts index bdf933a3f7..52a0bac785 100644 --- a/packages/workflow/workflow/src/types.ts +++ b/packages/workflow/workflow/src/types.ts @@ -7,8 +7,7 @@ */ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session/types' /** Identifies one workflow run. */ export type WorkflowRunId = Branded<'WorkflowRunId'> @@ -55,38 +54,6 @@ export interface WorkflowMeta { phases?: WorkflowPhase[] } -/** - * What a caller asks for when starting a workflow run. `meta` and `args` are - * plain JSON DATA by the seam contract (the tool builds both from the model's schema-validated call; - * the engine validates `meta` against its schema and rejects loud - * before anything runs) — an engine never evaluates script text to obtain - * them. `parent` is REQUIRED — every `agent()` the script spawns is - * attributed to it (cwd, lineage, depth flow through the subagent seam). - */ -export interface WorkflowStartRequest { - /** The plain-JS script body (top-level await allowed; ends with `return `). */ - script: string - /** The workflow's identity fields as plain JSON data, validated by the engine. */ - meta: WorkflowMeta - /** Optional input exposed verbatim to the script as the `args` global. */ - args?: unknown - /** - * Optional engine-wide child-provider override for this run. The workflow - * script cannot observe or replace it; omission uses the engine's configured - * provider. - */ - subagentProvider?: string - /** - * Optional per-run total-child ceiling. Implementations reject values above - * their deployment ceiling before publishing the run. - */ - maxTotalAgents?: number - /** The agent on whose behalf the run executes (parent of every child). */ - parent: Agent - /** Cancels the run when aborted (the tool's `exec.signal`). */ - signal?: AbortSignal -} - /** * Why a run settled. CLOSED union (engine-owned, consumers may exhaust): * `completed` = the script ran to its final `return`; `cancelled` = the run @@ -96,7 +63,7 @@ export interface WorkflowStartRequest { export type WorkflowStopReason = 'completed' | 'cancelled' | 'error' /** - * The outcome of one run, resolved by {@link WorkflowRun.result}. `value` is + * The outcome resolved by a live workflow run. `value` is * the script's materialized return value (plain host-realm JSON data; `null` * when the script returned `undefined`) — meaningful only for `completed`. * A non-`completed` reason carries the failure in `error`; the consumer maps @@ -119,23 +86,6 @@ export interface WorkflowResult { agentsStarted: number } -/** - * Holder-owned live workflow. `result` never rejects and settles within the - * engine's cancellation grace; failures resolve through `stopReason`. Consumers - * may cancel and must call idempotent `dispose()` on every path to await bounded - * script settlement and child quiescence. - */ -export interface WorkflowRun { - readonly id: WorkflowRunId - /** The validated meta block (available before the body runs). */ - readonly meta: WorkflowMeta - readonly result: Promise - /** Cancel the run: children abort, pending hooks reject, the script dies at its next await (or is force-settled at the grace). */ - cancel(reason?: string): void - /** Cancel + bounded-grace settle; safe to call on every path (idempotent). */ - dispose(): Promise -} - /** Identifying detail for a run, carried by every `workflow/*` event as borrowed immutable data, never the live run. */ export interface WorkflowRunInfo { /** The run's id. */ diff --git a/packages/workflow/workflow/tsconfig.json b/packages/workflow/workflow/tsconfig.json index 76ad9f725a..11a71a280b 100644 --- a/packages/workflow/workflow/tsconfig.json +++ b/packages/workflow/workflow/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../core/agent" }, + { + "path": "../../core/session" + }, { "path": "../../util/brand" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4912b264d7..905b1db792 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1529,6 +1529,9 @@ importers: '@deepseek-ai/dsh-client-ui-trajectory': specifier: workspace:^ version: link:../../client/ui-trajectory + '@deepseek-ai/dsh-client-ui-workflow-run': + specifier: workspace:^ + version: link:../../client/ui-workflow-run '@deepseek-ai/dsh-client-ui-workspace': specifier: workspace:^ version: link:../../client/ui-workspace @@ -2758,6 +2761,49 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/client/ui-workflow-run: + dependencies: + react: + specifier: ^18.2.0 + version: 18.3.1 + devDependencies: + '@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-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-tool-workflow': + specifier: workspace:^ + version: link:../../workflow/tool-workflow + '@deepseek-ai/dsh-workflow': + specifier: workspace:^ + version: link:../../workflow/workflow + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/client/ui-workspace: dependencies: clsx: diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 1a9d998029..36872ca1a3 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1278,7 +1278,7 @@ { "doc": "docs/subsystems/workflow.md", "symbol": "WorkflowStartRequest", - "source": "packages/workflow/workflow/src/types.ts" + "source": "packages/workflow/workflow/src/runtime-types.ts" }, { "doc": "docs/subsystems/workflow.md", @@ -1293,7 +1293,7 @@ { "doc": "docs/subsystems/workflow.md", "symbol": "WorkflowRun", - "source": "packages/workflow/workflow/src/types.ts" + "source": "packages/workflow/workflow/src/runtime-types.ts" }, { "doc": "docs/subsystems/lsp.md", diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 0a202e6142..15a61aa342 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -72,6 +72,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-tool': { kind: 'none', reason: 'Browser-side Tool presentation layer; renders logged calls without changing model context.' }, 'packages/client/ui-deliverables': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/ui-workflow-run': { kind: 'none', reason: 'Browser-side UI plugin layer; renders durable workflow records without changing model context.' }, 'packages/client/ui-slash': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-command': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' }, 'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the Host snapshots the selection at the next prompt-assembly boundary and owns the model-visible effect.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 0523b378d9..b99e2d7d73 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -69,6 +69,8 @@ "@deepseek-ai/dsh-llm/types": ["./packages/llm/llm/src/types.ts"], "@deepseek-ai/dsh-llm/brand": ["./packages/llm/llm/src/brand.ts"], "@deepseek-ai/dsh-llm-retry/types": ["./packages/llm/llm-retry/src/types.ts"], + "@deepseek-ai/dsh-workflow/types": ["./packages/workflow/workflow/src/types.ts"], + "@deepseek-ai/dsh-tool-workflow/types": ["./packages/workflow/tool-workflow/src/types.ts"], "@deepseek-ai/dsh-llm/message": ["./packages/llm/llm/src/message.ts"], "@deepseek-ai/dsh-commands/brand": ["./packages/interaction/commands/src/brand.ts"], "@deepseek-ai/dsh-commands/types": ["./packages/interaction/commands/src/types.ts"], @@ -169,6 +171,7 @@ "@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"], "@deepseek-ai/dsh-client-ui-tool": ["./packages/client/ui-tool/src"], "@deepseek-ai/dsh-client-ui-deliverables": ["./packages/client/ui-deliverables/src"], + "@deepseek-ai/dsh-client-ui-workflow-run": ["./packages/client/ui-workflow-run/src"], "@deepseek-ai/dsh-client-ui-slash": ["./packages/client/ui-slash/src"], "@deepseek-ai/dsh-client-ui-command": ["./packages/client/ui-command/src"], "@deepseek-ai/dsh-client-ui-model": ["./packages/client/ui-model/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 632f6a84a7..f5b2d235c9 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -61,6 +61,7 @@ { "path": "./packages/client/ui-conversation" }, { "path": "./packages/client/ui-tool" }, { "path": "./packages/client/ui-deliverables" }, + { "path": "./packages/client/ui-workflow-run" }, { "path": "./packages/client/ui-workspace" }, { "path": "./packages/client/ui-slash" }, { "path": "./packages/client/ui-command" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index d9bf1c29e4..a52b1964b9 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -70,6 +70,7 @@ "apps/web/tests/composer-tab-geometry.e2e.ts", "apps/web/tests/complex-history.perf.ts", "apps/web/tests/pwsh-terminal.e2e.ts", + "apps/web/tests/workflow-run.e2e.ts", "apps/web/stress-tests/reasoning-chunks.stress.ts", "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", From 4eb0a52840df227b5541c7beb5edb64495585ec8 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 10 Aug 2026 19:25:21 +0800 Subject: [PATCH 03/31] fix(workflow): close review and snapshot gaps --- .../snapshots/workflow-run/ui.expected.md | 23 --- apps/web/tests/workflow-run.e2e.ts | 17 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/subsystems/workflow.i18n.yaml | 4 +- docs/subsystems/workflow.md | 2 +- docs/subsystems/workflow.zh.md | 2 +- .../advanced-toolchain/session.jsonl | 48 +++--- .../snapshots/workflow-run/session.jsonl | 34 ++-- .../advanced-toolchain/session.jsonl | 66 ++++---- .../stream-json.expected.jsonl | 46 +++--- .../src/client/WorkflowRunPanel.module.css | 8 +- .../src/client/WorkflowRunPanel.tsx | 120 +++++++------- .../ui-workflow-run/src/client/index.ts | 6 - .../src/client/workflow-definition.ts | 22 +-- .../tests/workflow-run.spec.tsx | 34 ++-- packages/workflow/tool-workflow/src/index.ts | 146 +++++++----------- .../workflow/tool-workflow/src/invariant.ts | 40 +++-- .../tool-workflow/tests/tool-workflow.spec.ts | 24 --- 20 files changed, 300 insertions(+), 350 deletions(-) diff --git a/apps/web/tests/snapshots/workflow-run/ui.expected.md b/apps/web/tests/snapshots/workflow-run/ui.expected.md index 7a2e1cfd13..297aad1b70 100644 --- a/apps/web/tests/snapshots/workflow-run/ui.expected.md +++ b/apps/web/tests/snapshots/workflow-run/ui.expected.md @@ -1,14 +1,3 @@ -- banner: - - navigation "Session hierarchy": - - button "Use the workflow tool exactly" [disabled] - - button "1 subagent": - - text: 1 subagent - - img - - img - - text: 标准模式 - - tablist: - - tab "Chat" [selected] - - tab "Trajectory" - text: "Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): phase('Run') const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') return { reply } After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool. {{clock}}" - button "Copy": - img @@ -41,15 +30,3 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- button "Back to bottom": - - img -- textbox "Message the agent" -- button "Commands": - - img -- 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img -- button "3% of context used" -- button "Send message" [disabled] -- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 47% Input 6.6K tok · Output 227 tok diff --git a/apps/web/tests/workflow-run.e2e.ts b/apps/web/tests/workflow-run.e2e.ts index cacefa75a4..eafb78223f 100644 --- a/apps/web/tests/workflow-run.e2e.ts +++ b/apps/web/tests/workflow-run.e2e.ts @@ -93,8 +93,16 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = const label = element.querySelector('[data-member-label]') const labelWrap = element.querySelector('[data-member-label-wrap]') const status = element.querySelector('[data-member-status-text]') - const runHeader = element.querySelector('[data-run-header]') - const phaseHeader = element.querySelector('[data-phase-header]') + const disclosures = element.querySelectorAll('[data-disclosure-row]') + const runHeader = disclosures[0] + const phaseHeader = disclosures[1] + const phaseTitle = phaseHeader?.children.item(1) as HTMLElement | null + const phaseStatus = element.querySelector('[data-phase-status-text]') + const originalPhaseTitle = phaseTitle?.textContent ?? '' + if (phaseTitle !== null) phaseTitle.textContent = 'A phase name long enough to require ellipsis in the narrow layout' + const phaseTitleRight = phaseTitle?.getBoundingClientRect().right ?? 0 + const phaseStatusLeft = phaseStatus?.getBoundingClientRect().left ?? 0 + if (phaseTitle !== null) phaseTitle.textContent = originalPhaseTitle return { clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, @@ -105,6 +113,8 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = statusFontSize: status === null ? '' : getComputedStyle(status).fontSize, runHeight: runHeader?.getBoundingClientRect().height ?? 0, phaseHeight: phaseHeader?.getBoundingClientRect().height ?? 0, + phaseTitleRight, + phaseStatusLeft, } }) expect(darkNarrow.clientWidth).toBe(356) @@ -116,6 +126,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = expect(darkNarrow.statusFontSize).toBe('13px') expect(darkNarrow.runHeight).toBe(32) expect(darkNarrow.phaseHeight).toBe(32) + expect(darkNarrow.phaseTitleRight).toBeLessThanOrEqual(darkNarrow.phaseStatusLeft) await page.locator('[data-workflow-run]').evaluate((element) => { (element as HTMLElement).style.removeProperty('width') document.body.removeAttribute('data-ds-dark-theme') @@ -158,7 +169,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = await page.getByText(CHILD_PROMPT, { exact: false }).waitFor() expect(await page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count()).toBe(0) - const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + const snapshot = await captureStableAria(page, '[data-chat-flow]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) }, 60_000) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 9a0d47093e..5d08bb4a0c 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: d9b70eb15865b45d0d8251789d6d661cd9747024 -config-catalog.zh.md: 4974a7e53c60507c2dced9a93cb5e2a2ba0ed850 +config-catalog.md: 7ae543267733cbe27541b1fca5599a2a09d6462d +config-catalog.zh.md: 5ed7b1fc2cf6576496ec144d1fdccf85ce646717 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d9b70eb158..7ae5432677 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2412,7 +2412,7 @@ export interface Config { } ``` -Source: [`packages/workflow/tool-workflow/src/index.ts:34`](../packages/workflow/tool-workflow/src/index.ts) +Source: [`packages/workflow/tool-workflow/src/index.ts:33`](../packages/workflow/tool-workflow/src/index.ts) ## `@deepseek-ai/dsh-tools` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4974a7e53c..5ed7b1fc2c 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2413,7 +2413,7 @@ export interface Config { } ``` -来源:[`packages/workflow/tool-workflow/src/index.ts:34`](../packages/workflow/tool-workflow/src/index.ts) +来源:[`packages/workflow/tool-workflow/src/index.ts:33`](../packages/workflow/tool-workflow/src/index.ts) ## `@deepseek-ai/dsh-tools` diff --git a/docs/subsystems/workflow.i18n.yaml b/docs/subsystems/workflow.i18n.yaml index 3100aaeddc..4061410c14 100644 --- a/docs/subsystems/workflow.i18n.yaml +++ b/docs/subsystems/workflow.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/workflow.md -workflow.md: b651a5459d4ff8c71de223ca2b51dca997ab86bf -workflow.zh.md: 0fd32675c8612dfeee1dbce7cd8e9977bbe330ef +workflow.md: 3c7cc39feb8493b9ace11ae10c031a34a3942aee +workflow.zh.md: c945a339c91402004a790ebc1ce7ffd5f8921ef6 diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index b651a5459d..3c7cc39feb 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -125,7 +125,7 @@ The top-level `dsh-tool-workflow` consumer projects display facts into its calli `dsh-tool-workflow/invariant` validates the same protocol before live commit and when a Session is loaded: one start per run, positive unique member sequences, paired member endings, no run ending with open members, and no updates after the run ending. A missing member ending or run ending at the log tail is valid interruption evidence rather than corruption. -`dsh-client-ui-workflow-run` folds the four events through the Conversation Node engine into one `workflow-run` Chat node anchored at the run-start sequence, after the original workflow tool node. Phase groups come only from actual member starts and preserve exact strings, including the distinction between an omitted phase and `''`. Closed Locations turn missing terminal facts into interrupted presentation. The 32-pixel run row uses module-platform background, persistent chevrons, and inline dot plus status text; 32-pixel phase rows keep title and count in the main area and precise aggregate status in a fixed tail without another dot; members use a 16-pixel dot slot and fixed 64-pixel lifecycle column. Underlined names alone mark navigation while the member and current list both prove a running same-parent local subagent. +`dsh-client-ui-workflow-run` folds the four events through the Conversation Node engine into one `workflow-run` Chat node anchored at the run-start sequence, after the original workflow tool node. Phase groups come only from actual member starts and preserve exact strings, including the distinction between an omitted phase and `''`. Closed Locations turn missing terminal facts into interrupted presentation. The [UI package README](../../packages/client/ui-workflow-run/README.md) owns disclosure, status, and same-parent local navigation behavior. diff --git a/docs/subsystems/workflow.zh.md b/docs/subsystems/workflow.zh.md index 0fd32675c8..c945a339c9 100644 --- a/docs/subsystems/workflow.zh.md +++ b/docs/subsystems/workflow.zh.md @@ -125,7 +125,7 @@ interface WorkflowRun { `dsh-tool-workflow/invariant` 会在实时提交前和 Session 加载时校验同一协议:每个运行只有一个 start,成员序号为正且唯一,成员 end 必须配对,仍有开放成员时不能结束运行,运行结束后不能继续更新。日志尾部缺少成员 end 或 run end 是有效的中断证据,不是损坏。 -`dsh-client-ui-workflow-run` 通过 Conversation Node 引擎把四类事件折叠为一个 `workflow-run` Chat 节点,以 run-start 序号锚定在原工作流工具节点之后。阶段组只来自真正开始过的成员,并保留精确字符串,包括字段缺省与 `''` 的区别。Location 关闭时,缺失终点会显示为已中断。32 像素运行行使用 module-platform 背景、常驻 chevron 与内联状态点加文字;32 像素阶段行在主区显示标题和计数,在固定尾部精确显示聚合状态且不重复状态点;成员使用 16 像素状态点槽和固定 64 像素生命周期列。只有成员状态与当前列表同时证明它是同父级、仍运行的本地 subagent 时,带下划线名称才标记普通 Session 导航。 +`dsh-client-ui-workflow-run` 通过 Conversation Node 引擎把四类事件折叠为一个 `workflow-run` Chat 节点,以 run-start 序号锚定在原工作流工具节点之后。阶段组只来自真正开始过的成员,并保留精确字符串,包括字段缺省与 `''` 的区别。Location 关闭时,缺失终点会显示为已中断。[界面包 README](../../packages/client/ui-workflow-run/README.md)负责定义 disclosure、状态与同父本地导航行为。 diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl index d6935b6c98..b92c50efa5 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821417919,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498801761,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"},"role":"user","id":"6e45782a-31be-4ba7-8c4a-7411a2027e36"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730458430,"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":"9f38e2b8-1d4e-4c90-8896-00aa42307ea7"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730458430,"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":"f66cc92b-b90c-4aeb-9568-7463d5eeede9"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730458430,"data":{"title":"Run this advanced flow exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498801765,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730458431,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} @@ -47,24 +47,28 @@ {"type":"assistant/chunk","seq":45,"time":1785730458577,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":46,"time":1785730458577,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ebeca5c6-68ae-43b3-87c3-c48fdfe416c8"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[41,42,43,44,45],"surfaceOp":"append"} {"type":"tool/call","seq":47,"time":1785730458577,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}} -{"type":"tool/result","seq":48,"time":1785730458711,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-acp-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"f892f17e-1e93-4f4b-9e9e-15116593b6fc"}},"sourceEventSeqs":[47],"surfaceOp":"append"} -{"type":"step/end","seq":49,"time":1785730458711,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":50,"time":1785730458723,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":51,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":52,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} -{"type":"assistant/chunk","seq":53,"time":1785036891795,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} -{"type":"assistant/chunk","seq":54,"time":1785498802087,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":55,"time":1785730458728,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":56,"time":1785730458728,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1291ce3c-e568-4f0d-a95a-5157b8b2cc75"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[51,52,53,54,55],"surfaceOp":"append"} -{"type":"tool/call","seq":57,"time":1785730458728,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} -{"type":"tool/result","seq":58,"time":1785730458735,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"b3634221-2358-4e82-aac5-e37f0a115023"}},"sourceEventSeqs":[57],"surfaceOp":"append"} -{"type":"step/end","seq":59,"time":1785730458735,"data":{"turn":1,"step":5}} -{"type":"step/start","seq":60,"time":1785730458747,"data":{"turn":1,"step":6}} -{"type":"assistant/chunk","seq":61,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":62,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_ACP_OK"}}} -{"type":"assistant/chunk","seq":63,"time":1785036891804,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_ACP_OK"}}}} -{"type":"assistant/chunk","seq":64,"time":1785498802107,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":65,"time":1785730458751,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":66,"time":1785730458751,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_ACP_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"a32b89ce-13ed-48ba-a7f9-24144b94ec56"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[61,62,63,64,65],"surfaceOp":"append"} -{"type":"step/end","seq":67,"time":1785730458751,"data":{"turn":1,"step":6}} -{"type":"turn/end","seq":68,"time":1785730458751,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"tool-workflow/run-start","seq":48,"time":1786359248404,"data":{"runId":"2f6d6a6e-6d76-4a8a-8677-6671366645dc","name":"advanced-acp-snapshot"}} +{"type":"tool-workflow/agent-start","seq":49,"time":1786359248518,"data":{"runId":"2f6d6a6e-6d76-4a8a-8677-6671366645dc","seq":1,"label":"workflow-child","phase":"Delegate","childId":"33333333-3333-4333-8333-333333333333"}} +{"type":"tool-workflow/agent-end","seq":50,"time":1786359248542,"data":{"runId":"2f6d6a6e-6d76-4a8a-8677-6671366645dc","seq":1,"outcome":"completed"}} +{"type":"tool-workflow/run-end","seq":51,"time":1786359248543,"data":{"runId":"2f6d6a6e-6d76-4a8a-8677-6671366645dc","stopReason":"completed"}} +{"type":"tool/result","seq":52,"time":1786359248543,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-acp-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"f892f17e-1e93-4f4b-9e9e-15116593b6fc"}},"sourceEventSeqs":[47],"surfaceOp":"append"} +{"type":"step/end","seq":53,"time":1786359248543,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":54,"time":1786359248550,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":55,"time":1785730458728,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":56,"time":1786359248554,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":57,"time":1786359248554,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":58,"time":1786359248554,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":59,"time":1786359248554,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":60,"time":1786359248554,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1291ce3c-e568-4f0d-a95a-5157b8b2cc75"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"} +{"type":"tool/call","seq":61,"time":1786359248554,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} +{"type":"tool/result","seq":62,"time":1786359248558,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"b3634221-2358-4e82-aac5-e37f0a115023"}},"sourceEventSeqs":[61],"surfaceOp":"append"} +{"type":"step/end","seq":63,"time":1786359248558,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":64,"time":1786359248564,"data":{"turn":1,"step":6}} +{"type":"assistant/chunk","seq":65,"time":1785730458751,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":66,"time":1786359248568,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_ACP_OK"}}} +{"type":"assistant/chunk","seq":67,"time":1786359248568,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_ACP_OK"}}}} +{"type":"assistant/chunk","seq":68,"time":1786359248568,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":69,"time":1786359248568,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":70,"time":1786359248568,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_ACP_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"a32b89ce-13ed-48ba-a7f9-24144b94ec56"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[65,66,67,68,69],"surfaceOp":"append"} +{"type":"step/end","seq":71,"time":1786359248568,"data":{"turn":1,"step":6}} +{"type":"turn/end","seq":72,"time":1786359248568,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl index 6ee104dd0c..16d284eb09 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821416248,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783600631839,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498800152,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"},"role":"user","id":"5188a9c7-d3ca-4679-b8df-1443e0a0a4df"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730457160,"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":"1c92c213-1d4f-45ad-be50-161f26a23e65"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730457160,"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":"7b864c39-41fc-4bfb-809a-0dd9f1dc4383"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730457160,"data":{"title":"Use the workflow tool exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498800153,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730457161,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} @@ -18,17 +18,21 @@ {"type":"assistant/chunk","seq":163,"time":1785730457174,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":164,"time":1785730457174,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:\n\n1. args omitted (so I don't include it)\n2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }\n3. script = as given verbatim\n4. After it returns, reply with \"WORKFLOW_DONE\"\n\nLet me do exactly that."},{"type":"tool-call","id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9a15ecb9-11ce-4d1b-9a0a-07cc388dc0e0"},"usage":{"inputTokens":3174,"outputTokens":191,"cacheReadTokens":0,"reasoningTokens":88}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163],"surfaceOp":"append"} {"type":"tool/call","seq":165,"time":1785730457174,"data":{"turn":1,"step":1,"callId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}} -{"type":"tool/result","seq":166,"time":1785730457320,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_dD2BLuNeJCTh2iiYC1QR3449"},"content":[{"type":"tool-result","toolCallId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","content":[{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"a3ca6fd6-3d4c-4ad2-a67c-fc9479ef4f15"}},"sourceEventSeqs":[165],"surfaceOp":"append"} -{"type":"step/end","seq":167,"time":1785730457320,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":168,"time":1785730457334,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":169,"time":1783600640134,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":170,"time0":1783600640162,"data":{"turn":1,"step":2,"index":0,"dt":[33,667,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," workflow"," returned"," successfully"," with"," the"," reply"," \"","WF","_CH","ILD","_OK","\"."," Now"," I"," need"," to"," reply"," with"," exactly"," \"","WORK","FL","OW","_D","ONE","\""," and"," stop","."]}} -{"type":"assistant/chunk","seq":200,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":201,"time0":1783600640865,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,0],"texts":["WORK","FL","OW","_D","ONE"]}} -{"type":"assistant/chunk","seq":206,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."}}}} -{"type":"assistant/chunk","seq":207,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKFLOW_DONE"}}}} -{"type":"assistant/chunk","seq":208,"time":1785498800365,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}}}} -{"type":"assistant/chunk","seq":209,"time":1785730457339,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":210,"time":1785730457339,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."},{"type":"text","text":"WORKFLOW_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"265fc6fa-19e0-4df9-b4ea-f38141ba4efa"},"usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}},"sourceEventSeqs":[169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209],"surfaceOp":"append"} -{"type":"step/end","seq":211,"time":1785730457339,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":212,"time":1785730457339,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"tool-workflow/run-start","seq":166,"time":1786359246611,"data":{"runId":"632cc7d7-38d4-45ba-b6c5-55e5784b2501","name":"snapshot-flow"}} +{"type":"tool-workflow/agent-start","seq":167,"time":1786359246721,"data":{"runId":"632cc7d7-38d4-45ba-b6c5-55e5784b2501","seq":1,"label":"Reply with exactly the word WF_CHILD_OK and not…","phase":"Run","childId":"583a4db2-3350-436c-b4a5-5615fd159052"}} +{"type":"tool-workflow/agent-end","seq":168,"time":1786359246743,"data":{"runId":"632cc7d7-38d4-45ba-b6c5-55e5784b2501","seq":1,"outcome":"completed"}} +{"type":"tool-workflow/run-end","seq":169,"time":1786359246745,"data":{"runId":"632cc7d7-38d4-45ba-b6c5-55e5784b2501","stopReason":"completed"}} +{"type":"tool/result","seq":170,"time":1786359246745,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_dD2BLuNeJCTh2iiYC1QR3449"},"content":[{"type":"tool-result","toolCallId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","content":[{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"a3ca6fd6-3d4c-4ad2-a67c-fc9479ef4f15"}},"sourceEventSeqs":[165],"surfaceOp":"append"} +{"type":"step/end","seq":171,"time":1786359246746,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":172,"time":1786359246751,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":173,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":174,"time0":1783600640862,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,2,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," workflow"," returned"," successfully"," with"," the"," reply"," \"","WF","_CH","ILD","_OK","\"."," Now"," I"," need"," to"," reply"," with"," exactly"," \"","WORK","FL","OW","_D","ONE","\""," and"," stop","."]}} +{"type":"assistant/chunk","seq":204,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":205,"time0":1783600640865,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,1898159500,231656974],"texts":["WORK","FL","OW","_D","ONE"]}} +{"type":"assistant/chunk","seq":210,"time":1786359246756,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."}}}} +{"type":"assistant/chunk","seq":211,"time":1786359246756,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKFLOW_DONE"}}}} +{"type":"assistant/chunk","seq":212,"time":1786359246756,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}}}} +{"type":"assistant/chunk","seq":213,"time":1786359246756,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":214,"time":1786359246756,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."},{"type":"text","text":"WORKFLOW_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"265fc6fa-19e0-4df9-b4ea-f38141ba4efa"},"usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}},"sourceEventSeqs":[173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213],"surfaceOp":"append"} +{"type":"step/end","seq":215,"time":1786359246757,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":216,"time":1786359246757,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 646110b6d9..b64afd808e 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -1,20 +1,20 @@ {"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785498583746,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"d2f4f71c-78bc-4a22-908d-c08fbb3ab9ef"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498583746,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"63f46c0a-1c99-4b19-b097-fcb2d0d12357"}]}} {"type":"turn/start","seq":1,"time":1785821454304,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821454304,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785498583779,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"d2f4f71c-78bc-4a22-908d-c08fbb3ab9ef"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785498583779,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"63f46c0a-1c99-4b19-b097-fcb2d0d12357"},"surfaceOp":"append"} {"type":"session/title","seq":5,"time":1785498583779,"data":{"title":"Run this advanced flow exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":6,"time":1785498583782,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":6,"time":1785498583782,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785730501403,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":8,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":9,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} {"type":"assistant/chunk","seq":10,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} {"type":"assistant/chunk","seq":11,"time":1785498583784,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":12,"time":1785730501404,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1785730501404,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e65c0ebe-8e3d-44c0-833f-68efcbc0acb5"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1785730501404,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f468e717-7654-4020-9fe2-53300ff16763"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1785730501404,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} -{"type":"tool/result","seq":15,"time":1785730501413,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"abb8ecee-cb03-4a66-9477-38a52458ab05"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","seq":15,"time":1785730501413,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"4bad4fa0-ca5e-4062-887c-b93f31bc89ba"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","seq":16,"time":1785730501413,"data":{"turn":1,"step":1}} {"type":"step/start","seq":17,"time":1785730501423,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":18,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -22,11 +22,11 @@ {"type":"assistant/chunk","seq":20,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}} {"type":"assistant/chunk","seq":21,"time":1785498583804,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":22,"time":1785730501424,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":23,"time":1785730501424,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cdc95327-3ce1-49ea-8a92-b17e450cc455"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} +{"type":"assistant/message","seq":23,"time":1785730501424,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4a157153-f4e0-4417-a595-e3fdb848ee72"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} {"type":"tool/call","seq":24,"time":1785730501424,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}} {"type":"tool/code-dispatch-start","seq":25,"time":1785730501473,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} {"type":"tool/code-dispatch","seq":26,"time":1785730501474,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} -{"type":"tool/result","seq":27,"time":1785730501475,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"d75c7d03-cbbc-4260-ba40-8c210a3b5bbe"}},"sourceEventSeqs":[24],"surfaceOp":"append"} +{"type":"tool/result","seq":27,"time":1785730501475,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"e66e0537-ae11-4783-bf67-1eab7210bd11"}},"sourceEventSeqs":[24],"surfaceOp":"append"} {"type":"step/end","seq":28,"time":1785730501475,"data":{"turn":1,"step":2}} {"type":"step/start","seq":29,"time":1785730501483,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":30,"time":1783950000029,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -34,9 +34,9 @@ {"type":"assistant/chunk","seq":32,"time":1785037378923,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} {"type":"assistant/chunk","seq":33,"time":1785498583869,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":34,"time":1785730501484,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":35,"time":1785730501484,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ba4958e9-231c-437f-a2fc-7a13f392d3ba"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[30,31,32,33,34],"surfaceOp":"append"} +{"type":"assistant/message","seq":35,"time":1785730501484,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1c5a9eee-b5ae-4d17-994f-d5ce5d57c3b3"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[30,31,32,33,34],"surfaceOp":"append"} {"type":"tool/call","seq":36,"time":1785730501484,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} -{"type":"tool/result","seq":37,"time":1785730501508,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"b9ebb37d-e565-4882-95b0-5343da1d68d8"}},"sourceEventSeqs":[36],"surfaceOp":"append"} +{"type":"tool/result","seq":37,"time":1785730501508,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"c1f65bfd-dc5c-4b11-b4d0-1e45628168aa"}},"sourceEventSeqs":[36],"surfaceOp":"append"} {"type":"step/end","seq":38,"time":1785730501508,"data":{"turn":1,"step":3}} {"type":"step/start","seq":39,"time":1785730501521,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":40,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -44,26 +44,30 @@ {"type":"assistant/chunk","seq":42,"time":1785037378946,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}}} {"type":"assistant/chunk","seq":43,"time":1785498583919,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":44,"time":1785730501522,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":45,"time":1785730501522,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4757f4b9-9bde-488b-a54a-1bdea55dd15f"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[40,41,42,43,44],"surfaceOp":"append"} +{"type":"assistant/message","seq":45,"time":1785730501522,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"162f6c74-332c-4819-b498-4e2000a71895"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[40,41,42,43,44],"surfaceOp":"append"} {"type":"tool/call","seq":46,"time":1785730501522,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}} -{"type":"tool/result","seq":47,"time":1785730501647,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"35baa460-54ff-4fa1-ba9d-66b6661f84e9"}},"sourceEventSeqs":[46],"surfaceOp":"append"} -{"type":"step/end","seq":48,"time":1785730501648,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":49,"time":1785730501660,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":51,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} -{"type":"assistant/chunk","seq":52,"time":1785037379534,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} -{"type":"assistant/chunk","seq":53,"time":1785498584085,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":54,"time":1785730501661,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":55,"time":1785730501661,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"739166e2-ed48-4df2-a9a5-207f34058030"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[50,51,52,53,54],"surfaceOp":"append"} -{"type":"tool/call","seq":56,"time":1785730501661,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} -{"type":"tool/result","seq":57,"time":1785730501668,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"98b05c06-cb77-41a9-8310-324bc72fc7a0"}},"sourceEventSeqs":[56],"surfaceOp":"append"} -{"type":"step/end","seq":58,"time":1785730501668,"data":{"turn":1,"step":5}} -{"type":"step/start","seq":59,"time":1785730501678,"data":{"turn":1,"step":6}} -{"type":"assistant/chunk","seq":60,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":61,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_HEADLESS_OK"}}} -{"type":"assistant/chunk","seq":62,"time":1785037379541,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}} -{"type":"assistant/chunk","seq":63,"time":1785498584102,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":64,"time":1785730501679,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":65,"time":1785730501679,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0a4ca8f2-92c1-4dbc-beb8-923b8791c298"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[60,61,62,63,64],"surfaceOp":"append"} -{"type":"step/end","seq":66,"time":1785730501679,"data":{"turn":1,"step":6}} -{"type":"turn/end","seq":67,"time":1785730501679,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"tool-workflow/run-start","seq":47,"time":1786359174028,"data":{"runId":"668432bb-f01c-41e7-841e-30d8deab7b55","name":"advanced-headless-snapshot"}} +{"type":"tool-workflow/agent-start","seq":48,"time":1786359174210,"data":{"runId":"668432bb-f01c-41e7-841e-30d8deab7b55","seq":1,"label":"workflow-child","phase":"Delegate","childId":"33333333-3333-4333-8333-333333333333"}} +{"type":"tool-workflow/agent-end","seq":49,"time":1786359174230,"data":{"runId":"668432bb-f01c-41e7-841e-30d8deab7b55","seq":1,"outcome":"completed"}} +{"type":"tool-workflow/run-end","seq":50,"time":1786359174232,"data":{"runId":"668432bb-f01c-41e7-841e-30d8deab7b55","stopReason":"completed"}} +{"type":"tool/result","seq":51,"time":1786359174232,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"630f5c50-936a-4cfd-b659-69eeba6f9d3f"}},"sourceEventSeqs":[46],"surfaceOp":"append"} +{"type":"step/end","seq":52,"time":1786359174233,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":53,"time":1786359174239,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":54,"time":1785730501661,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":55,"time":1786359174239,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":56,"time":1786359174239,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":57,"time":1786359174239,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":58,"time":1786359174239,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":59,"time":1786359174239,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"97e67ea7-7d8d-4ab9-8bcd-0b7fab0216a2"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[54,55,56,57,58],"surfaceOp":"append"} +{"type":"tool/call","seq":60,"time":1786359174239,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} +{"type":"tool/result","seq":61,"time":1786359174243,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"0a466846-c6c2-475c-a7bc-f201bcfdd28b"}},"sourceEventSeqs":[60],"surfaceOp":"append"} +{"type":"step/end","seq":62,"time":1786359174243,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":63,"time":1786359174248,"data":{"turn":1,"step":6}} +{"type":"assistant/chunk","seq":64,"time":1785730501679,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":65,"time":1786359174249,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_HEADLESS_OK"}}} +{"type":"assistant/chunk","seq":66,"time":1786359174249,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}} +{"type":"assistant/chunk","seq":67,"time":1786359174249,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":68,"time":1786359174249,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":69,"time":1786359174249,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e8d83a6c-28f1-4ef1-9d90-a729dd2efe97"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[64,65,66,67,68],"surfaceOp":"append"} +{"type":"step/end","seq":70,"time":1786359174249,"data":{"turn":1,"step":6}} +{"type":"turn/end","seq":71,"time":1786359174249,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl index 817ee1e1a2..469c156969 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl @@ -45,25 +45,29 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":45,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[40,41,42,43,44],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":46,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":47,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[46],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":48,"time":0,"data":{"turn":1,"step":4}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":49,"time":0,"data":{"turn":1,"step":5}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":53,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":54,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":55,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[50,51,52,53,54],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":56,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":57,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[56],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":58,"time":0,"data":{"turn":1,"step":5}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":59,"time":0,"data":{"turn":1,"step":6}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_HEADLESS_OK"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":63,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":64,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":65,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[60,61,62,63,64],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":66,"time":0,"data":{"turn":1,"step":6}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":67,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool-workflow/run-start","seq":47,"time":0,"data":{"runId":"{{sessionId}}","name":"advanced-headless-snapshot"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool-workflow/agent-start","seq":48,"time":0,"data":{"runId":"{{sessionId}}","seq":1,"label":"workflow-child","phase":"Delegate","childId":"{{sessionId}}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool-workflow/agent-end","seq":49,"time":0,"data":{"runId":"{{sessionId}}","seq":1,"outcome":"completed"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool-workflow/run-end","seq":50,"time":0,"data":{"runId":"{{sessionId}}","stopReason":"completed"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":51,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[46],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":52,"time":0,"data":{"turn":1,"step":4}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":53,"time":0,"data":{"turn":1,"step":5}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":54,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":59,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[54,55,56,57,58],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":60,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":61,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[60],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":62,"time":0,"data":{"turn":1,"step":5}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":63,"time":0,"data":{"turn":1,"step":6}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":64,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":65,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_HEADLESS_OK"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":66,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":67,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":68,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":69,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[64,65,66,67,68],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":70,"time":0,"data":{"turn":1,"step":6}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":71,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} {"type":"result","sessionId":"{{sessionId}}","output":"ADVANCED_HEADLESS_OK","usage":{"inputTokens":18,"outputTokens":18}} diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css index 0f069ac77b..77145ee06a 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css @@ -29,6 +29,7 @@ height: 16px; align-items: center; justify-content: center; + margin-right: 0; color: var(--dsw-alias-label-tertiary); } @@ -93,14 +94,19 @@ height: 16px; align-items: center; justify-content: center; + margin-right: 0; color: var(--dsw-alias-label-tertiary); } .phaseTitle { - flex: none; + overflow: hidden; + flex: 0 1 auto; + min-width: 0; + max-width: 42%; color: var(--dsw-alias-label-secondary); font-size: 14px; line-height: 24px; + text-overflow: ellipsis; white-space: nowrap; } diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx index 313bb06c97..8e48ffb4be 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx @@ -1,6 +1,6 @@ -import { useMemo, useState, type KeyboardEvent } from 'react' +import { useMemo, useState } from 'react' import { - IconChevronDownOutline14, IconChevronRightOutline14, StateDot, type StateDotState, + DisclosureRow, IconChevronRightOutline14, StateDot, type StateDotState, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' @@ -71,12 +71,6 @@ function phaseStatusSummary(members: readonly WorkflowRunMemberData[], t: Workfl return visible.map(status => statusCount(status, count(status), t)).join(' · ') } -function handleDisclosureKey(event: KeyboardEvent, onToggle: () => void): void { - if (event.key !== 'Enter' && event.key !== ' ') return - event.preventDefault() - onToggle() -} - function RunHeader({ count, name, onToggle, open, status, t }: { readonly count: number readonly name: string @@ -86,27 +80,29 @@ function RunHeader({ count, name, onToggle, open, status, t }: { readonly t: WorkflowRunPanelProps['t'] }) { return ( -
{ handleDisclosureKey(event, onToggle) }} - > - - {open ? : } - - {t('run.title', { name })} - - {t('run.members', { count })} - - - {t(STATUS_KEYS[status])} - -
+ } + title={t('run.title', { name })} + open={open} + expandable + onToggle={onToggle} + expandOnRowClick + previewChevron={false} + keepContentWhenOpen + rowClassName={css.runHeader} + leadingClassName={css.runLeading} + titleClassName={css.runTitle} + collapsedContent={( + <> + + {t('run.members', { count })} + + + {t(STATUS_KEYS[status])} + + + )} + /> ) } @@ -149,38 +145,39 @@ function PhaseSection({ phase, navigable, openSession, t }: { const [open, setOpen] = useState(false) const toggle = (): void => { setOpen(value => !value) } return ( -
-
{ handleDisclosureKey(event, toggle) }} - > - - {open ? : } - - {readablePhase(phase.phase, t)} - - {t('run.members', { count: phase.members.length })} - {phaseStatusSummary(phase.members, t)} -
- {open && ( -
- {phase.members.map(member => ( - - ))} -
+ } + title={readablePhase(phase.phase, t)} + open={open} + expandable + onToggle={toggle} + expandOnRowClick + previewChevron={false} + keepContentWhenOpen + className={css.phase} + rowClassName={css.phaseHeader} + leadingClassName={css.phaseLeading} + titleClassName={css.phaseTitle} + collapsedContent={( + <> + + {t('run.members', { count: phase.members.length })} + {phaseStatusSummary(phase.members, t)} + )} -
+ > +
+ {phase.members.map(member => ( + + ))} +
+
) } @@ -188,6 +185,7 @@ function PhaseSection({ phase, navigable, openSession, t }: { export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t }: WorkflowRunPanelProps) { const [open, setOpen] = useState(() => node.data.status === 'running') const sessions = useSessions(value => value) + const memberCount = node.data.phases.reduce((count, phase) => count + phase.members.length, 0) const navigable = useMemo(() => { const ordinary = new Set(sessions.ids) const result = new Set() @@ -208,7 +206,7 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t return (
{ readonly outcome?: WorkflowAgentOutcome } @@ -90,14 +88,6 @@ function locationClosed(location: ConversationLocation | undefined): boolean { return location.kind === 'turn' && location.turn.status === 'closed' } -function aggregateStatus(members: readonly WorkflowRunMemberData[]): WorkflowRunStatus { - if (members.some(member => member.status === 'running')) return 'running' - if (members.some(member => member.status === 'failed')) return 'failed' - if (members.some(member => member.status === 'cancelled')) return 'cancelled' - if (members.some(member => member.status === 'interrupted')) return 'interrupted' - return 'completed' -} - function projectWorkflow( context: ConversationNodeContext, ): WorkflowRunChatData | undefined { @@ -126,7 +116,6 @@ function projectWorkflow( const projectedPhases = [...phases].map(([key, phase]) => ({ key, phase: phase.phase, - status: aggregateStatus(phase.members), members: phase.members, })) return { @@ -134,13 +123,18 @@ function projectWorkflow( status: state.stopReason === undefined ? interrupted ? 'interrupted' : 'running' : statusFromStopReason(state.stopReason), - memberCount: state.members.length, phases: projectedPhases, } } function updateAgentStart(state: WorkflowState, data: ToolWorkflowAgentStartData): WorkflowState { - return { ...state, members: [...state.members, data] } + const member: WorkflowMemberState = { + seq: data.seq, + label: data.label, + ...data.phase === undefined ? {} : { phase: data.phase }, + childId: data.childId, + } + return { ...state, members: [...state.members, member] } } function updateAgentEnd(state: WorkflowState, data: ToolWorkflowAgentEndData): WorkflowState { diff --git a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx index 3b7a2b3f79..8e3019df19 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx @@ -107,14 +107,13 @@ describe('workflow-run Conversation Definition', () => { expect(data).toEqual({ name: 'audit', status: 'failed', - memberCount: 2, phases: [ { - key: 'value:0:', phase: '', status: 'completed', + key: 'value:0:', phase: '', members: [{ seq: 1, label: 'first', childId: 'child-1', status: 'completed' }], }, { - key: 'missing', phase: null, status: 'failed', + key: 'missing', phase: null, members: [{ seq: 2, label: 'second', childId: 'child-2', status: 'failed' }], }, ], @@ -167,7 +166,7 @@ describe('workflow-run Conversation Definition', () => { at(4, 'tool-workflow/run-end', { runId: 'empty', stopReason: 'completed' }), ]) expect(workflowData(value)).toEqual({ - name: 'empty', status: 'completed', memberCount: 0, phases: [], + name: 'empty', status: 'completed', phases: [], }) }) @@ -187,7 +186,7 @@ describe('workflow-run Conversation Definition', () => { ]) expect(workflowData(cancelled)).toMatchObject({ status: 'cancelled', - phases: [{ phase: 'Research', status: 'cancelled', members: [{ status: 'cancelled' }, { status: 'completed' }] }], + phases: [{ phase: 'Research', members: [{ status: 'cancelled' }, { status: 'completed' }] }], }) const interruptedTurn = assembler([ @@ -254,7 +253,6 @@ function node(data: WorkflowRunChatData): WorkflowRunPanelProps['node'] { const phase = (overrides: Partial = {}): WorkflowRunChatData['phases'][number] => ({ key: 'missing', phase: null, - status: 'running', members: [{ seq: 1, label: 'worker', childId: 'child-1' as SessionId, status: 'running' }], ...overrides, }) @@ -303,7 +301,7 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi describe('WorkflowRunPanel', () => { it('defaults running runs open, terminal history closed, and keeps the current choice across data updates', () => { const running: WorkflowRunChatData = { - name: 'audit', status: 'running', memberCount: 1, phases: [phase()], + name: 'audit', status: 'running', phases: [phase()], } const view = render() expect(screen.getByText('未分阶段')).toBeTruthy() @@ -321,7 +319,7 @@ describe('WorkflowRunPanel', () => { it('supports root keyboard disclosure and renders a zero-member running state', () => { render() const header = screen.getByRole('button', { name: /^keyboard/ }) @@ -344,14 +342,14 @@ describe('WorkflowRunPanel', () => { cleanup() render() expect(screen.getByText('没有启动成员')).toBeTruthy() }) it('keeps phase disclosure independent and preserves empty versus absent names', () => { render( { it('covers the Figma completed, failed/cancelled, and interrupted state boards', () => { const completed: WorkflowRunChatData = { - name: 'repo-audit', status: 'completed', memberCount: 1, + name: 'repo-audit', status: 'completed', phases: [phase({ - status: 'completed', members: [{ seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' }], })], } @@ -387,9 +384,8 @@ describe('WorkflowRunPanel', () => { completedView.unmount() const mixed: WorkflowRunChatData = { - name: 'repo-audit', status: 'failed', memberCount: 2, + name: 'repo-audit', status: 'failed', phases: [phase({ - status: 'failed', members: [ { seq: 1, label: 'failed', childId: 'child-1' as SessionId, status: 'failed' }, { seq: 2, label: 'cancelled', childId: 'child-2' as SessionId, status: 'cancelled' }, @@ -407,17 +403,16 @@ describe('WorkflowRunPanel', () => { mixedView.unmount() const interrupted: WorkflowRunChatData = { - name: 'repo-audit', status: 'interrupted', memberCount: 2, + name: 'repo-audit', status: 'interrupted', phases: [ phase({ - status: 'interrupted', members: [ { seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' }, { seq: 2, label: 'interrupted', childId: 'child-2' as SessionId, status: 'interrupted' }, ], }), phase({ - key: 'interrupted-only', phase: 'Interrupted only', status: 'interrupted', + key: 'interrupted-only', phase: 'Interrupted only', members: [{ seq: 3, label: 'interrupted', childId: 'child-3' as SessionId, status: 'interrupted', }], @@ -433,7 +428,7 @@ describe('WorkflowRunPanel', () => { it('opens only a running ordinary-list subagent proven to have this parent', () => { const data: WorkflowRunChatData = { - name: 'audit', status: 'running', memberCount: 1, phases: [phase()], + name: 'audit', status: 'running', phases: [phase()], } const openSession = vi.fn() render() @@ -459,9 +454,8 @@ describe('WorkflowRunPanel', () => { ['member terminal', listState(), 'completed'], ] as const)('does not navigate when %s', (_name, sessions, memberStatus) => { const data: WorkflowRunChatData = { - name: 'audit', status: 'running', memberCount: 1, + name: 'audit', status: 'running', phases: [phase({ - status: memberStatus === 'running' ? 'running' : 'completed', members: [{ seq: 1, label: 'worker', childId: 'child-1' as SessionId, status: memberStatus, }], diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index b815a776c8..b479e6c9fc 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -17,8 +17,7 @@ import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { JsonValue, Session, SessionEventMap } from '@deepseek-ai/dsh-session' import type { - WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowResult, WorkflowRun, - WorkflowRunId, WorkflowRunInfo, WorkflowStopReason, + WorkflowResult, WorkflowRun, WorkflowRunId, WorkflowStopReason, } from '@deepseek-ai/dsh-workflow' import type { ToolWorkflowAgentEndData, ToolWorkflowAgentStartData, @@ -45,14 +44,10 @@ export const Config: z = z.object({ type ResolvedConfig = Required -type BufferedWorkflowEvent = - | { readonly kind: 'agent-start'; readonly info: WorkflowRunInfo; readonly agent: WorkflowAgentInfo } - | { readonly kind: 'agent-end'; readonly info: WorkflowRunInfo; readonly agent: WorkflowAgentEndInfo } - interface WorkflowRecorder { - bind(run: WorkflowRun): void - finish(stopReason: WorkflowStopReason): void - dispose(): void + start(session: Session, run: WorkflowRun): void + finish(runId: WorkflowRunId, stopReason: WorkflowStopReason): void + abandon(runId: WorkflowRunId): void } interface ToolWorkflowRecordEventMap { @@ -72,84 +67,66 @@ function renderRecordingError(error: unknown): string { } /** - * Project one top-level workflow run into its parent Session without letting - * recording failure affect tool execution. Listeners are installed before - * `start()` so even a synchronous provider cannot outrun the recorder. + * Project active top-level workflow runs into their parent Sessions without + * letting recording failure affect tool execution. */ -function createWorkflowRecorder(ctx: Context, session: Session): WorkflowRecorder { - let runId: WorkflowRunId | undefined - let enabled = true - const buffered: BufferedWorkflowEvent[] = [] - // These four package-owned events are all log-only. Narrowing the generic - // append face here lets TypeScript discharge Session.append's conditional - // surface-options tuple once for the complete closed event set. - const appendRecord = session.append.bind(session) as ( - type: Type, - data: SessionEventMap[Type], - ) => void - +function createWorkflowRecorder(ctx: Context): WorkflowRecorder { + const active = new Map() const append = ( + session: Session, type: Type, data: SessionEventMap[Type], - ): void => { - if (!enabled) return + ): boolean => { + // These four package-owned events are all log-only. Narrowing the generic + // append face here discharges Session.append's conditional options tuple. + const appendRecord = session.append.bind(session) as ( + event: Event, + value: SessionEventMap[Event], + ) => void try { appendRecord(type, data) + return true } catch (error: unknown) { - enabled = false ctx.logger.warn(`tool-workflow: disabled durable record after ${type} append failed: ${renderRecordingError(error)}`) + return false } } - const record = (event: BufferedWorkflowEvent): void => { - if (runId === undefined) { - buffered.push(event) - return + ctx.on('workflow/agent-start', (info, agent) => { + const session = active.get(info.id) + if (session === undefined) return + const data: ToolWorkflowAgentStartData = { + runId: info.id, + seq: agent.seq, + label: agent.label, + ...agent.phase === undefined ? {} : { phase: agent.phase }, + childId: agent.childId, } - if (event.info.id !== runId) return - if (event.kind === 'agent-start') { - const data: ToolWorkflowAgentStartData = { - runId, - seq: event.agent.seq, - label: event.agent.label, - ...event.agent.phase === undefined ? {} : { phase: event.agent.phase }, - childId: event.agent.childId, - } - append('tool-workflow/agent-start', data) - return - } - const data: ToolWorkflowAgentEndData = { - runId, - seq: event.agent.seq, - outcome: event.agent.outcome, - } - append('tool-workflow/agent-end', data) - } - - const disposeStart = ctx.on('workflow/agent-start', (info, agent) => { - record({ kind: 'agent-start', info, agent }) + if (!append(session, 'tool-workflow/agent-start', data)) active.delete(info.id) }) - const disposeEnd = ctx.on('workflow/agent-end', (info, agent) => { - record({ kind: 'agent-end', info, agent }) + ctx.on('workflow/agent-end', (info, agent) => { + const session = active.get(info.id) + if (session === undefined) return + const data: ToolWorkflowAgentEndData = { + runId: info.id, + seq: agent.seq, + outcome: agent.outcome, + } + if (!append(session, 'tool-workflow/agent-end', data)) active.delete(info.id) }) return { - bind(run) { - runId = run.id - append('tool-workflow/run-start', { runId, name: run.meta.name }) - for (const event of buffered) record(event) - buffered.length = 0 + start(session, run) { + if (append(session, 'tool-workflow/run-start', { runId: run.id, name: run.meta.name })) { + active.set(run.id, session) + } }, - finish(stopReason) { - /* v8 ignore next -- execute binds every returned run before result settlement can call finish. */ - if (runId === undefined) return - append('tool-workflow/run-end', { runId, stopReason }) - }, - dispose() { - disposeStart() - disposeEnd() - buffered.length = 0 + finish(runId, stopReason) { + const session = active.get(runId) + if (session !== undefined) append(session, 'tool-workflow/run-end', { runId, stopReason }) + active.delete(runId) }, + abandon: (runId) => { active.delete(runId) }, } } @@ -229,6 +206,7 @@ export function apply(ctx: Context, config: Config): void { // schemastery (the exported Config schema) has already filled the defaulted // fields; the assertion records that resolution, not a hidden fallback. const { toolName, maxResultChars } = config as ResolvedConfig + const recorder = createWorkflowRecorder(ctx) // Usage policy ships with the tool (the master convention: tool guidance // lives in tool plugins as prompt sections, not in the deployment persona). ctx.systemPrompt.section({ @@ -303,23 +281,15 @@ export function apply(ctx: Context, config: Config): void { // Meta/body validation failures (META_INVALID/SCRIPT_PARSE) throw // synchronously here and become isError results via the registry — the // model sees the violation list and can correct the call. - const recorder = exec.parent === undefined - ? createWorkflowRecorder(ctx, parent.session) - : undefined - let run: WorkflowRun - try { - run = ctx.workflows.start({ - script: args.script, - meta: args.meta, - ...args.args !== undefined ? { args: args.args } : {}, - parent, - signal: exec.signal, - }) - } catch (error: unknown) { - recorder?.dispose() - throw error - } - recorder?.bind(run) + const run = ctx.workflows.start({ + script: args.script, + meta: args.meta, + ...args.args !== undefined ? { args: args.args } : {}, + parent, + signal: exec.signal, + }) + const recordsRun = exec.parent === undefined + if (recordsRun) recorder.start(parent.session, run) // Bridge the tool's abort signal to the run: if the parent step is aborted while the // script is in flight, cancel the whole run. The signal also enters the engine directly, but @@ -348,9 +318,9 @@ export function apply(ctx: Context, config: Config): void { // synthesize cancelled member endings while reaching quiescence. await run.dispose() /* v8 ignore next -- WorkflowRun.result never rejects by contract, so result is assigned before finally. */ - if (result !== undefined) recorder?.finish(result.stopReason) + if (recordsRun && result !== undefined) recorder.finish(run.id, result.stopReason) } finally { - recorder?.dispose() + if (recordsRun) recorder.abandon(run.id) } } }, diff --git a/packages/workflow/tool-workflow/src/invariant.ts b/packages/workflow/tool-workflow/src/invariant.ts index 5fb14908ca..127b6d8780 100644 --- a/packages/workflow/tool-workflow/src/invariant.ts +++ b/packages/workflow/tool-workflow/src/invariant.ts @@ -19,12 +19,9 @@ interface RunTrace { type WorkflowTrace = Map -/** Clone the independent fold before validating one candidate append. */ -function cloneTrace(source: WorkflowTrace): WorkflowTrace { - return new Map([...source].map(([runId, run]) => [runId, { - ended: run.ended, - members: new Map(run.members), - }])) +/** Whether this package owns the candidate Session event. */ +function isWorkflowRecordEvent(event: SessionEvent): boolean { + return event.type.startsWith('tool-workflow/') } /** Require a durable opaque identity to be a non-empty string. */ @@ -50,6 +47,23 @@ function recordOf(event: SessionEvent, fail: InvariantFailure): Record } +/** Copy only the run one candidate can mutate; other committed states stay shared. */ +function cloneTraceForEvent( + source: WorkflowTrace, + event: SessionEvent, + fail: InvariantFailure, +): WorkflowTrace { + const trace = new Map(source) + if (event.type === 'tool-workflow/run-start') return trace + const data = recordOf(event, fail) + const runId = stringId(data.runId, `${event.type} runId`, fail) + const run = source.get(runId) + if (run !== undefined) { + trace.set(runId, { ended: run.ended, members: new Map(run.members) }) + } + return trace +} + /** Require the named run to exist and remain open. */ function openRun(trace: WorkflowTrace, runId: string, eventType: string, fail: InvariantFailure): RunTrace { const run = trace.get(runId) @@ -60,7 +74,6 @@ function openRun(trace: WorkflowTrace, runId: string, eventType: string, fail: I /** Advance the workflow-record fold with one relevant Session event. */ function applyEvent(trace: WorkflowTrace, event: SessionEvent, fail: InvariantFailure): void { - if (!event.type.startsWith('tool-workflow/')) return const data = recordOf(event, fail) const runId = stringId(data.runId, `${event.type} runId`, fail) @@ -107,6 +120,7 @@ function applyEvent(trace: WorkflowTrace, event: SessionEvent, fail: InvariantFa fail(`tool-workflow/run-end leaves member seq ${openMembers.join(', ')} open in run ${runId}`) } run.ended = true + run.members.clear() return } default: @@ -126,23 +140,23 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): WorkflowTrace => { const trace: WorkflowTrace = new Map() - for (const event of session.events) applyChecked(trace, event, fail) + for (const event of session.events.filter(isWorkflowRecordEvent)) applyChecked(trace, event, fail) traces.set(session, trace) return trace } - /* v8 ignore next -- session/event always follows list() or session/created seeding. */ - const traceFor = (session: Session): WorkflowTrace => traces.get(session) ?? seed(session) - - for (const session of ctx.sessions.list()) seed(session) + ctx.sessions.list().forEach(seed) ctx.on('session/created', (session) => { seed(session) }, { global: true }) ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] - const trace = cloneTrace(traceFor(session)) + if (!isWorkflowRecordEvent(event)) return + // session/event dispatch follows list() or session/created seeding. + const trace = cloneTraceForEvent(traces.get(session) as WorkflowTrace, event, fail) applyChecked(trace, event, fail) staged.set(event, { session, trace }) }, { global: true }) ctx.on('session/event', (session, event) => { + if (!isWorkflowRecordEvent(event)) return const candidate = staged.get(event) /* v8 ignore next 2 -- internal/dispatch stages the exact session/event callback arguments. */ if (candidate === undefined || candidate.session !== session) { diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index ab1fd05a8d..142ca31fba 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -27,7 +27,6 @@ class StubEngine extends WorkflowService { settle!: (result: WorkflowResult) => void readonly settlements = new Map void>() startError: Error | undefined - emitMemberDuringStart = false start(request: WorkflowStartRequest): WorkflowRun { if (this.startError) throw this.startError @@ -35,12 +34,6 @@ class StubEngine extends WorkflowService { const id = WorkflowRunId(`run-${this.requests.length}`) const result = new Promise((resolve) => { this.settle = resolve }) this.settlements.set(id, this.settle) - if (this.emitMemberDuringStart) { - const info = { id, meta: request.meta } - const member = { seq: 1, label: 'synchronous', childId: SessionId('sync-child') } - this.emitWorkflowEvent('workflow/agent-start', info, member) - this.emitWorkflowEvent('workflow/agent-end', info, { ...member, outcome: 'completed' }) - } request.signal?.addEventListener('abort', () => { this.settle({ value: null, stopReason: 'cancelled', error: 'signal', agentsStarted: 0 }) }, { once: true }) @@ -205,23 +198,6 @@ describe('dsh-tool-workflow', () => { ]) }) - it('buffers synchronous member events until start returns the run identity', async () => { - const { ctx, engine, parent, session } = await setup() - engine.emitMemberDuringStart = true - const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent }) - await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) }) - engine.settleRun(WorkflowRunId('run-1'), { - value: null, stopReason: 'completed', agentsStarted: 1, - }) - expect((await pending).isError).toBe(false) - expect(session.events.map(event => event.type)).toEqual([ - 'tool-workflow/run-start', - 'tool-workflow/agent-start', - 'tool-workflow/agent-end', - 'tool-workflow/run-end', - ]) - }) - it('does not record nested transport executions', async () => { const { ctx, engine, parent, session } = await setup() const pending = execute(ctx, { script: SCRIPT, meta: META }, { From fff7dfac8eacc858fd72f6b41becf40bc726216f Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 10 Aug 2026 19:33:24 +0800 Subject: [PATCH 04/31] test(workflow): follow locale settings prerequisites --- packages/client/ui-workflow-run/tests/workflow-run.spec.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx index 8e3019df19..71ca4109a7 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx @@ -478,6 +478,7 @@ describe('plugin lifecycle', () => { it('registers and removes the Definition and keyed renderer with its fiber', async () => { const ctx = new Context() await ctx.plugin(SlotsService).await() + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) await ctx.plugin(ConversationEventRegistry).await() await ctx.plugin(TestSessions).await() ctx.slots.register({ From a7ddded2ef44bc806a96f9ee00806109ee62b130 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 10 Aug 2026 20:56:31 +0800 Subject: [PATCH 05/31] fix(workflow): address ready review findings --- ...apse-workflow-to-foreground-core.i18n.yaml | 4 +- ...12-collapse-workflow-to-foreground-core.md | 12 ++-- ...collapse-workflow-to-foreground-core.zh.md | 12 ++-- .../snapshots/workflow-run/ui.expected.md | 8 +-- .../snapshots/workflow-run/session.jsonl | 2 +- .../src/client/WorkflowRunPanel.tsx | 60 +++++++++++-------- .../ui-workflow-run/src/client/locales.ts | 6 +- .../src/client/workflow-definition.ts | 16 +++-- .../tests/workflow-run.spec.tsx | 4 +- packages/workflow/tool-workflow/src/index.ts | 9 ++- .../workflow/tool-workflow/src/invariant.ts | 9 +-- 11 files changed, 80 insertions(+), 62 deletions(-) diff --git a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml index cc9f18fbef..9ade4e5770 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.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/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md -2026-07-12-collapse-workflow-to-foreground-core.md: 9151d9fb72a97aadf040fbdc13b5e0a4943f2f30 -2026-07-12-collapse-workflow-to-foreground-core.zh.md: c9eafe83e931de7aec4ec39e2471f0669c73609d +2026-07-12-collapse-workflow-to-foreground-core.md: 5fc46584f83eb5307ff16f3353b56951b928aef3 +2026-07-12-collapse-workflow-to-foreground-core.zh.md: 0b4c73e5df973215b10166f3dc2bbd525cc8231b diff --git a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md index 9151d9fb72..5fc46584f8 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md +++ b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md @@ -6,11 +6,15 @@ English | [中文](2026-07-12-collapse-workflow-to-foreground-core.zh.md) ## Problem -The workflow capability carries an observe-only lifecycle beside its execution handle. That surface can look removable because the script still completes without a UI listener, but it is the only provider-neutral source of the actual members that started, their exact labels and phases, and their paired outcomes. +The workflow capability executes foreground JavaScript that composes subagents, but it also carries an unconsumed progress-observation system. No production listener subscribes to any of the six `workflow/*` events; listeners exist only in workflow tests. Nevertheless the seam defines run/phase/agent outcome payloads, the worker sends phase/log/agent lifecycle protocol messages, the host forwards them through a `liveAgents` pairing ledger, and the engine maintains run ids solely to correlate those notifications. -The top-level `dsh-tool-workflow` consumer now uses those events to write four minimal `tool-workflow/*` facts into the calling parent Session, and `ui-workflow-run` rebuilds them into a durable Chat node. The consumer deliberately owns the projection because it alone holds the calling Agent, knows whether the tool execution is top-level, and can keep recording failure separate from workflow execution. `WorkflowRun.id` and `meta` therefore correlate live engine events with that exact durable record rather than duplicating presentation state. +The progress vocabulary is not merely unused; it cannot serve its only named future owner without redesign. `WorkflowRunInfo` contains `{id, meta}` but no parent agent, session, or tool-call identity, while the model-facing tool never exposes the run id. A global ACP listener could not route an event to the correct client session. `meta.phases` is never consulted, `phase(title)` does not validate against it, phase `detail`/`model` and agent `label`/`phase` feed only events, and `whenToUse` is validated and copied but never rendered or selected. `phase()` and `log()` still cross the worker boundary despite having no receiver. -Deleting the event vocabulary, member labels or phases, or run identity would remove the current replay and navigation result rather than merely simplify unused scaffolding. The rejected proposal below remains useful as the contraction to avoid; [durable workflow runs in Chat](../../implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md) owns the present consumer and boundaries. +The live handle repeats event-era data after those observers disappear. `WorkflowRun.id` has no non-event consumer, while the tool reads `run.meta.name` only to render a value it already owns as `args.meta.name`; neither belongs on the execution/cancellation handle. + +Cancellation also has two public channels for one synchronous start. `WorkflowStartRequest.signal` is passed to the worker host, while the sole production caller separately bridges the same signal to `WorkflowRun.cancel()`. Because `start()` returns the run before control can yield, there is no readiness window that requires request-time cancellation; the duplicate signal adds host listener/disarm state without closing a race. + +`WorkflowError.fatal` is the same speculative branch in miniature: every production construction is fatal, `fatal: false` exists only in tests, and combinators already distinguish workflow failures with `instanceof`. ## Proposal @@ -20,7 +24,7 @@ Amend the implemented dynamic-workflow Agent Note and update the seam/tool/worke ## Alternatives considered -**Move durable recording into the workflow engine.** The engine knows run and member lifecycle but does not own the calling parent Session or the top-level-versus-nested tool boundary. Giving it those facts would couple a provider seam to one consumer and make recording failure part of engine execution. The tool-owned projection adds the missing ownership without widening worker messages or the service contract. +**Keep the prebuilt observation vocabulary for a future UI.** The current shape resembles Claude Code dynamic-workflow metadata, and the host deliberately pairs each forwarded agent start with either the worker's end or a synthesized terminal end. Removing it gives up compatibility-by-shape and makes progress UI a new design task, but the existing payloads still lack routable ownership, so balanced lifecycles alone cannot make the named ACP owner viable without redesign. ## Acceptance criteria diff --git a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md index c9eafe83e9..0b4c73e5df 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md @@ -6,11 +6,15 @@ Status: rejected — 工作流进度是有意设计的观测接口面;应通 ## 问题 -工作流能力在执行句柄之外还携带一套只供观察的生命周期。脚本即使没有 UI 监听器也能完成,因此这套界面看似可删除;但它是唯一与提供方无关、能够报告真正开始过的成员、精确标签与阶段以及配对结果的事实来源。 +工作流能力在前台执行用于编排 subagent 的 JavaScript,但它同时携带了一套无人消费的进度观测系统。没有任何生产环境的监听器订阅六个 `workflow/*` 事件中的任何一个;监听器仅存在于工作流测试中。尽管如此,seam 定义了 run/phase/agent(智能体)outcome 载荷,worker 发送 phase/log/agent 生命周期协议消息,host 通过一个 `liveAgents` 配对账本转发它们,引擎维护 run id 仅仅是为了关联这些通知。 -顶层 `dsh-tool-workflow` 消费方现在利用这些事件,把四类最小 `tool-workflow/*` 事实写入调用方父 Session;`ui-workflow-run` 再把它们重建为持久 Chat 节点。投影由消费方拥有,因为只有它同时持有调用 Agent、知道工具执行是顶层还是嵌套,并能让记录故障与工作流执行隔离。`WorkflowRun.id` 与 `meta` 因此用于把实时引擎事件关联到该条精确持久记录,而不是复制展示状态。 +这套进度词汇不仅仅是未被使用;它在不经重新设计的情况下也无法服务于其唯一已命名的未来消费方。`WorkflowRunInfo` 包含 `{id, meta}` 但没有父 agent、会话或工具调用标识,而面向模型的工具也从不暴露 run id。一个全局 ACP(Agent Client Protocol)监听器无法将事件路由到正确的客户端会话。`meta.phases` 从未被查询,`phase(title)` 不对其做校验,phase 的 `detail`/`model` 和 agent 的 `label`/`phase` 仅供事件消费,`whenToUse` 被校验和复制但从未被渲染或用于选择。`phase()` 和 `log()` 仍然跨越 worker 边界,尽管没有接收方。 -删除事件词汇、成员标签或阶段、运行身份,会移除当前回放和导航结果,而不再只是清理未使用脚手架。下方提案继续记录应避免的收缩;[Chat 中的持久工作流运行](../../implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md)拥有当前消费方与边界。 +这些观测者移除后,live handle 仍重复携带事件机制所需的数据。`WorkflowRun.id` 没有非事件消费方,而工具读取 `run.meta.name` 只是为了渲染一个它已经以 `args.meta.name` 形式持有的值;两者都不属于执行/取消 handle。 + +取消机制也为一个同步启动提供了两条公开通道。`WorkflowStartRequest.signal` 被传递给 worker host,而唯一的生产调用方另外将同一个 signal 桥接到 `WorkflowRun.cancel()`。因为 `start()` 在控制权让出之前就返回了 run,不存在需要请求时取消的就绪窗口;重复的 signal 增加了 host 的 listener/disarm 状态却没有封堵任何竞态。 + +`WorkflowError.fatal` 是同一种推测性分支的微缩版:所有生产环境的构造都是 fatal 的,`fatal: false` 仅存在于测试中,组合子已经通过 `instanceof` 区分工作流失败。 ## 提案 @@ -20,7 +24,7 @@ Status: rejected — 工作流进度是有意设计的观测接口面;应通 ## 曾考虑的替代方案 -**把持久记录移入工作流引擎。** 引擎知道运行与成员生命周期,却不拥有调用方父 Session,也不知道顶层与嵌套工具边界。把这些事实交给引擎会让提供方 seam 耦合到单一消费方,并使记录故障进入引擎执行域。由工具拥有的投影补齐了缺失所有权,同时不扩展 worker 消息或 service 合同。 +**为未来 UI 保留预建的观测词汇。** 当前形态类似 Claude Code 的动态工作流元数据,host 有意地将每个转发的 agent start 与 worker 的 end 或一个合成的终止 end 配对。移除它意味着放弃形态兼容性,使进度 UI 成为一项全新的设计任务;但现有载荷仍缺少可路由的归属信息,因此仅靠平衡的生命周期也无法在不重新设计的情况下让已命名的 ACP 消费方可行。 ## 验收标准 diff --git a/apps/web/tests/snapshots/workflow-run/ui.expected.md b/apps/web/tests/snapshots/workflow-run/ui.expected.md index 297aad1b70..be377da995 100644 --- a/apps/web/tests/snapshots/workflow-run/ui.expected.md +++ b/apps/web/tests/snapshots/workflow-run/ui.expected.md @@ -13,12 +13,12 @@ - img - img - text: Tool call workflow · -- button "snapshot-flow 1 members Completed" [expanded]: +- button "snapshot-flow 1 member Completed" [expanded]: - img - - text: snapshot-flow 1 members Completed -- button "Run 1 members Completed 1" [expanded]: + - text: snapshot-flow 1 member Completed +- button "Run 1 member Completed 1" [expanded]: - img - - text: Run 1 members Completed 1 + - text: Run 1 member Completed 1 - text: Reply with exactly the word WF_CHILD_OK and not… Completed - button "Think The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop.": - img diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl index 16d284eb09..75efc1a3e0 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl @@ -28,7 +28,7 @@ {"type":"assistant/chunk","seq":173,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","seq0":174,"time0":1783600640862,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,2,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," workflow"," returned"," successfully"," with"," the"," reply"," \"","WF","_CH","ILD","_OK","\"."," Now"," I"," need"," to"," reply"," with"," exactly"," \"","WORK","FL","OW","_D","ONE","\""," and"," stop","."]}} {"type":"assistant/chunk","seq":204,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":205,"time0":1783600640865,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,1898159500,231656974],"texts":["WORK","FL","OW","_D","ONE"]}} +{"type":"text-chunks","seq0":205,"time0":1783600640865,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,0],"texts":["WORK","FL","OW","_D","ONE"]}} {"type":"assistant/chunk","seq":210,"time":1786359246756,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."}}}} {"type":"assistant/chunk","seq":211,"time":1786359246756,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKFLOW_DONE"}}}} {"type":"assistant/chunk","seq":212,"time":1786359246756,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}}}} diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx index 8e48ffb4be..fcb36da7a3 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx @@ -1,9 +1,9 @@ -import { useMemo, useState } from 'react' +import { useState } from 'react' import { DisclosureRow, IconChevronRightOutline14, StateDot, type StateDotState, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { shallowEqual, type SessionId, type SessionListState } from '@deepseek-ai/dsh-client-runtime/client' import type { WorkflowRunKey } from './locales.ts' import type { WorkflowRunMemberData, WorkflowRunPhaseData, WorkflowRunStatus, @@ -58,6 +58,10 @@ function statusCount( return t(`statusCount.${status}`, { count }) } +function memberCount(count: number, t: WorkflowRunPanelProps['t']): string { + return t(count === 1 ? 'run.members.one' : 'run.members.other', { count }) +} + function phaseStatusSummary(members: readonly WorkflowRunMemberData[], t: WorkflowRunPanelProps['t']): string { const counts = new Map() for (const member of members) counts.set(member.status, (counts.get(member.status) ?? 0) + 1) @@ -71,6 +75,28 @@ function phaseStatusSummary(members: readonly WorkflowRunMemberData[], t: Workfl return visible.map(status => statusCount(status, count(status), t)).join(' · ') } +function navigableMembers( + sessions: SessionListState, + phases: readonly WorkflowRunPhaseData[], + parentId: SessionId, +): readonly SessionId[] { + const ordinary = new Set(sessions.ids) + const result: SessionId[] = [] + for (const phase of phases) { + for (const member of phase.members) { + const summary = sessions.byId[member.childId] + if (member.status === 'running' + && ordinary.has(member.childId) + && summary?.origin === 'subagent' + && summary.parentId === parentId + && summary.running) { + result.push(member.childId) + } + } + } + return result +} + function RunHeader({ count, name, onToggle, open, status, t }: { readonly count: number readonly name: string @@ -95,7 +121,7 @@ function RunHeader({ count, name, onToggle, open, status, t }: { collapsedContent={( <> - {t('run.members', { count })} + {memberCount(count, t)} {t(STATUS_KEYS[status])} @@ -138,7 +164,7 @@ function MemberRow({ member, navigable, openSession, t }: { function PhaseSection({ phase, navigable, openSession, t }: { readonly phase: WorkflowRunPhaseData - readonly navigable: ReadonlySet + readonly navigable: readonly SessionId[] readonly openSession: WorkflowRunInjected['openSession'] readonly t: WorkflowRunPanelProps['t'] }) { @@ -161,7 +187,7 @@ function PhaseSection({ phase, navigable, openSession, t }: { collapsedContent={( <> - {t('run.members', { count: phase.members.length })} + {memberCount(phase.members.length, t)} {phaseStatusSummary(phase.members, t)} )} @@ -171,7 +197,7 @@ function PhaseSection({ phase, navigable, openSession, t }: { @@ -184,25 +210,11 @@ function PhaseSection({ phase, navigable, openSession, t }: { /** Render one durable workflow run with independent run and phase disclosure. */ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t }: WorkflowRunPanelProps) { const [open, setOpen] = useState(() => node.data.status === 'running') - const sessions = useSessions(value => value) const memberCount = node.data.phases.reduce((count, phase) => count + phase.members.length, 0) - const navigable = useMemo(() => { - const ordinary = new Set(sessions.ids) - const result = new Set() - for (const phase of node.data.phases) { - for (const member of phase.members) { - const summary = sessions.byId[member.childId] - if (member.status === 'running' - && ordinary.has(member.childId) - && summary?.origin === 'subagent' - && summary.parentId === sessionId - && summary.running) { - result.add(member.childId) - } - } - } - return result - }, [node.data.phases, sessionId, sessions]) + const navigable = useSessions( + sessions => navigableMembers(sessions, node.data.phases, sessionId), + shallowEqual, + ) return (
= { 'run.title': '{name}', - 'run.members': '{count} members', + 'run.members.one': '{count} member', + 'run.members.other': '{count} members', 'run.empty': 'No members started', 'phase.unassigned': 'Unphased', 'phase.empty': 'Empty phase name', diff --git a/packages/client/ui-workflow-run/src/client/workflow-definition.ts b/packages/client/ui-workflow-run/src/client/workflow-definition.ts index 3a4672d30b..2716988941 100644 --- a/packages/client/ui-workflow-run/src/client/workflow-definition.ts +++ b/packages/client/ui-workflow-run/src/client/workflow-definition.ts @@ -80,8 +80,7 @@ function statusFromOutcome(outcome: WorkflowAgentOutcome): WorkflowRunStatus { } } -function locationClosed(location: ConversationLocation | undefined): boolean { - if (location === undefined) return false +function locationClosed(location: ConversationLocation): boolean { if (location.kind === 'step') { return location.step.status === 'closed' || location.turn.status === 'closed' } @@ -90,11 +89,11 @@ function locationClosed(location: ConversationLocation | undefined): boolean { function projectWorkflow( context: ConversationNodeContext, -): WorkflowRunChatData | undefined { - const state = context.state - if (state === undefined) return undefined + location: ConversationLocation, +): WorkflowRunChatData { + const state = context.state as WorkflowState const interrupted = state.stopReason === undefined - && locationClosed(context.start?.location ?? context.matches[0]?.location) + && locationClosed(location) const phases = new Map() for (const member of state.members) { const phase = member.phase === undefined ? null : member.phase @@ -177,9 +176,8 @@ export const workflowRunDefinition: ConversationNodeDefinition = return context.state }, buildViewNode: (context, target): ChatConversationViewNode | null => { - if (target !== 'chat') return null - const data = projectWorkflow(context) - if (data === undefined || context.start === undefined) return null + if (target !== 'chat' || context.start === undefined) return null + const data = projectWorkflow(context, context.start.location) return { key: context.key, kind: 'workflow-run', diff --git a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx index 71ca4109a7..38e779f961 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx @@ -7,7 +7,7 @@ import { } from '@deepseek-ai/dsh-client-runtime/client' import type { ChatConversationViewNode, ConversationEventInput, ConversationMatch, ConversationNodeDefinition, - ConversationViewDefinition, ConversationViewNode, SessionId, SessionListState, + ConversationViewDefinition, SessionId, SessionListState, } from '@deepseek-ai/dsh-client-runtime/client' import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -517,5 +517,3 @@ describe('plugin lifecycle', () => { expect(registered).toEqual(['@deepseek-ai/dsh-client-ui-workflow-run']) }) }) - -void ({} as ConversationViewNode) diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index b479e6c9fc..ad0ee0e51d 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -289,6 +289,8 @@ export function apply(ctx: Context, config: Config): void { signal: exec.signal, }) const recordsRun = exec.parent === undefined + // The shipped worker-thread engine publishes member events from later + // worker messages, after start() returns and this run record is active. if (recordsRun) recorder.start(parent.session, run) // Bridge the tool's abort signal to the run: if the parent step is aborted while the @@ -317,8 +319,11 @@ export function apply(ctx: Context, config: Config): void { // Keep member listeners alive through disposal: an engine may // synthesize cancelled member endings while reaching quiescence. await run.dispose() - /* v8 ignore next -- WorkflowRun.result never rejects by contract, so result is assigned before finally. */ - if (recordsRun && result !== undefined) recorder.finish(run.id, result.stopReason) + if (recordsRun) { + /* v8 ignore next -- WorkflowRun.result never rejects by contract, so result is assigned before finally. */ + if (result === undefined) throw new Error('workflow run settled without a result') + recorder.finish(run.id, result.stopReason) + } } finally { if (recordsRun) recorder.abandon(run.id) } diff --git a/packages/workflow/tool-workflow/src/invariant.ts b/packages/workflow/tool-workflow/src/invariant.ts index 127b6d8780..549b317379 100644 --- a/packages/workflow/tool-workflow/src/invariant.ts +++ b/packages/workflow/tool-workflow/src/invariant.ts @@ -128,11 +128,6 @@ function applyEvent(trace: WorkflowTrace, event: SessionEvent, fail: InvariantFa } } -/** Apply one cold-load or live-append candidate through the package reporter. */ -function applyChecked(trace: WorkflowTrace, event: SessionEvent, fail: InvariantFailure): void { - applyEvent(trace, event, fail) -} - /** Install an independent incremental fold over every attached Session. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { const traces = new WeakMap() @@ -140,7 +135,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): WorkflowTrace => { const trace: WorkflowTrace = new Map() - for (const event of session.events.filter(isWorkflowRecordEvent)) applyChecked(trace, event, fail) + for (const event of session.events.filter(isWorkflowRecordEvent)) applyEvent(trace, event, fail) traces.set(session, trace) return trace } @@ -152,7 +147,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant if (!isWorkflowRecordEvent(event)) return // session/event dispatch follows list() or session/created seeding. const trace = cloneTraceForEvent(traces.get(session) as WorkflowTrace, event, fail) - applyChecked(trace, event, fail) + applyEvent(trace, event, fail) staged.set(event, { session, trace }) }, { global: true }) ctx.on('session/event', (session, event) => { From f98a95023d98e8db6cee7ffca597922afd85f367 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 17:50:37 +0800 Subject: [PATCH 06/31] Route documentation home to quick start --- ...13-documentation-site-projection.i18n.yaml | 4 +-- ...026-07-13-documentation-site-projection.md | 2 +- ...-07-13-documentation-site-projection.zh.md | 2 +- ...-07-22-product-first-root-readme.i18n.yaml | 4 +-- .../2026-07-22-product-first-root-readme.md | 4 +-- ...2026-07-22-product-first-root-readme.zh.md | 4 +-- ...11-quickstart-documentation-home.i18n.yaml | 6 ++++ ...026-08-11-quickstart-documentation-home.md | 31 +++++++++++++++++++ ...-08-11-quickstart-documentation-home.zh.md | 31 +++++++++++++++++++ docs/user/index.i18n.yaml | 4 +-- docs/user/index.md | 24 +++----------- docs/user/index.zh.md | 24 +++----------- scripts/project-doc-site.spec.ts | 17 ++++++++-- 13 files changed, 105 insertions(+), 52 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.md create mode 100644 .agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.zh.md diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml index 36f6181c46..b2bed645ba 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.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/process/2026-07-13-documentation-site-projection.md -2026-07-13-documentation-site-projection.md: d9af915754fa6a1df51a27d18d412597472aaa73 -2026-07-13-documentation-site-projection.zh.md: 7d7b4752b8f27d55aae8426a7dc001ce4340e661 +2026-07-13-documentation-site-projection.md: d6c7daf1929c93f61a65e9609b283aa889a0df56 +2026-07-13-documentation-site-projection.zh.md: 45c3ebf68b26be062fd827019d69d33cac346c86 diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md index d9af915754..d6c7daf192 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md @@ -16,7 +16,7 @@ Canonical Markdown remains in the repository tier that owns it. Product-facing g `scripts/project-doc-site.ts` projects the manifest into the ignored `website/.generated/` directory before VitePress starts or builds. The generated tree follows public routes so VitePress navigation, locale detection, and local search share the same route vocabulary. Each page receives an `editSource` frontmatter field pointing to its canonical repository file; the edit-link callback reads only that page data, so public URLs remain independent of the source layout. -Locale home projections retain only the canonical YAML frontmatter. The repository-facing body can keep its H1 and bilingual source links, while the VitePress home theme owns the rendered hero and features and the site navigation owns locale switching. +Locale home projections retain only the canonical YAML frontmatter. The repository-facing body keeps its H1 and bilingual source links, while the frontmatter implements the [locale-preserving quick-start redirect](../simplification/2026-08-11-quickstart-documentation-home.md) and the site navigation owns locale switching. The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a source link under the public `deepseek-ai/deepseek-harness-sdk` home; a repository image is copied into the generated tree and referenced from there ([why](2026-08-06-doc-site-carries-its-images.md)). Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates. diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md index 7d7b4752b8..45c3ebf68b 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md @@ -16,7 +16,7 @@ Status: implemented 在 VitePress 启动或构建之前,`scripts/project-doc-site.ts` 会把 manifest 投影到被忽略的 `website/.generated/` 目录。生成目录树遵循公开路由,使 VitePress 导航、locale 检测和本地搜索使用同一套路由命名。每个页面都会获得一个指向其权威仓库文件的 `editSource` frontmatter 字段;编辑链接回调只读取该页面的数据,因此公开 URL 与源文件布局彼此独立。 -各 locale 的首页投影只保留权威 YAML frontmatter。面向仓库的正文可以保留其 H1 和双语源文件链接,而 VitePress 首页主题负责渲染 hero 与功能区,网站导航负责切换 locale。 +各 locale 的首页投影只保留权威 YAML frontmatter。面向仓库的正文保留其 H1 和双语源文件链接;frontmatter 实现[保持 locale 不变的快速开始重定向](../simplification/2026-08-11-quickstart-documentation-home.md),网站导航负责切换 locale。 投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成公开 `deepseek-ai/deepseek-harness-sdk` 主页下的源文件链接;仓库图片会被拷贝进生成树并从那里引用([原因](2026-08-06-doc-site-carries-its-images.md))。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml index d85aa1fe0c..407247314c 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-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 .agents/notes/implemented/process/2026-07-22-product-first-root-readme.md -2026-07-22-product-first-root-readme.md: 32542a45019d64ed1826d4eb21e68c67c3c3d52e -2026-07-22-product-first-root-readme.zh.md: 8ef6f4b99ca2c935183a225b6357d2d128edb3b0 +2026-07-22-product-first-root-readme.md: bd7fa1458fdef120f9e99b7d6af2872f4be55216 +2026-07-22-product-first-root-readme.zh.md: 5521537e9c8daed8cb2eb494389ff3f0d1eec4d2 diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md index 32542a4501..bd7fa1458f 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md @@ -16,7 +16,7 @@ A note before installation thanks internal testers, states that features and exp The user-surface section adds the ACP automation server and Python/JSON-RPC SDK beside the existing Web, TUI, and headless entries. The installed TUI remains the single `dsh` command; the Web instructions build the active checkout before running `dsh web`, and custom or reused checkout paths stay explicit. These launch paths must remain executable through a real PTY and a production build/HTTP smoke, respectively. The capability paragraph keeps its compact inventory style while adding the shipped PTY, LSP, web, goal, planning, task, sandbox, approval, settings, credentials, session-query, and telemetry families and stating that compositions select subsets. One adjacent bullet records the authoritative-session-log rule because persistence, replay, queries, telemetry, and interfaces depend on it. -Detailed package and service inventories remain at their owning documentation. The English and Chinese README sides share the same technical structure, while their community sections continue to point to the primary channel for each language audience. The documentation website keeps its separate user-guide landing page. +Detailed package and service inventories remain at their owning documentation. The English and Chinese README sides share the same technical structure, while their community sections continue to point to the primary channel for each language audience. The documentation website keeps a separate [quick-start entry route](../simplification/2026-08-11-quickstart-documentation-home.md) instead of presenting another product landing page. ## Alternatives considered @@ -26,7 +26,7 @@ Detailed package and service inventories remain at their owning documentation. T **Use a long marketing page with screenshots, badges, and duplicated tutorials.** Rich media can demonstrate a stable product journey, but it ages separately from commands and source contracts. The root stays compact and links to runnable examples and owned guides. -**Project the root README as the documentation website home page.** A single landing page avoids two narratives, but the website's user guide and the repository's product/developer front door have different navigation and maintenance needs. +**Project the root README as the documentation website home page.** A single landing page avoids two narratives, but the website's user guide and the repository's product/developer front door have different navigation and maintenance needs. The documentation root sends readers to quick start instead. ## Consequences diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md index 8ef6f4b99c..5521537e9c 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md @@ -16,7 +16,7 @@ Status: implemented 用户入口章节在已有的 Web、TUI 和 Headless 入口旁补充 ACP(Agent Client Protocol)自动化服务器和 Python/JSON-RPC SDK。安装后的 TUI 仍只需执行一条 `dsh` 命令;Web 说明要求先构建当前检出,再运行 `dsh web`,并明确处理自定义或复用的检出路径。这两条启动路径必须分别能在真实 PTY 与生产构建/HTTP 冒烟中原样执行。能力段落沿用简洁清单的写法,补充已经交付的 PTY、LSP、Web、目标、规划、任务、沙箱、审批、设置、凭据、会话查询和遥测等能力类别,并说明不同组合只选用其中一部分。相邻的一条列表项说明权威会话日志规则,因为持久化、回放、查询、遥测和各类接口都依赖它。 -包与服务的完整清单仍由各自的归属文档维护。中英文 README 采用相同的技术结构,但社区章节仍分别指向各自语言受众的主要交流渠道。文档网站继续使用独立的用户指南首页。 +包与服务的完整清单仍由各自的归属文档维护。中英文 README 采用相同的技术结构,但社区章节仍分别指向各自语言受众的主要交流渠道。文档网站保留独立的[快速开始入口路由](../simplification/2026-08-11-quickstart-documentation-home.md),不另行呈现产品首页。 ## 考虑过的替代方案 @@ -26,7 +26,7 @@ Status: implemented **使用包含截图、徽章和重复教程的长篇营销页面。** 富媒体能够展示稳定的产品使用路径,但其内容会独立于命令和源码约定而逐渐陈旧。根 README 保持紧凑,并链接到可运行示例和各自维护的指南。 -**将根 README 投影为文档网站首页。** 使用同一个首页可以避免两套叙事,但文档网站的用户指南与仓库面向产品和开发者的入口在导航和维护需求上并不相同。 +**将根 README 投影为文档网站首页。** 使用同一个首页可以避免两套叙事,但文档网站的用户指南与仓库面向产品和开发者的入口在导航和维护需求上并不相同。文档根路由则将读者引导至快速开始。 ## 结果 diff --git a/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.i18n.yaml new file mode 100644 index 0000000000..876a11fb0c --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.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-11-quickstart-documentation-home.md +2026-08-11-quickstart-documentation-home.md: 3fd98843fc0e3e09fc1f4a5623729511aba98def +2026-08-11-quickstart-documentation-home.zh.md: 2e3890f8586488b5a94b236dad595bd23514ab2a diff --git a/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.md b/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.md new file mode 100644 index 0000000000..3fd98843fc --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.md @@ -0,0 +1,31 @@ +# Agent Note: Route documentation roots to quick start + +Status: implemented + +English | [中文](2026-08-11-quickstart-documentation-home.zh.md) + +## Problem + +A separate documentation landing page duplicates product positioning and feature summaries owned by the product landing page. Those parallel claims require synchronization and review without helping readers reach technical instructions. + +## Decision + +Each locale root is a redirect page. `/` sends readers to `./guide/quickstart`, and `/en/` resolves the same relative target to `/en/guide/quickstart`. The relative target preserves the configured `DOCS_BASE` when the site is hosted below an origin path. + +`docs/user/index.md` and `docs/user/index.zh.md` own the redirect as VitePress frontmatter. The [documentation-site projector](../process/2026-07-13-documentation-site-projection.md) publishes only that frontmatter for locale homes, so the canonical Markdown retains its bilingual switcher without rendering a second landing page. The projector test verifies that both locale roots use the same locale-relative quick-start target. + +Product positioning and feature summaries stay outside the documentation site. Guide, development, reference, search, and locale navigation remain available from the quick-start page. + +## Alternatives considered + +**Keep a documentation hero and synchronize its wording.** This preserves a promotional entry page but creates a second product narrative whose claims and terminology can drift from the product landing page. + +**Render a documentation index at the root.** An index repeats the navigation already provided by the site and inserts another choice before the first actionable guide. + +**Copy quick-start content to each locale root.** Two public routes would then own the same tutorial and require another synchronization mechanism. + +**Use origin-absolute redirect targets.** Paths such as `/guide/quickstart` ignore `DOCS_BASE` and fail when the documentation site is hosted below an origin path. + +## Consequences + +Readers entering either locale root immediately reach the quick-start tutorial in that locale. The documentation site gives up a promotional home surface, while the product landing page remains the single owner of positioning and feature summaries. The stable root routes remain valid entry points, and quick-start content retains one canonical source. diff --git a/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.zh.md b/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.zh.md new file mode 100644 index 0000000000..2e3890f858 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 将文档根路由指向快速开始 + +Status: implemented + +[English](2026-08-11-quickstart-documentation-home.md) | 中文 + +## 问题 + +单独的文档首页会重复产品首页所维护的产品定位和功能摘要。这些重复声明需要同步与评审,却不能帮助读者查阅技术操作说明。 + +## 决策 + +每个 locale 根路由都是重定向页面。`/` 将读者导向 `./guide/quickstart`,`/en/` 则把同一相对目标解析为 `/en/guide/quickstart`。当网站托管在源站的子路径下时,相对目标仍会保留配置的 `DOCS_BASE`。 + +重定向由 `docs/user/index.md` 与 `docs/user/index.zh.md` 的 VitePress frontmatter 维护。对于 locale 首页,[文档网站投影器](../process/2026-07-13-documentation-site-projection.md)只发布这段 frontmatter,因此权威 Markdown 保留中英文语言切换行,且不会渲染第二个首页。投影器测试验证两个 locale 根路由都使用相对于各自 locale 的同一快速开始目标。 + +文档网站不承载产品定位和功能摘要。快速开始页面仍提供指南、开发、参考、搜索和 locale 导航。 + +## 考虑过的替代方案 + +**保留文档 hero 并同步其文案。** 这样会保留一个推广入口页,但也会产生第二套产品叙事,其中的声明和术语可能与产品首页逐渐偏离。 + +**在根路由渲染文档索引。** 索引会重复网站已有的导航,并在读者开始首篇操作指南之前插入一次额外选择。 + +**把快速开始内容复制到每个 locale 根路由。** 这样会让两个公开路由同时维护同一篇教程,并需要另一套同步机制。 + +**使用源站绝对路径作为重定向目标。** `/guide/quickstart` 等路径会忽略 `DOCS_BASE`,当文档网站托管在源站的子路径下时将失效。 + +## 结果 + +进入任一 locale 根路由的读者都会立即到达该 locale 的快速开始教程。文档网站放弃推广型首页,产品首页则继续作为产品定位和功能摘要的唯一归属。稳定的根路由仍是有效入口,快速开始内容仍由单一权威来源维护。 diff --git a/docs/user/index.i18n.yaml b/docs/user/index.i18n.yaml index 670a3c8033..30cbd0583b 100644 --- a/docs/user/index.i18n.yaml +++ b/docs/user/index.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/index.md -index.md: bf656e391273b828abe67bc0741f2efe7de957c6 -index.zh.md: 1d4a45a1423604d60ec9fba76431f9b7f0844044 +index.md: 6b7f3f2e3f5a2cc0233ee4785d5495bf6369f025 +index.zh.md: 350185cc98d1cafdac26f48dc377384884eec284 diff --git a/docs/user/index.md b/docs/user/index.md index bf656e3912..6b7f3f2e3f 100644 --- a/docs/user/index.md +++ b/docs/user/index.md @@ -1,23 +1,9 @@ --- -layout: home -hero: - name: DeepSeek Harness - text: Plugin-based Coding Agent - tagline: Built on the Cordis microkernel; everything is a plugin - actions: - - theme: brand - text: Quick start - link: /en/guide/quickstart - - theme: alt - text: Develop plugins - link: /en/develop/basic/ -features: - - title: Microkernel - details: The kernel manages plugin lifecycles, events, and dependencies without containing product-specific capabilities. - - title: Plugin-first - details: Models, tools, sessions, and storage are provided by plugins that work together through events. - - title: Composable - details: Select, replace, or extend capabilities through configuration without modifying the Agent Loop. +layout: false +head: + - - meta + - http-equiv: refresh + content: 0; url=./guide/quickstart --- # DeepSeek Harness diff --git a/docs/user/index.zh.md b/docs/user/index.zh.md index 1d4a45a142..350185cc98 100644 --- a/docs/user/index.zh.md +++ b/docs/user/index.zh.md @@ -1,23 +1,9 @@ --- -layout: home -hero: - name: DeepSeek Harness - text: 插件化 Coding Agent - tagline: 基于 Cordis 微内核,一切皆插件 - actions: - - theme: brand - text: 快速开始 - link: /guide/quickstart - - theme: alt - text: 开发插件 - link: /develop/basic/ -features: - - title: 微内核 - details: 内核只负责插件生命周期、事件通信和依赖管理,不包含具体业务能力。 - - title: 插件化 - details: 模型、工具、会话和存储都由插件提供,并通过事件协作。 - - title: 自由组合 - details: 通过配置选择、替换或扩展能力,不需要修改 Agent Loop。 +layout: false +head: + - - meta + - http-equiv: refresh + content: 0; url=./guide/quickstart --- # DeepSeek Harness diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index a8f339cec7..8cbc56fb7c 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -251,6 +251,19 @@ describe('rewriteMarkdown', () => { }) describe('docsPages locale routes', () => { + it('redirects both locale roots to their locale-relative quick-start page', () => { + const homes = docsPages.filter(page => page.sidebar === null) + expect(homes.map(page => page.route).sort()).toEqual(['en/index.md', 'index.md']) + for (const page of homes) { + const source = readFileSync(resolve(repositoryRoot, page.source), 'utf8') + const projected = projectedPageContent(source, page) + expect(projected).toContain('layout: false') + expect(projected).toContain('http-equiv: refresh') + expect(projected).toContain('content: 0; url=./guide/quickstart') + expect(projected).not.toContain('# DeepSeek Harness') + } + }) + it('publishes every route in both locales and uses every available Chinese counterpart', () => { const byRoute = new Map(docsPages.map(page => [page.route, page])) for (const page of docsPages.filter(page => page.locale === 'root')) { @@ -388,9 +401,9 @@ describe('projectedPageContent', () => { it('omits the source-only body from locale home pages', () => { expect(projectedPageContent( - '---\nlayout: home\nhero:\n name: Harness\n---\n\n# Harness\n\n[English](index.md) | 中文\n', + '---\nlayout: false\nhead:\n - - meta\n - http-equiv: refresh\n content: 0; url=./guide/quickstart\n---\n\n# Harness\n\n[English](index.md) | 中文\n', page(null), - )).toBe('---\nlayout: home\nhero:\n name: Harness\n---\n') + )).toBe('---\nlayout: false\nhead:\n - - meta\n - http-equiv: refresh\n content: 0; url=./guide/quickstart\n---\n') }) it('keeps the full body for ordinary pages', () => { From 59e98ca8dd97c1bc4a1d61ccd183e387749797a0 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 11 Aug 2026 16:54:57 +0800 Subject: [PATCH 07/31] docs: add contribution guide --- CONTRIBUTING.md | 21 +++++++++++++++++++++ README.i18n.yaml | 4 ++-- README.md | 2 ++ README.zh.md | 2 ++ 4 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..c7098ac353 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,21 @@ +# Contributing + +Thank you for your interest in contributing to DeepSeek Harness! + +We deeply believe in the power of open source communities, and that belief has shaped this project from the very beginning. + +DeepSeek Harness is still at an early stage and under active development. We are sorry that we cannot accept external pull requests at the moment. However, contributing code to this repository is far from the only way to help. There are many other ways to get involved: + +- Identify and report issues or bugs in GitHub Discussions. + - Upvote discussions that you would like to bring to the team's attention. We are a very small team and may not be able to reply to every post, but we monitor them and consider them when allocating resources. +- Contribute to the ecosystem: + - Create a plugin that excites you and share it with others. + - Associate your GitHub project with the `dsh-plugin` topic to help others discover your plugin. + - Write blog posts and how-to guides about DeepSeek Harness. + - Answer questions and help other members of the community. + +DeepSeek Harness is designed to be deeply customizable. We do not believe that packages in the official repository are inherently more important than packages created by the community. You may consider this repository an idea, an official showcase, and a source of inspiration, but not a mandate from us. + +We have already seen exciting projects emerge from the community, and we hope to see the ecosystem continue to grow in its own directions. + +Into the unknown. diff --git a/README.i18n.yaml b/README.i18n.yaml index 552fb03a25..3b138ed5dd 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: b2d84672275a4ca996b6ea596c104abfaa52432a -README.zh.md: 747a3c88bc129ad5dd24f5fa150a1661c4db0b11 +README.md: efe171d624be21488daabe20b839e715e8f4673a +README.zh.md: b12765179e55d0d3e134e1bb3c3991f4a63477a0 diff --git a/README.md b/README.md index b2d8467227..efe171d624 100644 --- a/README.md +++ b/README.md @@ -88,3 +88,5 @@ DeepSeek Harness is currently in internal testing. [BSD 3-Clause](LICENSE) Third-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). + +Read [CONTRIBUTING.md](CONTRIBUTING.md) before contributing to this repository. diff --git a/README.zh.md b/README.zh.md index 747a3c88bc..b12765179e 100644 --- a/README.zh.md +++ b/README.zh.md @@ -92,3 +92,5 @@ DeepSeek Harness 目前处于内测阶段。 [BSD 3-Clause](LICENSE) 第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。 + +向本仓库贡献前,请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。 From 00c466437033206f631d6ba60569cae9e7d336c9 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 11 Aug 2026 17:15:20 +0800 Subject: [PATCH 08/31] test: refresh translation prompt snapshot --- .../translation-prompt-v4/request-response.expected.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index d4ab49c13c..dc5e3a7fc1 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "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 from source\n\nClone this repo, complete the [dependency and API-key setup](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key), then run:\n\n```sh\npnpm dsh web\n```\n\n## Use DeepSeek Harness\n\n### Web UI\n\nStart the recommended local interface from the repository root:\n\n```sh\npnpm dsh web\n```\n\nThe command builds the repository before starting the Web UI, which is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\nThe source CLI boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\npnpm dsh --profile web # the browser UI\npnpm dsh plugin --profile tui add # install a plugin into a custom profile\npnpm dsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\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\nDeepSeek Harness is currently in internal testing.\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" + "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 from source\n\nClone this repo, complete the [dependency and API-key setup](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key), then run:\n\n```sh\npnpm dsh web\n```\n\n## Use DeepSeek Harness\n\n### Web UI\n\nStart the recommended local interface from the repository root:\n\n```sh\npnpm dsh web\n```\n\nThe command builds the repository before starting the Web UI, which is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\nThe source CLI boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\npnpm dsh --profile web # the browser UI\npnpm dsh plugin --profile tui add # install a plugin into a custom profile\npnpm dsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\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\nDeepSeek Harness is currently in internal testing.\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\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克隆本仓库,完成[依赖安装和 API 密钥配置](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key),然后运行:\n\n```sh\npnpm dsh web\n```\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n请从仓库根目录启动推荐的本地界面:\n\n```sh\npnpm dsh web\n```\n\n该命令会先构建仓库,再启动 Web UI。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n源码 CLI(命令行界面)会启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\npnpm dsh --profile web # the browser UI\npnpm dsh plugin --profile tui add # install a plugin into a custom profile\npnpm dsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/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\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.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克隆本仓库,完成[依赖安装和 API 密钥配置](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key),然后运行:\n\n```sh\npnpm dsh web\n```\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n请从仓库根目录启动推荐的本地界面:\n\n```sh\npnpm dsh web\n```\n\n该命令会先构建仓库,再启动 Web UI。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n源码 CLI(命令行界面)会启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\npnpm dsh --profile web # the browser UI\npnpm dsh plugin --profile tui add # install a plugin into a custom profile\npnpm dsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/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\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n\n向本仓库贡献前,请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。\n" }, { "role": "user", From 2f4bf08798421d66807f8260fe85da0f5c7090c1 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 11 Aug 2026 17:55:48 +0800 Subject: [PATCH 09/31] docs: add Chinese contribution guide --- CONTRIBUTING.i18n.yaml | 6 ++++++ CONTRIBUTING.md | 2 ++ CONTRIBUTING.zh.md | 23 +++++++++++++++++++++++ docs/i18n/README.i18n.yaml | 4 ++-- docs/i18n/README.md | 2 +- docs/i18n/README.zh.md | 2 +- scripts/translation-pairing.spec.ts | 4 ++++ scripts/translation-pairing.ts | 2 ++ 8 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 CONTRIBUTING.i18n.yaml create mode 100644 CONTRIBUTING.zh.md diff --git a/CONTRIBUTING.i18n.yaml b/CONTRIBUTING.i18n.yaml new file mode 100644 index 0000000000..3470fe5932 --- /dev/null +++ b/CONTRIBUTING.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 CONTRIBUTING.md +CONTRIBUTING.md: 9dd90e8e032eb80384047e18d02e07cec6138ee2 +CONTRIBUTING.zh.md: 7d4e8849ab01af8407ccc6e85135dbb37ee2fc32 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c7098ac353..9dd90e8e03 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,7 @@ # Contributing +English | [中文](CONTRIBUTING.zh.md) + Thank you for your interest in contributing to DeepSeek Harness! We deeply believe in the power of open source communities, and that belief has shaped this project from the very beginning. diff --git a/CONTRIBUTING.zh.md b/CONTRIBUTING.zh.md new file mode 100644 index 0000000000..7d4e8849ab --- /dev/null +++ b/CONTRIBUTING.zh.md @@ -0,0 +1,23 @@ +# 贡献 + +[English](CONTRIBUTING.md) | 中文 + +感谢你有兴趣为 DeepSeek Harness 作出贡献! + +我们深信开源社区的力量,这份信念从项目最初就塑造着 DeepSeek Harness。 + +DeepSeek Harness 仍处于早期阶段,并在积极开发中。很抱歉,我们目前无法接受外部 PR(Pull Request)。但贡献代码远不是帮助这个仓库的唯一方式。你还可以通过许多其他方式参与其中: + +- 在 GitHub Discussions 中发现并报告问题或 bug。 + - 为你希望引起团队关注的讨论投票。我们的团队规模很小,可能无法回复每个帖子,但我们会持续关注,并在分配资源时将这些讨论纳入考虑。 +- 为生态系统作出贡献: + - 创建令你感兴趣的插件,并分享给其他人。 + - 为你的 GitHub 项目添加 `dsh-plugin` topic,帮助其他人发现你的插件。 + - 撰写有关 DeepSeek Harness 的博客文章和操作指南。 + - 回答问题并帮助其他社区成员。 + +DeepSeek Harness 的设计支持深度定制。我们不认为官方仓库中的包在本质上比社区创建的包更重要。你可以将这个仓库视为一种思路、一个官方展示和一项灵感来源,而不是我们要求社区遵循的方向。 + +我们已经看到社区中涌现出令人期待的项目,也希望生态系统继续沿着自己的方向发展。 + +向未知进发。 diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index 087e9e9dfe..334be9e2cf 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: 9875eb0c9924daa0b519923e9aac8a67de8cda61 -README.zh.md: eed73226dffd9bc1f6af7b21af5b0b77363878e2 +README.md: 23400801426f77dae5136406cd747dbe4b06a4c5 +README.zh.md: fe3cc7b5a5403fc9cf0c9ce536178d4fa7581e3c diff --git a/docs/i18n/README.md b/docs/i18n/README.md index 9875eb0c99..2340080142 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -41,7 +41,7 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co ## Scope and exclusions -**Scope**: every non-vendor README, plus 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. +**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. Generated 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. diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index eed73226df..fe3cc7b5a5 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -41,7 +41,7 @@ ## 范围与排除 -**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。 +**范围**:根目录 CONTRIBUTING 文档、除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。 有经评审中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。 diff --git a/scripts/translation-pairing.spec.ts b/scripts/translation-pairing.spec.ts index b5b2a1d5a7..a77dd98248 100644 --- a/scripts/translation-pairing.spec.ts +++ b/scripts/translation-pairing.spec.ts @@ -182,6 +182,9 @@ describe('translation pairing records', () => { describe('translation scope discovery', () => { it.each([ 'README.md', + 'CONTRIBUTING.md', + 'CONTRIBUTING.zh.md', + 'CONTRIBUTING.i18n.yaml', 'apps/cli/README.md', 'future/subtree/readme.md', 'packages/example/README.zh.md', @@ -195,6 +198,7 @@ describe('translation scope discovery', () => { it.each([ 'packages/example/guide.md', + 'packages/example/CONTRIBUTING.md', 'examples/tutorial.md', 'website/reference.md', 'packages/example/README.txt', diff --git a/scripts/translation-pairing.ts b/scripts/translation-pairing.ts index 01a9208427..ef94d84e2c 100644 --- a/scripts/translation-pairing.ts +++ b/scripts/translation-pairing.ts @@ -125,6 +125,7 @@ export interface TranslationPairingManifest { } const README_ARTIFACT = /(?:^|\/)readme(?:\.md|\.zh\.md|\.i18n\.yaml)$/i +const ROOT_CONTRIBUTING_ARTIFACT = /^contributing(?:\.md|\.zh\.md|\.i18n\.yaml)$/i const NON_SOURCE_DIRECTORIES = new Set([ 'node_modules', 'lib', @@ -179,6 +180,7 @@ function isTranslationSourceExcluded(file: string): boolean { export function isTranslationScopeFile(file: string): boolean { return !file.startsWith('.agents/notes/archived/') && !isTranslationSourceExcluded(file) && (README_ARTIFACT.test(file) + || ROOT_CONTRIBUTING_ARTIFACT.test(file) || file.startsWith('.agents/notes/') || file.startsWith('docs/') || file.startsWith('python/')) From a11081ac840b4029134d9368fb8e49391bfbc4b3 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 11 Aug 2026 18:09:16 +0800 Subject: [PATCH 10/31] test: refresh contribution pairing snapshot --- .../translation-prompt-v4/request-response.expected.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index dc5e3a7fc1..0160c63cf2 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -24,11 +24,11 @@ }, { "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**: every non-vendor README, plus 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.\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 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `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**范围**:除 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)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。\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", From 8a6c3736e4adb7da1db40eb6d3ab26a1ee0762a2 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 11 Aug 2026 18:16:29 +0800 Subject: [PATCH 11/31] docs: separate contribution links from license --- README.i18n.yaml | 4 ++-- README.md | 2 ++ README.zh.md | 2 ++ .../translation-prompt-v4/request-response.expected.json | 4 ++-- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.i18n.yaml b/README.i18n.yaml index 3b138ed5dd..a7f0956a0a 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: efe171d624be21488daabe20b839e715e8f4673a -README.zh.md: b12765179e55d0d3e134e1bb3c3991f4a63477a0 +README.md: 9c19dfec19cba6f1364e4f9d5734af49675d68c2 +README.zh.md: 31d83ede854e9f0dfbbba1f8ce1094d043f6d829 diff --git a/README.md b/README.md index efe171d624..9c19dfec19 100644 --- a/README.md +++ b/README.md @@ -89,4 +89,6 @@ DeepSeek Harness is currently in internal testing. Third-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). +## Contributing + Read [CONTRIBUTING.md](CONTRIBUTING.md) before contributing to this repository. diff --git a/README.zh.md b/README.zh.md index b12765179e..31d83ede85 100644 --- a/README.zh.md +++ b/README.zh.md @@ -93,4 +93,6 @@ DeepSeek Harness 目前处于内测阶段。 第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。 +## 参与贡献 + 向本仓库贡献前,请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。 diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 0160c63cf2..3e7308ee97 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "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 from source\n\nClone this repo, complete the [dependency and API-key setup](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key), then run:\n\n```sh\npnpm dsh web\n```\n\n## Use DeepSeek Harness\n\n### Web UI\n\nStart the recommended local interface from the repository root:\n\n```sh\npnpm dsh web\n```\n\nThe command builds the repository before starting the Web UI, which is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\nThe source CLI boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\npnpm dsh --profile web # the browser UI\npnpm dsh plugin --profile tui add # install a plugin into a custom profile\npnpm dsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\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\nDeepSeek Harness is currently in internal testing.\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\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 from source\n\nClone this repo, complete the [dependency and API-key setup](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key), then run:\n\n```sh\npnpm dsh web\n```\n\n## Use DeepSeek Harness\n\n### Web UI\n\nStart the recommended local interface from the repository root:\n\n```sh\npnpm dsh web\n```\n\nThe command builds the repository before starting the Web UI, which is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\nThe source CLI boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\npnpm dsh --profile web # the browser UI\npnpm dsh plugin --profile tui add # install a plugin into a custom profile\npnpm dsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\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\nDeepSeek Harness is currently in internal testing.\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克隆本仓库,完成[依赖安装和 API 密钥配置](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key),然后运行:\n\n```sh\npnpm dsh web\n```\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n请从仓库根目录启动推荐的本地界面:\n\n```sh\npnpm dsh web\n```\n\n该命令会先构建仓库,再启动 Web UI。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n源码 CLI(命令行界面)会启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\npnpm dsh --profile web # the browser UI\npnpm dsh plugin --profile tui add # install a plugin into a custom profile\npnpm dsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/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\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\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克隆本仓库,完成[依赖安装和 API 密钥配置](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key),然后运行:\n\n```sh\npnpm dsh web\n```\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n请从仓库根目录启动推荐的本地界面:\n\n```sh\npnpm dsh web\n```\n\n该命令会先构建仓库,再启动 Web UI。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n源码 CLI(命令行界面)会启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\npnpm dsh --profile web # the browser UI\npnpm dsh plugin --profile tui add # install a plugin into a custom profile\npnpm dsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/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\nDeepSeek Harness 目前处于内测阶段。\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", From 8e3418e925ecc4ca7992b64d924201969b370863 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 11 Aug 2026 18:41:10 +0800 Subject: [PATCH 12/31] test(workflow): avoid unbound view builder reference --- .../client/ui-workflow-run/tests/workflow-run.spec.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx index 308de0a2a8..667eabe903 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx @@ -227,13 +227,12 @@ describe('workflow-run Conversation Definition', () => { const unrelated = matched(at(3, 'turn/start', { turn: 1 }), 'update') expect(workflowRunDefinition.update(updateContext, unrelated)).toBe(state) expect(workflowRunDefinition.target).toBe('chat') - const buildViewNode = workflowRunDefinition.buildViewNode - if (buildViewNode === undefined) throw new Error('expected workflow Chat view builder') - expect(buildViewNode({ + expect(workflowRunDefinition.buildViewNode?.({ ...updateContext, matches: [], start: undefined, })).toBeNull() - const directNode = buildViewNode(updateContext) as ChatConversationViewNode | null + const directNode = workflowRunDefinition.buildViewNode?.(updateContext) as ChatConversationViewNode | null | undefined if (directNode === null) throw new Error('expected direct workflow Chat node') + if (directNode === undefined) throw new Error('expected workflow Chat view builder') expect(directNode.kind).toBe('workflow-run') expect((directNode.data as WorkflowRunChatData).status).toBe('running') }) From 7c8ee81818cd68cd3c120ed505c8b665fdcabbb7 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:32:49 +0800 Subject: [PATCH 13/31] docs(agent-note): propose Remote event delivery via ctx.remote.$on An allowlist in api/remotes drives verbatim forwarding of Host cordis events that bind no AgentScope, wrapped in one host/remote-event frame on the existing host stream. Consumers subscribe through ctx.remote.$on, whose listener type is the owner package's own Events declaration, so the payload contract holds by construction rather than through a restated table. --- .../2026-08-10-remote-event-delivery.md | 175 ++++++++++++++++++ .../2026-08-10-remote-event-delivery.zh.md | 175 ++++++++++++++++++ 2 files changed, 350 insertions(+) create mode 100644 .agents/notes/proposed/architecture/2026-08-10-remote-event-delivery.md create mode 100644 .agents/notes/proposed/architecture/2026-08-10-remote-event-delivery.zh.md diff --git a/.agents/notes/proposed/architecture/2026-08-10-remote-event-delivery.md b/.agents/notes/proposed/architecture/2026-08-10-remote-event-delivery.md new file mode 100644 index 0000000000..d6e6555263 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-08-10-remote-event-delivery.md @@ -0,0 +1,175 @@ +# Agent Note: Remote event delivery (ctx.remote.$on) + +Status: proposed + +English | [中文](2026-08-10-remote-event-delivery.zh.md) + +## Problem + +[TypeRT Gateway targeted method calls](2026-08-02-typert-remote-method-calls.md) cover only the request/response shape and deliberately leave Session event streams and stateful interactions to separate designs. Every **one-way Host-to-consumer push** therefore still rides the legacy API Proxy. + +The Host owns a family of pure invalidation events — "a registry changed, refetch it" — whose payloads are already JSON and whose emission never binds an AgentScope: `commands/change`, `credentials/updated`, `settings/document-updated`. Reaching one UI subscriber takes four hops: the Host cordis event, a hand-written `HostFrame` variant plus its zod branch in apiproxy, a hand-written bridge in client/runtime that re-emits it as a Client cordis event, and finally the consumer's `ctx.on(...)`. Adding one such event edits five places (frame union, zod union, host-stream listener, client bridge, a duplicated Client-side `Events` declaration), and not one of them states a new fact: the name, the payload type, and the emission point were all declared by the owner package's cordis `Events` merge. + +That duplicated declaration is also **lossy**: the Client side restates it as `settings/changed(ns: string)`, flattening a branded type into bare `string` — the opposite of the Remote method contract, where a consumer type points at the business package's one canonical symbol. + +## Proposal + +Add one one-way subscription verb to the consumer Remote surface, `ctx.remote.$on(event, listener)`, driven by an allowlist and forwarding verbatim: + +- `packages/api/remotes/src/types.ts` holds the allowlist of forwardable Host events, and it is the single control point over what a consumer may subscribe to. That file is listed in the `files` of **both** of this package's faces, so the Host forwarding loop and the consumer key surface read one declaration. +- The wire event name **is** the Host cordis event name (`settings/document-updated`) with no `host/` prefix, and the payload **is** the Host argument list, element for element, with no projection, redaction, or renaming. +- The carrier reuses the existing host stream: `HostFrame` gains one wrapper variant, `host/remote-event`. No new downlink. +- Event **signatures** get no second table. Each owner package moves its cordis `Events` declaration into its client-safe, type-only `./types` export, so both faces read the same declaration and `$on`'s listener type is `Events[Event]` itself. "Verbatim" then holds by construction rather than by proof. +- Only cordis's *type shape* is borrowed, not its event system: delivery semantics, the subscription registry, and failure containment belong to TypeRT. + +When an `Events` entry's signature reaches a Host-only symbol (a Service, `Agent`, a Context), the answer is to **split the code until the entry lands cleanly in `./types`** — never a declaration half-left in `index.ts`, and never a structurally equivalent shadow type in `./types`. None of the three packages needed that here: their entries reach only `SettingsNamespace`, `SettingsUpdateSource`, and `CredentialRef`, all pure types. + +This change migrates the three **pure passthrough** events and deletes their `HostFrame` variants. Everything with derivation stays untouched: `host/models-changed` (a fan-in of `llm/adapters-updated` with provider/agent-default namespace filtering), `host/workspace-changed`/`-removed`/`host/archived-sessions-changed` (view derivation plus per-connection dedup state), and `host/session-added`/`-removed`/`host/session-status`/`host/agent-error` (live-object projection or frame-time derived fields). + +`skills/change`, `tools/change`, and `system-prompt/change` have the same shape but **no consumer today**; under "require a current owner and need" they stay out of the allowlist and are recorded here only as the extension seat. + +### Consumer contract (dsh-type-meta) + +type-meta gains one **shape predicate**, one **selection seat**, and **one** member on `TypeRTClientRemote`. No runtime code: + +```ts +/** Cordis events shaped for one-way remote delivery: no Scope binding, void return. */ +export type TypeRTForwardableEvent = { + [Event in keyof Events]: unknown extends ThisParameterType + ? ReturnType extends void ? Event : never + : never +}[keyof Events] + +/** The Host assembly's forwarding selection; api/remotes' allowlist fills it, no other package does. */ +export interface TypeRTRemoteEventSelection {} + +/** `$on`'s legal keys: selected, and present in the current compilation face. */ +export type TypeRTRemoteEvent = Extract +``` + +```ts +/** Subscribe to one forwarded Host event; the returned disposer belongs to the calling fiber. */ +$on(event: Event, listener: Events[Event]): () => void +``` + +`Events` resolves per program: the full Host vocabulary in the Host program, whatever the Client face can see in the Client program. The same predicate therefore holds on both sides without dragging Host declarations into the Client. + +**The consumer surface has `$on` and no `$dispatch`.** The port that hands a decoded frame to the subscription table stays out of the developer-visible contract, and it cannot be a module-level function reaching across Client plugins: the client bundle purity gate (`packages/client/tsdown.client.ts`) admits value imports only from `CLIENT_EXTERNALS`, the `INLINE_SAFE` wire layer, and generated `/remote` contributions — and inlining around it would copy `ClientRemoteService` into the runtime bundle, making `instanceof` permanently false. + +The port is therefore **one internal Client cordis event**, declared in `dsh-type-meta` (a single-face package both sides already depend on, so no new dependency): + +```ts +'remote/host-event'(event: string, args: readonly unknown[]): void +``` + +client/runtime — the owner of the host frame sink — emits it; `ClientRemoteService` is the only subscriber and turns it into `$on` callbacks through a private `dispatch`. This is the repository's existing cross-plugin plumbing shape: `connection/reset` is declared and emitted by runtime, subscribed by `ui-command`, and pinned by `runtime/tests/wire-events.spec.ts`. The `event` parameter is `string`, not `TypeRTRemoteEvent`: this is a wire boundary, and a name nobody subscribed to is dropped silently. + +Delivery shares no implementation with the cordis event system: one-way only, no waterfall/bail/parallel/serial modes and no `@mode` concept (`ReturnType extends void` is the static expression of that rule), no `this` binding, no `EventOptions`, `prepend`, or priority. Listeners run in registration order, and one that throws is contained and logged — it must never take down the frame pump (the same posture `ConnectionController` already applies to its sinks). + +### The allowlist: one file both faces read + +`packages/api/remotes/src/types.ts` is listed in the `files` of both `tsconfig.host.json` and `tsconfig.client.json`, and is the allowlist's single home: + +```ts +export const API_REMOTE_FORWARDED_EVENTS = [ + 'commands/change', + 'credentials/updated', + 'settings/document-updated', +] as const + +export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number] + +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTRemoteEventSelection extends Record {} +} +``` + +Forwarding one more event is therefore **one line in that array**: the type projection, `$on`'s key surface, and the Host forwarding loop all derive from it. `ctx.remote.$on('slots/changed', …)` (a Client-local event) and `$on('skills/change', …)` (declared but unselected) are both **compile errors**. + +The Host face adds one shape assertion, binding the Host event vocabulary to that same array: + +```ts +API_REMOTE_FORWARDED_EVENTS satisfies readonly TypeRTForwardableEvent[] +``` + +It is an expression statement rather than a named constant, which `noUnusedLocals` would reject (the underscore prefix exempts parameters only). It enforces three things: the **name is real** (the predicate is keyed on `keyof Events`), the event **binds no Scope** (`goal/changed` and kin have a `ThisParameterType` other than `unknown` and drop out — the static expression of "no AgentScope dependency"), and the event is **one-way** (a non-`void` return, i.e. a waterfall/bail shape, drops out). + +**"Verbatim" is proved nowhere because it holds by construction**: `$on`'s listener type comes from the one cordis `Events` declaration in the owner package's `./types`, and Host forwarding reads that same declaration. There is no second declaration that could drift. + +JSON-safety is a runtime concern: before forwarding, apiproxy validates each argument with `dsh-session`'s `isJsonValue` and **throws loudly** when one fails, because that is an allowlist composition mistake rather than untrusted input. + +### Wire contract (apiproxy) + +```ts +| { type: 'host/remote-event'; event: string; args: JsonValue[] } +``` + +The zod branch keeps `args: z.array(z.unknown())`: the frame arrives from `JSON.parse`, so every element is already a JSON value, and the structural contract belongs to the owner package's `Events` declaration — the same posture the existing `session/projection` frame takes with its `value`. + +`events.host()` subscribes by allowlist when the stream opens (each stream owns its disposers, so no broadcast set is needed). **The registration position is part of the contract**: this block must sit *before* the `settings/document-updated` listener. Cordis fires in registration order, and `host/models-changed` is an invalidation frame *derived* from that same Host event; placing the forwarded frame after the derived one flips the relative order of two frames from one emit compared with the previous behavior (two config cases observe it). + +Cordis keys `on` by literal event name, so subscribing from a runtime list erases the handler type once. That is the only type assertion this change introduces; its safety rests on the allowlist predicate and the `isJsonValue` check. + +`api/events.ts` is a wire contract file the browser side also compiles, so every type it references must come from an owner package's **client-safe, type-only subpath**, never the package root. Evidence: importing one type from `@deepseek-ai/dsh-session` root drags the root's `declare module 'cordis' { interface Context { sessions: SessionStore } }` into the Client compilation face and overrides the Client's `ctx.sessions: ISessions`, producing 18 errors in the unrelated `ui-slash` and `ui-conversation`. `JsonValue` therefore needs a re-export from `dsh-session/src/types.ts`. + +### The apps/web browser e2e belong to the Host face + +The `apps/web/tests/**` e2e type-check in the root **`tsconfig.host.json`**: they boot a real harness in-process and read `ctx.apiProxy`, the Host `SessionStore`'s `get`/`create`/`flush`, and `ctx.sessionProjectionCache`. **Driving a browser at runtime does not make a file part of the Client program** — moving them into the Client aggregate immediately produces 21 errors, because one program cannot hold both faces' merges for the same Context key. + +That yields a discipline this design depends on: **when those tests import a value or a type from a Client package, they pull that package's whole project — and every project it references — into the Host build graph**. Four consumers (`ui-settings-general`, `ui-models`, `ui-permission`, `ui-command`) reference `api/remotes`' Client face, and that face cannot compile until Host tsdown has generated `@deepseek-ai/dsh-goal/remote`. The result is a build-order deadlock: Host tsc needs the Client face, which needs the generated artifact, which Host tsdown produces after Host tsc. + +This change therefore **mirrors** the few Client-owned symbols on the test side (`scaffold.ts` exports the mirrored welcome-notice constants; the two chat e2e keep importing `dsh-client-runtime/client` because the `runtime` project is already in the Host graph), which lets those four consumers leave the Host graph. The 15 Client project references in `apps/cli/tsconfig.json` then lose their owner-map role and are deleted as a group. Each mirrored value matches its source verbatim; a drift shows up as a missed selector or an unsuppressed notice, both loud failures. + +### Change inventory + +| Location | Change | +|---|---| +| `dsh-type-meta` | `src/types.ts` gains `TypeRTForwardableEvent`, `TypeRTRemoteEventSelection`, `TypeRTRemoteEvent`, and the `'remote/host-event'` declaration; `TypeRTClientRemote` gains `$on`. Types only, no runtime | +| `api/gateway` Client half | `ClientRemoteService` implements `$on` (subscription table, `ctx.effect` ownership for the calling fiber, registration-order delivery with listener failures contained) and subscribes to `'remote/host-event'`; `dispatch` stays private | +| `api/remotes` | New `src/types.ts` (allowlist, type projection, selection seat) listed in both faces' `files`; a `./types` export with `lib/types/**/*.js` added to `files`; the Host face adds the shape assertion and `import type {}` for the three owner `./types`; the Client half re-exports those three plus `@deepseek-ai/dsh-api-gateway/client`; `./invariant` asserts the runtime relation for allowlisted events (`thisArg === null` and `mode === 'emit'`) | +| Root `tsconfig.base.json` | Three `paths` entries (`dsh-settings/types`, `dsh-credentials/types`, `dsh-api-remotes/types`), all pointing at the **source** plane | +| `dsh-commands` / `dsh-settings` / `dsh-credentials` | The `interface Events` sub-block moves into each package's client-safe `./types` (settings and credentials create that export, moving the brands and pure types with it; `index` keeps re-exporting them and keeps the constructors; `files` gains `lib/types/**/*.js`) | +| `host/apiproxy` | `HostFrame` gains `host/remote-event` and loses `host/commands-changed`/`-settings-changed`/`-credentials-changed` with their zod branches; `events.host()` subscribes by allowlist ahead of the `settings/document-updated` listener and validates through `assertJsonArgs`; the `settings/document-updated` listener stays to keep feeding `host/models-changed` | +| `dsh-session` | `src/types.ts` re-exports `JsonValue` so wire contract files can use the client-safe subpath | +| `client/runtime` | The bridge's three `ctx.emit` branches collapse into `ctx.emit('remote/host-event', frame.event, frame.args)`; the `Events` merge drops `commands/changed`, `settings/changed`, and `credentials/changed` (`models/changed` stays) | +| Five consumers | ui-command / ui-models / ui-settings-general / ui-permission / ui-agent-preset subscribe through `ctx.remote.$on(...)`, following `ui-goal`'s precedent for the type-only facade import and the `'remote'` injection | +| `client/connection` | The fixture's `emitHost` produces `host/remote-event` | +| `apps/web/tests` + `apps/cli` | Client symbols mirrored on the test side (see above); `apps/cli/tsconfig.json` drops its 15 Client project references | + +## Alternatives considered + +**Open a general downlink channel for Remote events** (the push counterpart of `ctx.connection.rpc`, a third WebSocket). This best matches "Connection owns the carrier, the Gateway never touches transport", but it means a new stream in the Host downlink, `WebApiClient`, `ConnectionController`, the fixture, and the web e2e — a cost out of proportion to this change. Reusing the host stream costs a temporary tenancy inside a legacy frame union; when that stream moves, the wrapper moves with it and the consumer contract does not change. + +**Declare a separate `TypeRTRemoteEventMap` in type-meta and let owner packages merge into it.** The consumer key set would equal exactly "events declared remotely deliverable", but every signature would be written a second time outside cordis `Events`, requiring a bidirectional `extends` proof to stop the two from drifting, plus a new type-meta dependency for three owner packages. Sharing the one `Events` declaration makes that equivalence structural, so the table is not created. + +**Have the typert generator project Host `Events` declarations** (codec, `.d.ts`, declaration map, like `/remote`). The generator already analyzes Host events, but it cannot see projection or redaction intent, and it would change the generator and the build surface. Verbatim forwarding needs no projection. + +**Give forwardable events a payload projection function** (a `{ name, project, zod }` forwarding table). This would cover `models-changed`'s fan-in and workspace view derivation in one step, at the cost of hand-aligning projection logic with payload types — the central table the method side just removed. + +**Move the apps/web browser e2e into the Client aggregate.** "Client tests belong to the Client face" looks right and fails immediately with 21 errors: those tests use Host services, and in the Client program `ctx.sessions` is `ISessions`. + +**Split `directory-picker-browse`/`-native` into Host and Client faces** so no Client package reaches the Host graph. The direction is right — they are genuinely unsplit dual-half packages — but it is a separate concern from this capability seam and lands in another owner's packages; recorded as its own follow-up. + +## Acceptance criteria + +- Emitting the three Host events puts one `host/remote-event` frame each on the real host stream, with `event` the Host name and `args` equal element for element (a real composition test). +- The allowlist rejects three candidate classes at the type level: a name that is not an event, a Scope-bound event (`goal/changed`), and an event whose return is not `void`. +- `$on`'s key surface equals the allowlist: `$on('slots/changed', …)` and `$on('skills/change', …)` must both fail to compile. +- `TypeRTClientRemote` has **no** `$dispatch`: the developer-visible contract is `$on` plus the existing `$mount` and generated namespaces. +- A non-JSON-safe argument makes `assertJsonArgs` throw rather than degrade silently; that function is unit-tested directly instead of driving a malformed emit through the event bus. +- `ctx.remote.$on`'s disposer belongs to the calling fiber: disposing the fiber removes the subscription. One throwing listener affects neither its siblings nor later frames. +- For one emit, the forwarded frame and the invalidation frame derived from the same Host event keep the pre-change relative order. +- On the consumer side, `$on('settings/document-updated', …)` resolves `ns` as `SettingsNamespace` — the brand survives the wire. +- The three `HostFrame` variants, the three Client-side `Events` declarations, and the three bridge branches disappear in the same change; `host/models-changed` behavior is unchanged. +- `pnpm run build` passes. + +## Risks + +- **Tenancy inside a legacy frame union.** The new contract temporarily lives in apiproxy's `HostFrame`, so a reader may assume apiproxy owns Remote events. The frame's JSDoc names `api-remotes` as the allowlist owner, and apiproxy's README records the tenancy under known limitations. +- **The shared file breaks api/remotes' face-disjointness contract.** `src/types.ts` belongs to both projects, so each emits an identical declaration into the shared `lib/types`. Content is byte-identical and the `.tsbuildinfo` files stay separate, so this is harmless in practice — but the README's build-boundary section must state the exception and its cause (the `paths` entry points at source). +- **Any Client plugin can `ctx.emit('remote/host-event', …)`** and synthesize a Host event. This is the same exposure `connection/reset` already has for a fabricated reconnect; the Client is one trust domain. Tests pin the event-to-`$on` conversion and do not pretend the port authenticates its source. +- **The allowlist's shape assertion is currently commented out** in `packages/api/remotes/src/index.ts`, together with the allowlist import and the three owner `./types` type-only imports it needs. The three static guarantees described above are therefore inactive right now: adding a Scope-bound or misspelled name would not fail to compile. Restoring it does not change the build graph (those four consumers already left the Host graph) and is required before the pull request. +- **Mirrored test values can drift.** Nothing mechanically checks the Client constants mirrored in `apps/web/tests` against their source; the safety net is only that a drift misses a selector. A grep-level gate forbidding `@deepseek-ai/dsh-client-*` imports under `apps/web/tests` would close this and is not part of this change. +- **The dynamic subscription erases a handler type.** Subscribing by allowlist requires one erasure at `ctx.on(name, …)`; if the predicate is later relaxed, that assertion loses its static backing. +- **Capabilities given up.** No projected or redacted payloads, no Scope-bound events (`agentCtx.remote.$on`), and no replay on reconnect — these are pure invalidation signals, and the existing `connection/reset` already covers refetching after a reconnect. The mux stream's session events, answerable frames, and snapshot baselines stay out of scope. +- **Client packages remain in the Host graph.** Twelve projects (`connection`, `runtime`, `ui-slots`, and kin) still reach it through the unsplit `directory-picker-browse`/`-native` pair and `api/gateway → client/connection`. They compile and no longer implicate api/remotes' Client face, so they do not block this change; the root fix is the follow-up above. diff --git a/.agents/notes/proposed/architecture/2026-08-10-remote-event-delivery.zh.md b/.agents/notes/proposed/architecture/2026-08-10-remote-event-delivery.zh.md new file mode 100644 index 0000000000..2d34b2555e --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-08-10-remote-event-delivery.zh.md @@ -0,0 +1,175 @@ +# Agent Note: Remote 事件投递(ctx.remote.$on) + +Status: proposed + +[English](2026-08-10-remote-event-delivery.md) | 中文 + +## 问题 + +[TypeRT Remote 方法调用](2026-08-02-typert-remote-method-calls.zh.md)只覆盖「一次请求一个结果」的定向调用,明确把 Session 事件流与有状态交互留在别处;Host 向消费端的**单向事件推送**因此仍然全部压在遗留的 API Proxy 上。 + +Host 上一族「注册表变了,重新拉一次」的纯失效事件(`commands/change`、`credentials/updated`、`settings/document-updated`)既不依赖 AgentScope、载荷也本来就是 JSON,却要穿过四跳才能到达一个 UI 订阅者:host cordis 事件 → apiproxy 手写 `HostFrame` 变体 + zod → client/runtime 手写桥 `ctx.emit(...)` → 消费者 `ctx.on(...)`。每加一个这类事件要改 5 处(帧联合、zod 联合、host 流监听、client 桥、client 侧重复的 `Events` 声明),而这 5 处没有一处是在陈述新事实——事件名、载荷类型、发射时机全都由 owner 包早已在 cordis `Events` 里声明过。 + +那份重复声明还是**有损**的:client 侧写成 `settings/changed(ns: string)`,brand 类型在这一跳被拍平成裸 `string`,与 Remote 方法侧「消费端类型指向业务包唯一符号」的既有契约相反。 + +## 提案 + +给消费端 Remote 面补一个单向事件订阅动词 `ctx.remote.$on(event, listener)`;**名单驱动、原样转发**: + +- `packages/api/remotes/src/types.ts` 持有一份可转发 host 事件名单,它同时是「消费端能订阅什么」的唯一控制点。该文件**同时列进本包 host 与 client 两个 face 的 `files`**,两侧读同一份。 +- wire 上的事件名 **就是 host cordis 事件原名**(`settings/document-updated`),不加 `host/` 前缀;载荷 **就是 host 的实参列表**,逐元素原样过 JSON,无投影、无脱敏、无改名。 +- 载体**寄生现有 host 流**:`HostFrame` 加一个包裹帧 `host/remote-event`,不新开下行通道。 +- 事件**签名**不另立表:owner 包把自己的 cordis `Events` 声明搬进 client-safe 的 `./types` 纯类型出口,两侧读**同一份**——`$on` 的 listener 类型就是 `Events[Event]` 本身。「原样」不需要证明,是构造性成立的。 +- 但**只借 cordis 的类型形状,不接 cordis 的事件系统**:投递语义、注册表、异常处置全归 TypeRT 自己。 + +一条 `Events` 条目若签名里够到了 host-only 符号(Service、`Agent`、Context 等),处理方式是**把代码拆到能干净落进 `./types` 为止**;不接受「一半留 index、一半搬走」的分裂声明,也不接受在 `./types` 里造结构等价的影子类型。本次三个包都不需要拆:它们的条目只够到 `SettingsNamespace`、`SettingsUpdateSource`、`CredentialRef`,全是纯类型。 + +本次只迁**纯透传**的三条并删除对应 `HostFrame` 变体;带派生逻辑的一律不动:`host/models-changed`(`llm/adapters-updated` 与 provider/agent-default 命名空间过滤的 fan-in)、`host/workspace-changed`/`-removed`/`host/archived-sessions-changed`(需 view 派生 + 每连接 dedup 状态)、`host/session-added`/`-removed`/`host/session-status`/`host/agent-error`(需活对象投影或帧时派生字段)。 + +`skills/change`、`tools/change`、`system-prompt/change` 是同形状的纯失效事件但目前**没有任何消费者**,按「每个抽象都要有当前 owner 与需求」不进名单,只作为扩展位记录在此。 + +### 消费端契约(dsh-type-meta) + +type-meta 加一个**形状谓词**、一个**选择座位**和 `TypeRTClientRemote` 的**一个**成员;零运行时代码: + +```ts +/** 形状上可以单向远程投递的 cordis 事件名:不绑 Scope,且返回 void。 */ +export type TypeRTForwardableEvent = { + [Event in keyof Events]: unknown extends ThisParameterType + ? ReturnType extends void ? Event : never + : never +}[keyof Events] + +/** Host 装配声明的转发选择;由 api/remotes 的名单一次性填满,其他包不填。 */ +export interface TypeRTRemoteEventSelection {} + +/** `$on` 的合法键:被选中且当前编译面确实存在的事件。 */ +export type TypeRTRemoteEvent = Extract +``` + +```ts +/** 订阅一条被转发的 host 事件;返回的 disposer 归调用方 fiber。 */ +$on(event: Event, listener: Events[Event]): () => void +``` + +`Events` 按程序解析:host 程序里是 host 事件全集,client 程序里是 client 编译面看得见的那些——同一个谓词在两侧各自成立,不需要把 host 声明拖进 client。 + +**消费端只有 `$on`,没有 `$dispatch`。** 帧到订阅表的投递口不进开发者可见契约,也**不能**是一个跨插件的模块级函数:client bundle 纯度门禁(`packages/client/tsdown.client.ts`)只放行 `CLIENT_EXTERNALS`、`INLINE_SAFE` 那层 wire 契约与 `/remote` 生成物三类值导入,而靠 inline 绕过会把 `ClientRemoteService` 复制一份进 runtime bundle、令 `instanceof` 恒假。 + +投递口因此是**一条客户端内部 cordis 事件**,声明在 `dsh-type-meta`(两侧共用的单 face 包,runtime 本来就依赖它,所以零新增依赖): + +```ts +'remote/host-event'(event: string, args: readonly unknown[]): void +``` + +持有 host 帧 sink 的 client/runtime 发射它,`ClientRemoteService` 是唯一订阅者并转成 `$on` 回调(`dispatch` 是私有方法,不进 `TypeRTClientRemote`)。这是仓内既有的跨插件 plumbing 形态——`connection/reset` 就是 runtime 声明+发射、`ui-command` 订阅,并有 `runtime/tests/wire-events.spec.ts` 钉住。`event` 形参是 `string` 而非 `TypeRTRemoteEvent`:这是 wire 边界,收到无人订阅的名字即静默丢弃。 + +投递语义与 cordis 事件系统不共用实现:只有单向投递,没有 waterfall / bail / parallel / serial 模式,也没有 `@mode` 概念(`ReturnType extends void` 是这条纪律的静态表达);不绑 `this`;没有 `EventOptions`、`prepend`、优先级;按注册顺序逐个调用,单个 listener 抛错就地隔离并记日志——它绝不能拖垮帧泵(沿用 `ConnectionController` 对 sink 异常的既有处置)。 + +### 名单:两个 face 共读的一个文件 + +`packages/api/remotes/src/types.ts` 同时列进 `tsconfig.host.json` 与 `tsconfig.client.json` 的 `files`,是名单的**唯一家**: + +```ts +export const API_REMOTE_FORWARDED_EVENTS = [ + 'commands/change', + 'credentials/updated', + 'settings/document-updated', +] as const + +export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number] + +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTRemoteEventSelection extends Record {} +} +``` + +于是**加一个事件只改这一行数组**:类型投影、`$on` 的键面、host 的转发循环全部从它派生。`ctx.remote.$on('slots/changed', …)`(client 本地事件)或 `$on('skills/change', …)`(名单没开)都是**编译错误**。 + +host 半再加一处形状断言,把 host 事件词汇的约束落到同一份名单上: + +```ts +API_REMOTE_FORWARDED_EVENTS satisfies readonly TypeRTForwardableEvent[] +``` + +写成表达式语句而不是命名常量:后者会被 `noUnusedLocals` 判为未使用(下划线前缀只豁免参数)。它卡住三件事:**名字合法**(谓词以 `keyof Events` 为基)、**不绑 Scope**(`goal/changed` 那族的 `ThisParameterType` 不是 `unknown`,被排除——「不依赖 AgentScope」的静态表达)、**单向**(非 `void` 返回的 waterfall/bail 形状被排除)。 + +**「原样」不在任何地方证明,而是构造性成立**:`$on` 的 listener 类型取自 owner 包 `./types` 里那一份 cordis `Events` 声明,host 转发读的是同一份,不存在可以彼此偏离的第二份声明。 + +载荷 JSON-safe 交给运行时:apiproxy 转发前用 `dsh-session` 的 `isJsonValue` 逐元素校验,不合格**抛错 fail loud**(这是名单配置错误,不是外部输入)。 + +### 线协议(apiproxy) + +```ts +| { type: 'host/remote-event'; event: string; args: JsonValue[] } +``` + +zod 侧 `args: z.array(z.unknown())`:帧本身来自 `JSON.parse`,元素必然已是 JSON 值,结构契约由 owner 包的 `Events` 声明承担——与既有 `session/projection` 帧的 `value` 同 posture。 + +`events.host()` 打开时按名单挂监听(host 流每条自持 disposers,无需新增广播集合)。**注册位置是契约的一部分**:这段必须挂在 `settings/document-updated` 监听**之前**。cordis 按注册序触发,而 `host/models-changed` 是由同一条 host 事件**派生**出来的失效帧——转发帧排到派生帧之后会让同一次 emit 的两帧顺序相对改动前颠倒(已被两条 config 用例实测到)。规则:**转发帧必须先于由它派生的失效帧**。 + +cordis `on` 的键是字面量泛型,按动态名单订阅必须在此擦除一次 handler 类型;这是本变更唯一的类型断言点,其安全性由名单谓词与 `isJsonValue` 校验共同承担。 + +`api/events.ts` 是浏览器侧也要编译的 wire 契约文件,所以它引用的每个类型都必须走 owner 包的 **client-safe type-only 子路径**,绝不能走包根出口。实证:从 `@deepseek-ai/dsh-session` 根引一个类型,就把根出口的 `declare module 'cordis' { interface Context { sessions: SessionStore } }` 拖进 client 编译面、把 client 的 `ctx.sessions: ISessions` 顶掉,在完全无关的 `ui-slash` / `ui-conversation` 里炸出 18 条错。`JsonValue` 因此需要 `dsh-session/src/types.ts` 补一条 re-export。 + +### apps/web 的 browser e2e 属于 Host 面 + +`apps/web/tests/**` 那批 e2e 在**根 `tsconfig.host.json`** 做类型检查:它们在进程内起真 harness、直接摸 `ctx.apiProxy`、host `SessionStore.get/create/flush`、`ctx.sessionProjectionCache`。**运行时用浏览器 ≠ 类型上属于 client 程序**——把它们搬进 client 聚合会立刻报 21 条错,因为一个 program 装不下两个 face 对同一个 Context key 的合并。 + +由此得到一条对本设计要紧的连带纪律:**这些测试从客户端包 import 值或类型,会把该包的整个 project——以及它引用的每个 project——拖进 Host 构建图**。`ui-settings-general`/`ui-models`/`ui-permission`/`ui-command` 四个消费者 references `api/remotes` 的 client face,而该 face 必须等 host tsdown 生成 `@deepseek-ai/dsh-goal/remote` 才能编译,于是形成构建期死锁:host tsc → api/remotes client face → `goal/remote` → host tsdown → 排在 host tsc 之后。 + +本次的处置是在测试侧**镜像**所需的客户端符号(`scaffold.ts` 导出镜像后的 welcome-notice 常量,两个 chat e2e 直接引 `dsh-client-runtime/client` 因为 `runtime` 工程本来就在 host 图里),从而让那 4 个消费者离开 host 图;`apps/cli/tsconfig.json` 里 15 条 client 工程引用随之失去 owner-map 职责,一并删除。镜像值与源逐字一致,漂移的表现是选择器失配或通知未被抑制,都是响亮失败。 + +### 改动清单 + +| 位置 | 改动 | +|---|---| +| `dsh-type-meta` | `src/types.ts` 加 `TypeRTForwardableEvent`、`TypeRTRemoteEventSelection`、`TypeRTRemoteEvent` 与 `'remote/host-event'` 声明;`TypeRTClientRemote` 增 `$on`。纯类型,零运行时 | +| `api/gateway` client 半 | `ClientRemoteService` 实现 `$on`(订阅表、`ctx.effect` 归属调用方 fiber、按注册顺序派发并隔离 listener 异常)+ 订阅 `'remote/host-event'`,`dispatch` 保持私有 | +| `api/remotes` | 新增 `src/types.ts`(名单 + 类型投影 + 选择座位),双列进两个 face 的 `files`;`./types` 出口 + `files` 补 `lib/types/**/*.js`;host 半加形状断言并 `import type {}` 三个 owner 包的 `./types`;client 半 `export type {}` 那三个 `./types` 与 `@deepseek-ai/dsh-api-gateway/client`;`./invariant` 断言名单内事件的运行期关系(`thisArg === null` + `mode === 'emit'`) | +| 根 `tsconfig.base.json` | 加 `dsh-settings/types`、`dsh-credentials/types`、`dsh-api-remotes/types` 三条 `paths`,全部指向**源**平面 | +| `dsh-commands` / `dsh-settings` / `dsh-credentials` | `interface Events` 子块移入各自 client-safe 的 `./types`(settings/credentials 新建该出口,brand 与纯类型一并移入,index 继续 re-export 并留住构造器;`files` 补 `lib/types/**/*.js`) | +| `host/apiproxy` | `HostFrame` 增 `host/remote-event`、删 `host/commands-changed`/`-settings-changed`/`-credentials-changed` 三变体及其 zod;`events.host()` 按名单挂监听(位置在 `settings/document-updated` 之前)+ `assertJsonArgs`;`settings/document-updated` 监听保留以继续喂 `host/models-changed` | +| `dsh-session` | `src/types.ts` 补 `export type { JsonValue }`,让 wire 契约文件能走 client-safe 子路径 | +| `client/runtime` | 桥里三条 `ctx.emit` 换成一行 `ctx.emit('remote/host-event', frame.event, frame.args)`;`Events` 声明删 `commands/changed`/`settings/changed`/`credentials/changed`(`models/changed` 保留) | +| 5 个消费者 | ui-command / ui-models / ui-settings-general / ui-permission / ui-agent-preset 改订 `ctx.remote.$on(...)`;照 `ui-goal` 先例 type-only 引 `@deepseek-ai/dsh-api-remotes/client` 并把 `'remote'` 加进 `inject` | +| `client/connection` | fixture 的 `emitHost` 造 `host/remote-event` | +| `apps/web/tests` + `apps/cli` | 客户端符号镜像(见上节);`apps/cli/tsconfig.json` 删 15 条 client 工程引用 | + +## 备选方案 + +**给 Remote 事件新开一条通用下行通道**(`ctx.connection.rpc` 的推送对偶,第三条 WebSocket)。最符合「Connection 独占载体、Gateway 不碰传输」;但要同时改 host 下行、`WebApiClient`、`ConnectionController`、fixture 与 web e2e 各一条流,代价与本次收益不匹配。寄生 host 流的代价是新契约暂时寄居在 legacy 帧联合里——host 流将来整体搬家时它随之搬走,消费端契约不变。 + +**在 type-meta 立一张独立的 `TypeRTRemoteEventMap`,让 owner 包 declare-merge 进去**。消费端键集会精确等于「被声明为可远程投递的事件」;代价是每条事件的签名要在 cordis `Events` 之外**再写一遍**,于是需要一条双向 `extends` 的等价性证明来防漂移,还要给三个 owner 包新增 type-meta 依赖。共用同一份 `Events` 声明让等价性变成构造性成立,这张表因此不立。 + +**让 typert generator 从 host `Events` 声明生成事件投影**(codec + `.d.ts` + 声明映射,与 `/remote` 同族)。generator 已经在分析 host 事件;但它拿不到投影与脱敏语义,且要动生成器与构建面。原样转发这条路本就不需要投影。 + +**给可转发事件加载荷投影函数**(`{ 事件名, 投影, zod }` 转发表)。能一举覆盖 `models-changed` 的 fan-in 与 workspace 的 view 派生;代价是投影逻辑与载荷类型手工对齐,回到方法侧刚刚消灭的中心表形态。 + +**把 apps/web 的 browser e2e 搬进 client 聚合**。看似「客户端测试归客户端面」,实测立刻 21 条错:它们用 host 服务,而 client 程序里 `ctx.sessions` 是 `ISessions`。已否。 + +**给 `directory-picker-browse`/`-native` 做 host/client 双 face 切分**,从根上让客户端包不进 host 图。方向正确(它们确实是未切分的双半包),但与本单的 capability seam 是两件事,且改动落在别人属地——记为独立后续单。 + +## 验收标准 + +- host emit 三条事件后,真实 host 流各出一帧 `host/remote-event`,`event` 为 host 原名、`args` 与实参逐元素相等(真组合测试)。 +- 名单在类型层拒绝三类候选:不存在的事件名、绑 Scope 的事件(`goal/changed`)、非 `void` 返回的事件。 +- `$on` 的键面等于名单:`$on('slots/changed', …)` 与 `$on('skills/change', …)` 都必须编译失败。 +- `TypeRTClientRemote` 上**不存在** `$dispatch`:开发者可见契约只有 `$on`(加既有 `$mount` 与生成的 namespace)。 +- 名单内事件发射非 JSON-safe 实参时,`assertJsonArgs` 抛错而非静默降级(对该函数直接单测,不从事件总线造畸形 emit)。 +- `ctx.remote.$on` 的 disposer 归属调用方 fiber:处置 fiber 后订阅消失。一个 listener 抛错不影响同事件其余 listener,也不中断后续帧投递。 +- 转发帧与由同一条 host 事件派生的失效帧在同一次 emit 里的顺序与改动前逐帧一致。 +- 消费端 `$on('settings/document-updated', …)` 的 `ns` 形参解析为 `SettingsNamespace`(brand 未丢)。 +- 三条 `HostFrame` 变体、client 侧三条 `Events` 声明、client 手写桥的三条分支在同一 PR 内消失;`host/models-changed` 行为不变。 +- `pnpm run build` 全量通过。 + +## 风险 + +- **寄生 legacy 帧联合**:新契约暂时住在 apiproxy 的 `HostFrame` 里,读者会误以为 Remote 事件归 apiproxy 拥有。缓解=帧注释指明名单归 `api-remotes`,并在 apiproxy README 的已知欠账里记这条寄居关系。 +- **共享文件破了 api/remotes 的 face 互斥契约**:`src/types.ts` 同时属于两个 project,两侧各自 emit 一份同名声明到共用的 `lib/types`。内容逐字相同、`.tsbuildinfo` 各自独立,实际无害,但 README 的 Build boundary 节必须写明这条例外及其成因(paths 指向 src)。 +- **任一 client 插件都能 `ctx.emit('remote/host-event', …)` 伪造一条 host 事件**:与 `connection/reset` 可被伪造成重连同一量级(client 是单一信任域)。测试只钉「事件到 `$on` 的转换」,不假装它有来源鉴别。 +- **名单的形状断言当前处于注释态**(`packages/api/remotes/src/index.ts`,连同它所需的名单 import 与三条 owner `./types` 的 `import type {}`),因此本节描述的三条静态保证暂未生效:此刻往名单里塞一个 scoped 事件或拼错的名字不会有编译错误。恢复它对构建图无影响(那四个消费者已不在 host 图里),是 PR 前必做项。 +- **测试侧的镜像会漂移**:`apps/web/tests` 里镜像的客户端常量与源之间没有机械校验,只能靠「漂移即选择器失配」这种响亮失败兜底。理想上该加一条 grep 级门禁禁止 `apps/web/tests` 引入 `@deepseek-ai/dsh-client-*`,本轮未加。 +- **动态订阅的类型擦除**:按名单 `ctx.on(name, …)` 必须擦一次 handler 类型;若名单谓词将来被放宽,这处断言就不再有静态支撑。 +- **放弃的能力**:不支持带载荷投影/脱敏的事件、不支持 Scope 化事件(`agentCtx.remote.$on`)、不支持重连重放(纯失效信号,重连后的重新拉取由既有 `connection/reset` 覆盖)。mux 流的 session 事件、可答帧与快照基线不在范围内。 +- **host 图里仍有客户端包**:`connection`、`runtime`、`ui-slots` 等 12 个工程经 `directory-picker-browse|native`(未切分的双半包)与 `api/gateway → client/connection` 仍在 host 构建图内。它们当前都能编译、且不再牵连 api/remotes 的 client face,所以不阻塞;根治留给上面那条独立后续单。 From b64da061a820771cd7639c667618f3383a7ffac5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:32:49 +0800 Subject: [PATCH 14/31] docs: record the forwarded-event surface and its face boundaries Each owner package's README states which of its cordis events are forwarded and that the declaration now lives in the client-safe ./types export. api/remotes documents the allowlist as the single control point and the deliberate both-faces listing of src/types.ts; development.md keeps the split-tsconfig rule current. --- docs/development.md | 2 +- docs/development.zh.md | 2 +- packages/api/gateway/README.md | 3 +++ packages/api/gateway/README.zh.md | 3 +++ packages/api/remotes/README.md | 12 ++++++++++-- packages/api/remotes/README.zh.md | 14 ++++++++++++-- packages/client/ui-settings-general/README.md | 2 +- packages/client/ui-settings-general/README.zh.md | 2 +- packages/credentials/credentials/README.md | 2 +- packages/credentials/credentials/README.zh.md | 2 +- packages/host/apiproxy/README.md | 5 +++-- packages/host/apiproxy/README.zh.md | 5 +++-- packages/settings/settings/README.md | 2 ++ packages/settings/settings/README.zh.md | 2 ++ packages/typert/type-meta/README.md | 2 ++ packages/typert/type-meta/README.zh.md | 2 ++ 16 files changed, 48 insertions(+), 14 deletions(-) diff --git a/docs/development.md b/docs/development.md index 7d6ca74df1..d5bd18f83f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -59,7 +59,7 @@ Host and Client stay two aggregate programs because both sides declaration-merge - 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. - 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. -`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. 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. +`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. The root build follows the generated dependency order: diff --git a/docs/development.zh.md b/docs/development.zh.md index 1461717cf4..0725cfaad7 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -59,7 +59,7 @@ Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下 - 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。 - 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。 -`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。不要把该结构推广到其他包;[`api-remotes` README](../packages/api/remotes/README.md) 说明 Host/Client 拆分与构建顺序。 +`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 拆分与构建顺序。 根构建按生成依赖排序: diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 0e1a03d201..ffdec7ac4d 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -20,6 +20,8 @@ A cancellation-aware Remote method declares `signal: AbortSignal` as its final H Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before calling Connection. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. +`ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. Delivery is one-way and follows registration order; a listener that throws is logged and isolated from the remaining listeners, which never affects the frame pump. The delivery port is not part of the Remote contract: the Client half owning the Host frame sink emits the internal `remote/host-event` Cordis event, the Remote service is its only subscriber, and an event name nobody subscribes to is dropped, since the wire carries whatever the Host selected. + Generated declaration merges provide the TypeScript API through the shared `TypeRTClientRemote` contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy. ## Model Experience @@ -37,3 +39,4 @@ No direct effect; invoked business Services own any model-visible result. - Only strict generated contributions can mount on the Client face. SRC markers have no Client codec or type projection. - The package dispatches unary methods only. Incremental Session data uses a separate named-stream protocol over the same Connection. - Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key. +- Forwarded events reach `$on` exactly as the Host emitted them: no payload projection or redaction, no Scope-bound subscription, and no replay after a reconnect. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index f8c01b489f..0137eb77db 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -20,6 +20,8 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle 每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 +`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属发起调用的 fiber,并随该 fiber 一起消失。投递是单向的,并按注册顺序进行;抛错的 listener 会被记录并与其余 listener 隔离,绝不影响帧泵。投递口不属于 Remote 约定:持有 Host 帧 sink 的 Client 半发射内部 Cordis 事件 `remote/host-event`,Remote 服务是它唯一的订阅方,收到无人订阅的事件名即丢弃,因为 wire 上出现什么取决于 Host 的转发选择。 + 生成的声明合并通过共享的 `TypeRTClientRemote` 约定提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 ## 模型体验 @@ -37,3 +39,4 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle - Client 侧只能挂载严格模式生成的贡献项。SRC 标记不具备 Client 编解码器或类型投影。 - 该包只分发一元方法。增量会话数据通过同一个 Connection 上独立的具名流协议传输。 - lookup resolver 按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。 +- 被转发的事件原样到达 `$on`:没有载荷投影或脱敏,不支持 Scope 化订阅,重连后也不重放。 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index 567ece0fb5..34c78caf98 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -6,15 +6,23 @@ 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. +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. 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. +## Forwarded Host events + +`src/types.ts` holds `API_REMOTE_FORWARDED_EVENTS`, the allowlist of Host cordis events this application forwards to consumers verbatim — no projection, no redaction, no renaming — and therefore the legal key set of `ctx.remote.$on`. Forwarding one more event is an entry in that array and nothing else: the type projection, the consumer key face, and the Host forwarding loop all derive from it. + +The listener signature is not restated here. Each allowlisted event's cordis `Events` declaration lives in its owner package's client-safe `./types` export (`dsh-commands`, `dsh-credentials`, `dsh-settings`), and both faces of this package pull those declarations in, so "forwarded verbatim" holds by construction rather than by proof. The Host face additionally asserts the list against `TypeRTForwardableEvent`, which rejects a name that is not a declared event, one that binds an AgentScope, and one whose shape is not one-way. + ## Build boundary An ordinary repository package belongs to one TypeScript face: Host packages are registered in the root `tsconfig.host.json`, and Client packages in the root `tsconfig.client.json`. `api-remotes` is the only deliberate exception because its Host entry must participate in the Host TypeRT graph, while `src/client/index.ts` cannot compile until Host tsdown has generated the business packages' `/remote` declarations. -This package's root `tsconfig.json` is only a solution that references `tsconfig.host.json` and `tsconfig.client.json`. The Host aggregate and direct Host consumers reference the former, while the Client aggregate and direct Client consumers reference the latter; the package-root solution must not enter either aggregate's dependency graph. The two projects own disjoint source files and `.tsbuildinfo` files but share the `lib/types` output directory. +This package's root `tsconfig.json` is only a solution that references `tsconfig.host.json` and `tsconfig.client.json`. The Host aggregate and direct Host consumers reference the former, while the Client aggregate and direct Client consumers reference the latter; the package-root solution must not enter either aggregate's dependency graph. The two projects own disjoint source files and `.tsbuildinfo` files but share the `lib/types` output directory, with one deliberate exception: `src/types.ts` is listed in BOTH faces' `files`, because the forwarded-event allowlist is the single control point over what a consumer can receive, and the Host forwarding loop and the Client `ctx.remote.$on` key face must read one declaration rather than two that could drift. + +That exception is not just a `files` entry. The root `tsconfig.base.json` maps `@deepseek-ai/dsh-api-remotes/types` to `src/types.ts` — the source plane, like every other workspace subpath and unlike the generated `/remote` artifacts, which have no `paths` entry and resolve through `exports` to built output. Both faces therefore admit that one source file into their own program and each emits its own `lib/types/types.js`/`.d.ts` over the other's. The two emissions are byte-identical because they compile the same source, and the `.tsbuildinfo` files stay independent. No gate enforces the faces' source-file disjointness — `scripts/project-reference-faces.ts` only checks that a reference into a split project names the matching face — so this paragraph is the only thing standing between the next reader and the conclusion that the double listing is a mistake. The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bundle the Host entry and the later Client tsdown bundle only the browser entry. Ordinary Client plugins remain single Client projects and produce both their Node loader entry and browser bundle during Client tsdown; do not copy this package's split merely because a package has both `src/index.ts` and `src/client/index.ts`. diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index 12add6f8ef..4fff19673f 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -6,15 +6,25 @@ `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。 +当前 Client 组合仅挂载 Goal Remote 贡献。该组合卸载时,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。 +## 转发的 Host 事件 + +`src/types.ts` 持有 `API_REMOTE_FORWARDED_EVENTS`——本应用原样转发给消费端的 Host cordis 事件名单(无投影、无脱敏、无改名),它同时就是 `ctx.remote.$on` 的合法键集。多转发一个事件只需在该数组里加一行:类型投影、消费端键面与 Host 转发循环全部由它派生。 + +监听器签名不在此处重写。名单内每条事件的 cordis `Events` 声明都住在其 owner 包 client-safe 的 `./types` 出口(`dsh-commands`、`dsh-credentials`、`dsh-settings`),本包两个 face 都把那些声明纳入编译面,因此「原样转发」是构造性成立的,不需要另立证明。Host face 还额外把名单断言给 `TypeRTForwardableEvent`:未声明的事件名、绑定 AgentScope 的事件、以及形状不是单向的事件都会在此被拒绝。 + ## 构建边界 仓库中的普通包只属于一个 TypeScript face:Host 包登记在根 `tsconfig.host.json`,Client 包登记在根 `tsconfig.client.json`。`api-remotes` 是唯一刻意拆分的特例,因为它的 Host 入口要参与 Host TypeRT 图,而 `src/client/index.ts` 必须等 Host tsdown 生成业务包的 `/remote` 声明后才能编译。 -本包根 `tsconfig.json` 只是引用 `tsconfig.host.json` 与 `tsconfig.client.json` 的 solution。Host aggregate 和 Host 直接消费方引用前者,Client aggregate 和 Client 直接消费方引用后者;禁止把包根 solution 放进任一 aggregate 的依赖图。两个 project 拥有互不重叠的源码和 `.tsbuildinfo`,但共享 `lib/types` 输出目录。 +本包根 `tsconfig.json` 只是引用 `tsconfig.host.json` 与 `tsconfig.client.json` 的 solution。Host aggregate 和 Host 直接消费方引用前者,Client aggregate 和 Client 直接消费方引用后者;禁止把包根 solution 放进任一 aggregate 的依赖图。两个 project 拥有互不重叠的源码和 `.tsbuildinfo`,但共享 `lib/types` 输出目录——只有一处刻意的例外:`src/types.ts` **同时**列进两个 face 的 `files`,因为转发事件名单是「消费端能收到什么」的唯一控制点,Host 转发循环与 Client 的 `ctx.remote.$on` 键面必须读同一份声明,而不是两份可能彼此漂移的声明。 + +这条例外不止是一行 `files`。根 `tsconfig.base.json` 把 `@deepseek-ai/dsh-api-remotes/types` 映射到 `src/types.ts`——**源平面**,与其余所有 workspace 子路径一致,也与生成的 `/remote` 产物相反(后者没有 `paths` 条目,靠 `exports` 命中构建产物)。于是两个 face 都把这一个源文件收进各自的 program,各自把 `lib/types/types.js`/`.d.ts` 覆写到对方的输出上。两次 emit 编译的是同一份源码,内容逐字相同,而 `.tsbuildinfo` 各自独立。没有任何门禁强制两个 face 的源文件互不重叠——`scripts/project-reference-faces.ts` 只校验「引用一个 split project 必须指到对应 face」——因此挡在下一个读者与「这个双列是写错了」这个结论之间的,只有这一段文字。 + 包内 `clientBundle(..., { hostPhase: true })` 让 Host tsdown 打包 Host 入口,让后续 Client tsdown 只打包 browser 入口。普通 Client 插件仍使用单一 Client project,并在 Client tsdown 阶段一起生成 Node loader 入口和 browser bundle;不得因一个包同时存在 `src/index.ts` 与 `src/client/index.ts` 就复制本包的拆分。 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index ab27e073dc..93bb2b0498 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -6,7 +6,7 @@ Settings ownerless-copy and product-onboarding plugin: registers everything on t A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read. -`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out. +`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while the forwarded `settings/document-updated` event makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out. ## Model Experience diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 16ff5604be..5cf5da2f92 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -6,7 +6,7 @@ 回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权设置读取。 -`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在用户设置 seam 中注册 `ui-onboarding`。回环浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非回环浏览器不能访问受保护的设置 API:它仍会显示通知,但「继续」只推进当前浏览器进程,重新加载后会再次显示通知。版本不同时,系统也会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。 +`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源;GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在用户设置 seam 中注册 `ui-onboarding`。回环浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;转发的 `settings/document-updated` 事件则让页面在通知被外部确认后,无需重新加载即可推进。非回环浏览器不能访问受保护的设置 API:它仍会显示通知,但「继续」只推进当前浏览器进程,重新加载后会再次显示通知。版本不同时,系统也会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式。 ## 模型体验 diff --git a/packages/credentials/credentials/README.md b/packages/credentials/credentials/README.md index 5cfeb8e665..62f5c6318e 100644 --- a/packages/credentials/credentials/README.md +++ b/packages/credentials/credentials/README.md @@ -25,7 +25,7 @@ await ctx.credentials.set(ref, 'sk-…') // rejects while a re await ctx.credentials.unset(ref) // no-op when absent; same shadowing rule ``` -`credentials/updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration UIs refreshing a "configured" badge. +`credentials/updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration UIs refreshing a "configured" badge. Its declaration lives in the client-safe `./types` subpath export together with the `CredentialRef` type it names (the package root re-exports the type), so a consumer outside the Host compilation face reads the very signature the Host emits instead of restating it. The shadowing rule on `set`/`unset` is deliberate fail-loud: when a read-only source (the live process environment, in the local provider) currently supplies the reference, a write would appear to succeed while resolution keeps returning the shadowing value — the seam rejects instead, and `describe().writable` lets a UI render the reference read-only up front. diff --git a/packages/credentials/credentials/README.zh.md b/packages/credentials/credentials/README.zh.md index 28a029f0e4..10a98217b3 100644 --- a/packages/credentials/credentials/README.zh.md +++ b/packages/credentials/credentials/README.zh.md @@ -25,7 +25,7 @@ await ctx.credentials.set(ref, 'sk-…') // rejects while a re await ctx.credentials.unset(ref) // no-op when absent; same shadowing rule ``` -`credentials/updated (ref)` 在提供方管理的来源发生已提交变更后触发——`set`、`unset` 或在存储中观察到的外部编辑。进程环境变量的变化不可观测,永不触发。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。 +`credentials/updated (ref)` 在提供方管理的来源发生已提交变更后触发——`set`、`unset` 或在存储中观察到的外部编辑。进程环境变量的变化不可观测,永不触发。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。它的声明住在 client-safe 的 `./types` 子路径出口,与其点名的 `CredentialRef` 类型同处一处(包根继续 re-export 该类型),于是 Host 编译面之外的消费方读到的正是 Host 发射的那一份签名,而不必再写一遍。 `set`/`unset` 的遮蔽规则是刻意设计为明确报错:当只读来源(本地提供方中即当前进程环境)正在提供该引用时,写入会表面成功而解析仍返回遮蔽值——seam 选择直接拒绝,并通过 `describe().writable` 让界面提前把该引用渲染为只读。 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 2101c785a6..0d3b68c588 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -54,9 +54,9 @@ The `agentPreset.list` domain exposes the deployment's preset roster so a browse `agentPreset.read`, `copy`, `openDocument`, and `remove` manage the compositions themselves. `read` reports the text with its `trust`, for the read-only viewer. Authoring is copy-only: `copy` takes `{ from, agentPreset, name? }` — two ids the Host resolves against its own roots plus an optional display name — and copies the source's whole directory, so no composition text crosses the wire and a copy is exactly as loadable as its source; an uncontainable or already-taken id answers `agent-preset-invalid`, and `remove` refuses a shipped preset as `agent-preset-read-only`. `openDocument` hands one locally authored preset's DIRECTORY to the platform opener — the request carries an id, never a path, so no browser payload can select an arbitrary filesystem target; where the deployment has no native opener the reply is `{ opened: false, path }` for the surface to show as text, a shipped preset is refused like `remove`, and the gateway's `nativeOpen` config pins the capability where platform detection (`canOpenNativePath`) would mislead. These four are loopback-pinned in [`dsh-client-connection`](../../client/connection/README.md): a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop. `list` and `select` stay ordinary — the roster carries ids and trust and every preset picker needs it, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash. `list` reports two path-free capability flags: `authorable`, whether the deployment configures a root a new preset could be copied to, and `hasDocument`, whether `openDocument` would open natively rather than answer a path. -The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only invocation path is the slash gesture. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `` context, so every entry point (Web, TUI, and ACP) shares one deterministic path—including for hand-typed text—with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the registry-wide catalog invalidation frame: clients refetch `command.list` instead of diffing. `host/session-preset-changed` is its per-session counterpart, framed off the logged `agent-preset/selected` commit: recomposing a blank session's agent re-parents its scope without registering anything, so both catalogs that session's composition decides (`command.list`, `skill.list`) go stale with no registry change to announce it. +The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only invocation path is the slash gesture. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `` context, so every entry point (Web, TUI, and ACP) shares one deterministic path—including for hand-typed text—with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `commands/change` rides the forwarded-event frame as the registry-wide catalog invalidation signal: clients refetch `command.list` instead of diffing. `host/session-preset-changed` is its per-session counterpart, framed off the logged `agent-preset/selected` commit: recomposing a blank session's agent re-parents its scope without registering anything, so both catalogs that session's composition decides (`command.list`, `skill.list`) go stale with no registry change to announce it. -The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preferences `locale`, `permission`, `ui-conversation`, and `ui-theme`, and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `locale`, `permission`, `ui-conversation`, `ui-theme`, or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. +The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preferences `locale`, `permission`, `ui-conversation`, and `ui-theme`, and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Invalidations keep every surface converged without polling. `settings/document-updated` and `credentials/updated` ride the verbatim forwarded-event frame (see below), so a raw settings change whose resolved value is unchanged still reaches clients, and a credential invalidation still carries reference names only, never values. `host/models-changed` stays a derived frame of this package's own: it is fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `locale`, `permission`, `ui-conversation`, `ui-theme`, or `ui-onboarding` change emits only its forwarded settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. ## Carrier layer (`/client` + root) @@ -72,6 +72,7 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work +- **Forwarded Remote events are parasitic on this legacy frame union** — `host/remote-event` lives in `HostFrame` so the delivery path could reuse the existing host stream instead of opening a third downlink, which makes it read as if this package owned the Remote event contract. It does not: the allowlist is `dsh-api-remotes`' and the consumer verb is `ctx.remote.$on`. When the host stream moves off this package, the frame moves with it and the consumer contract is unaffected ([rationale](../../../.agents/notes/proposed/architecture/2026-08-10-remote-event-delivery.md)). - **Pending-interaction state is host-side** — the wire uses POST `/api/respond` plus `RpcReceipt`; the table in `src/api-proxy.ts` handles questions only and has no approval entries. - **Reserved seams stay out of `RpcMethodMap`** — `prompt.mode: 'inject'`, `task.list`, and a describe `hostInstanceId` are documented reservations; model discovery uses `llm.models`. An unknown method fails loud at envelope parse rather than getting a not-implemented code. - **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 3ba37967ff..e9bed071e0 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -54,9 +54,9 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `agentPreset.read`、`copy`、`openDocument` 与 `remove` 负责管理组装本身。`read` 返回文本连同它的 `trust`,供只读查看器使用。创作只有复制一种写入:`copy` 接收 `{ from, agentPreset, name? }`——两个由 Host 对照自身根目录解析的 id 加一个可选显示名——并整目录复制来源,因此组装文本不经过传输层,副本与其来源同等可加载;不可约束或已被占用的 id 回答 `agent-preset-invalid`,`remove` 对随附 preset 回答 `agent-preset-read-only`。`openDocument` 把一个本地创作 preset 的**目录**交给平台打开器——请求只携带 id、绝不携带路径,因此没有任何浏览器载荷能选中任意文件系统目标;部署没有原生打开器时回答 `{ opened: false, path }` 供界面以文本展示,随附 preset 与 `remove` 一样被拒绝,而网关的 `nativeOpen` 配置可在平台探测(`canOpenNativePath`)失真处钉死该能力。这四个方法在 [`dsh-client-connection`](../../client/connection/README.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面。`list` 与 `select` 保持为普通方法——名单只携带 id 与信任级别,每个 preset 选择器都需要它;而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash。`list` 报告两个不含路径的能力标志:`authorable`,即部署是否配置了可供复制新 preset 的根目录;`hasDocument`,即 `openDocument` 会原生打开、还是回答一个路径。 -`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的调用路径。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `` 上下文作答,因此所有入口(Web、TUI 与 ACP(Agent Client Protocol))共享同一条确定性路径,手动键入的文本也走该路径,且没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是注册表级目录失效帧:客户端重新拉取 `command.list` 而不是做差分。`host/session-preset-changed` 是它按会话粒度的对应物,由落账的 `agent-preset/selected` 提交点成帧:重组空会话的 agent 只是重新挂接其 scope,不产生任何注册,因此该会话组成所决定的两份目录(`command.list`、`skill.list`)都会失效,却没有任何注册表变化来宣告它。 +`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的调用路径。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `` 上下文作答,因此所有入口(Web、TUI 与 ACP(Agent Client Protocol))共享同一条确定性路径,手动键入的文本也走该路径,且没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`commands/change` 搭乘转发事件帧作为注册表级目录失效信号:客户端重新拉取 `command.list` 而不是做差分。`host/session-preset-changed` 是它按会话粒度的对应物,由落账的 `agent-preset/selected` 提交点成帧:重组空会话的 agent 只是重新挂接其 scope,不产生任何注册,因此该会话组成所决定的两份目录(`command.list`、`skill.list`)都会失效,却没有任何注册表变化来宣告它。 -`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `locale`、`permission`、`ui-conversation` 与 `ui-theme`,以及产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`locale`、`permission`、`ui-conversation`、`ui-theme` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 +`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `locale`、`permission`、`ui-conversation` 与 `ui-theme`,以及产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。失效通知让每个面无需轮询即保持收敛。`settings/document-updated` 与 `credentials/updated` 搭乘原样转发事件帧(见下),因此解析值未变的原始设置变更同样能到达客户端,凭据失效通知也仍然只带引用名、绝不带值。`host/models-changed` 仍是本包自有的派生帧:它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`locale`、`permission`、`ui-conversation`、`ui-theme` 或 `ui-onboarding` 变更只会发出自身的转发 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 ## 载体层(`/client` + 根路径) @@ -72,6 +72,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr ## 已知限制与暂缓事项 +- **转发的 Remote 事件寄居在这套 legacy 帧联合里**:`host/remote-event` 住在 `HostFrame` 中,是为了让投递路径复用现有宿主流、不必新开第三条下行通道,因此读起来像是本包拥有 Remote 事件契约。并非如此:名单归 `dsh-api-remotes`,消费端动词是 `ctx.remote.$on`。将来宿主流整体搬离本包时,该帧随之搬走,消费端契约不受影响([原委](../../../.agents/notes/proposed/architecture/2026-08-10-remote-event-delivery.zh.md))。 - **待处理交互状态位于宿主侧**:wire 使用 POST `/api/respond` 加 `RpcReceipt`;`src/api-proxy.ts` 中的表只处理问题,不包含审批条目。 - **预留 seam 不进入 `RpcMethodMap`**:`prompt.mode: 'inject'`、`task.list` 和描述字段 `hostInstanceId` 都是已记录的预留项;模型发现使用 `llm.models`。未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。 - **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 diff --git a/packages/settings/settings/README.md b/packages/settings/settings/README.md index 7917f38017..b1c32c7f4c 100644 --- a/packages/settings/settings/README.md +++ b/packages/settings/settings/README.md @@ -28,6 +28,8 @@ Subclasses implement `writable`, `load()`, and `persist(ns, section)`, optionall `settings/document-updated (ns, revision)` fires whenever the RAW user section changes, whether or not the resolved value did. Configuration surfaces need this one: storing an override equal to the composition base leaves the resolved value alone but changes what the document says (the field is now overridden, not inherited) and moves the revision every open editor is holding. Listener containment matches `settings/updated`. +Both declarations live in the client-safe `./types` subpath export, together with the `SettingsNamespace` and `SettingsUpdateSource` types their signatures name; the package root re-exports those types. A consumer outside the Host compilation face therefore reads the very signature the Host emits instead of restating it. + ## Model Experience Indirectly, through consumer plugins that resolve model-affecting values (for example a default model route) from their namespaces; each consumer's own surface documents the effect. diff --git a/packages/settings/settings/README.zh.md b/packages/settings/settings/README.zh.md index f46cf433b4..47fa75d13a 100644 --- a/packages/settings/settings/README.zh.md +++ b/packages/settings/settings/README.zh.md @@ -28,6 +28,8 @@ `settings/document-updated (ns, revision)` 在**原始**用户分节发生变化时触发,无论解析值是否随之改变。配置界面需要的是这一个:存入一个与组合 `base` 相同的覆盖值不会改变解析值,却改变了文档的说法(该字段从继承变成了覆盖),也推进了每个已打开编辑器所持有的 revision。监听器的收容方式与 `settings/updated` 相同。 +两条声明都住在 client-safe 的 `./types` 子路径出口,与其签名点名的 `SettingsNamespace`、`SettingsUpdateSource` 类型同处一处;包根继续 re-export 这些类型。于是 Host 编译面之外的消费方读到的正是 Host 发射的那一份签名,而不必再写一遍。 + ## 模型体验 间接生效:消费插件从各自 namespace 解析影响模型的值(例如默认模型路由);效果由各消费者自己的文档描述。 diff --git a/packages/typert/type-meta/README.md b/packages/typert/type-meta/README.md index 5bf35dabf8..33524355f0 100644 --- a/packages/typert/type-meta/README.md +++ b/packages/typert/type-meta/README.md @@ -20,6 +20,8 @@ Decorator initializers retain markers in a module-private `WeakMap` keyed by the Business packages extend `TypeRTLookupMap` and `TypeRTContextMap` to associate Host objects or scoped Contexts with their wire identities. Generated artifacts extend `TypeRTRemoteMap`, `TypeRTRemoteScopeMap`, and `TypeRTRemoteNamespaceMap` so Client imports expose only selected Remote methods. `InvocationDescriptor` is the shared runtime form consumed by the registry, Gateway, and Client Remote. +The Host assembly extends `TypeRTRemoteEventSelection` with the Host events it forwards to consumers, which narrows the `ctx.remote.$on` key face; `TypeRTForwardableEvent` states the shapes a one-way delivery can carry at all, excluding Scope-bound and answered events. The `remote/host-event` Cordis event is declared here because both compilation faces share this package, but only the consumer side participates: the Client half owning the host frame sink emits it and the Client Remote service is its only subscriber. + Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. A lookup or Host Context provider supplies the stable declaration and default resolver, while Host composition may separately configure a synchronous or asynchronous resolver; policy rejections may use `TypeRTLookupFailure` to carry a failure value owned by the boundary adapter. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. ## Model Experience diff --git a/packages/typert/type-meta/README.zh.md b/packages/typert/type-meta/README.zh.md index 68b690d214..1e7f434a72 100644 --- a/packages/typert/type-meta/README.zh.md +++ b/packages/typert/type-meta/README.zh.md @@ -20,6 +20,8 @@ Host 方法通过将 `signal: AbortSignal` 声明为最后一个参数来启用 业务包扩展 `TypeRTLookupMap` 和 `TypeRTContextMap`,以关联 Host 对象或作用域 Context 与其协议身份。生成的产物扩展 `TypeRTRemoteMap`、`TypeRTRemoteScopeMap` 和 `TypeRTRemoteNamespaceMap`,使 Client 导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、Gateway 和 Client Remote 使用的共享运行时形式。 +Host 装配扩展 `TypeRTRemoteEventSelection` 来声明转发给消费端的 Host 事件,从而收窄 `ctx.remote.$on` 的键面;`TypeRTForwardableEvent` 陈述单向投递根本能承载哪些形状,把 Scope 化事件与有返回值的事件排除在外。`remote/host-event` 这条 Cordis 事件声明在此包,是因为两个编译面共用它,但只有消费端参与:持有 Host 帧 sink 的 Client 半发射它,Client Remote 服务是唯一的订阅方。 + 查找包与 Context 包同时负责其约定的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。lookup 或 Host Context provider 提供稳定声明与默认 resolver,Host 组合可以另行配置同步或异步 resolver;策略拒绝可用 `TypeRTLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。 ## 模型体验 From d88f771e1927a3ed674242a391aa54fc4e0349cc Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:32:50 +0800 Subject: [PATCH 15/31] feat(remote): deliver allowlisted Host events through ctx.remote.$on api/remotes owns the allowlist and its type projection; type-meta owns the shape predicate, the selection seat, and the internal remote/host-event carrier signal; api/gateway's Client half turns that signal into $on callbacks through a private dispatch. apiproxy forwards each allowlisted emission verbatim in one host/remote-event frame, registered ahead of the derived invalidation frames so frame order is unchanged, and drops the three per-event variants it replaces. Owner packages move their Events declarations into client-safe ./types exports, so a consumer's listener signature is the Host's own declaration. --- packages/api/gateway/src/client/index.ts | 52 +++++++- packages/api/gateway/src/invariant.ts | 3 +- packages/api/gateway/tests/client.spec.ts | 111 +++++++++++++++++- packages/api/remotes/package.json | 17 ++- packages/api/remotes/src/client/index.ts | 16 +++ packages/api/remotes/src/index.ts | 23 ++++ packages/api/remotes/src/invariant.ts | 39 +++++- packages/api/remotes/src/types.ts | 29 +++++ packages/api/remotes/tests/invariant.spec.ts | 65 ++++++++++ packages/api/remotes/tsconfig.client.json | 15 ++- packages/api/remotes/tsconfig.host.json | 12 +- .../connection/tests/client-apply.spec.ts | 6 +- .../tests/websocket-downlink.spec.ts | 6 +- packages/client/runtime/src/client/index.ts | 34 +----- .../client/runtime/tests/wire-events.spec.ts | 94 +++++++++++---- packages/client/test-runtime/src/index.ts | 1 + packages/client/test-runtime/src/remote.ts | 55 +++++++++ packages/client/ui-agent-preset/package.json | 7 +- .../ui-agent-preset/src/client/index.ts | 17 ++- .../ui-agent-preset/tests/apply.spec.ts | 17 +-- packages/client/ui-agent-preset/tsconfig.json | 3 + packages/client/ui-command/package.json | 7 +- .../client/ui-command/src/client/index.ts | 2 +- .../client/ui-command/src/client/service.ts | 7 +- .../ui-command/tests/browser-plugin.spec.ts | 5 +- .../client/ui-command/tests/service.spec.ts | 6 +- packages/client/ui-command/tsconfig.json | 3 + packages/client/ui-models/package.json | 7 +- packages/client/ui-models/src/client/index.ts | 9 +- packages/client/ui-models/tests/apply.spec.ts | 13 +- packages/client/ui-models/tsconfig.json | 3 + packages/client/ui-permission/package.json | 8 +- .../client/ui-permission/src/client/index.ts | 15 ++- .../tests/browser-plugin.spec.ts | 8 +- packages/client/ui-permission/tsconfig.json | 3 + .../client/ui-settings-general/package.json | 7 +- .../ui-settings-general/src/client/index.ts | 15 ++- .../ui-settings-general/tests/apply.spec.ts | 11 +- .../client/ui-settings-general/tsconfig.json | 3 + packages/core/session/src/types.ts | 5 + packages/credentials/credentials/package.json | 5 + packages/credentials/credentials/src/index.ts | 21 +--- packages/credentials/credentials/src/types.ts | 31 +++++ packages/host/apiproxy/src/api-proxy.ts | 56 +++++++-- .../host/apiproxy/src/api/events.schema.ts | 8 +- packages/host/apiproxy/src/api/events.ts | 43 +++---- .../apiproxy/tests/api-proxy-commands.spec.ts | 28 ++++- .../apiproxy/tests/api-proxy-config.spec.ts | 44 ++++--- .../host/apiproxy/tests/rpc-schemas.spec.ts | 3 +- packages/interaction/commands/src/index.ts | 10 -- packages/interaction/commands/src/types.ts | 17 ++- packages/settings/settings/package.json | 5 + packages/settings/settings/src/index.ts | 41 +------ packages/settings/settings/src/types.ts | 50 ++++++++ packages/typert/type-meta/src/index.ts | 3 + packages/typert/type-meta/src/types.ts | 43 ++++++- .../typert/type-meta/tests/type-meta.spec.ts | 38 +++++- scripts/gen-cordis-catalog.ts | 5 +- tsconfig.base.json | 3 + 59 files changed, 956 insertions(+), 257 deletions(-) create mode 100644 packages/api/remotes/src/types.ts create mode 100644 packages/api/remotes/tests/invariant.spec.ts create mode 100644 packages/client/test-runtime/src/remote.ts create mode 100644 packages/credentials/credentials/src/types.ts create mode 100644 packages/settings/settings/src/types.ts diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index 847998f173..00df34f9b4 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -5,7 +5,7 @@ */ import { Service } from '@deepseek-ai/cordis' -import type { Context } from '@deepseek-ai/cordis' +import type { Context, Events } from '@deepseek-ai/cordis' import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client' import type { InvocationDescriptor, @@ -13,6 +13,7 @@ import type { TypeRTCodec, TypeRTDisposer, TypeRTRemoteContribution, + TypeRTRemoteEvent, } from '@deepseek-ai/dsh-type-meta' interface MountToken { @@ -71,14 +72,20 @@ export function apply(ctx: Context): void { new ClientRemoteService(ctx) } +/** One subscribed listener after `$on` erased its per-event argument list. */ +type RemoteEventListener = (...args: never[]) => void + class ClientRemoteService extends Service implements TypeRTClientRemote { private readonly ownerCtx: Context private readonly namespaces = new Map() + private readonly subscriptions = new Map>() private mutations = Promise.resolve() constructor(ctx: Context) { super(ctx, 'remote') this.ownerCtx = ctx + ctx.on('remote/host-event', (event, args) => { this.dispatch(event, args) }) + ctx.effect(() => () => { this.subscriptions.clear() }, 'api-gateway.client.subscriptions') } async $mount(contribution: TypeRTRemoteContribution): ReturnType { @@ -91,6 +98,49 @@ class ClientRemoteService extends Service implements TypeRTClientRemote { return async () => { await owned() } } + $on( + event: Event, + listener: Events[Event], + ): ReturnType { + // The table is keyed by the runtime event name, so the argument list this + // signature pins per event cannot survive in it; `$deliver` restores it + // from the frame the Host emitted for that same name. + const erased: RemoteEventListener = listener + const owned = this.ctx.effect(() => { + const listeners = this.listeners(event) + listeners.add(erased) + return () => { listeners.delete(erased) } + }, `api-gateway.client.$on(${JSON.stringify(event)})`) + return () => { void owned() } + } + + /** + * Deliver one forwarded event in registration order, isolating a throwing + * listener; an event name nobody subscribes to is dropped, since the wire + * carries whatever the Host forwarding allowlist selected. + */ + private dispatch(event: string, args: readonly unknown[]): void { + const listeners = this.subscriptions.get(event) + if (listeners === undefined) return + for (const listener of listeners) { + try { + listener(...args as never[]) + } catch (error) { + console.error(`client api: Remote event ${JSON.stringify(event)} listener threw:`, error) + } + } + } + + /** Subscription set for one event name; empty sets are retained, bounded by the Host's selection. */ + private listeners(event: string): Set { + let listeners = this.subscriptions.get(event) + if (listeners === undefined) { + listeners = new Set() + this.subscriptions.set(event, listeners) + } + return listeners + } + private enqueue(operation: () => T | Promise): Promise { const result = this.mutations.then(operation, operation) this.mutations = result.then(() => undefined, () => undefined) diff --git a/packages/api/gateway/src/invariant.ts b/packages/api/gateway/src/invariant.ts index a09d2fa776..9b40cbe64f 100644 --- a/packages/api/gateway/src/invariant.ts +++ b/packages/api/gateway/src/invariant.ts @@ -16,7 +16,8 @@ export const inject = ['invariants'] /** * No runtime invariant: Host calls re-read authoritative Cordis and TypeRT - * state, while Client methods and descriptors mutate in one owned effect. + * state, while Client methods, descriptors, and `$on` subscriptions mutate in + * one owned effect. */ const install: InvariantInstaller = () => {} diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index b253547c7d..a2c0c980cf 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -1,5 +1,6 @@ import { Context, Service } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' +import type { Fiber } from '@deepseek-ai/cordis' +import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { z } from 'zod' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { @@ -10,9 +11,32 @@ import type { TypeRTRemoteNamespace, } from '@deepseek-ai/dsh-type-meta' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import type { ClientRemote } from '../src/client/index.ts' import { apply, inject } from '../src/client/index.ts' +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * Test-only forwarded Host event. + * @param namespace - marker payload recorded by listeners. + */ + 'fixture/changed'(namespace: string): void + /** + * Test-only forwarded Host event nobody subscribes to. + * @param count - marker payload never observed. + */ + 'fixture/idle'(count: number): void + /** + * Test-only event the Host assembly does not forward. + * @param flag - marker payload never delivered. + */ + 'fixture/unselected'(flag: boolean): void + } +} + declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTRemoteEventSelection extends Record<'fixture/changed' | 'fixture/idle', true> {} + interface TypeRTContextMap { fixture: TypeRTContext } @@ -43,6 +67,19 @@ type FixtureContext = Omit & { readonly remote: TypeRTClientRemote & TypeRTRemoteScopeApi<'fixture'> } +// Compile-time contract of `$on`: the key face is the forwarding selection and +// the listener signature is the owning package's own Cordis declaration. +function remoteEventContracts(remote: ClientRemote): void { + remote.$on('fixture/changed', (namespace) => { void namespace }) + // @ts-expect-error -- declared in Events but outside the forwarding selection. + remote.$on('fixture/unselected', () => {}) + // @ts-expect-error -- not declared in Events at all. + remote.$on('fixture/absent', () => {}) + // @ts-expect-error -- the listener signature comes from the event declaration. + remote.$on('fixture/changed', (count: number) => { void count }) +} +void remoteEventContracts + const idSchema = z.string().min(1) const requestSchema = z.object({ objective: z.string().min(1) }) const createResultSchema = z.object({ ref: z.string().min(1) }) @@ -96,11 +133,19 @@ function contextDescriptor(): InvocationDescriptor { } async function bench(call: ConnectionHandle['rpc']['call']): Promise { + const { ctx } = await benchFiber(call) + return ctx +} + +async function benchFiber( + call: ConnectionHandle['rpc']['call'], +): Promise<{ readonly ctx: Context; readonly client: Fiber }> { const ctx = new Context() await ctx.plugin(TypertRegistry) ctx.provide('connection', { rpc: { call } } as unknown as ConnectionHandle) - await ctx.plugin({ inject, apply }) - return ctx + const client = ctx.plugin({ inject, apply }) + await client + return { ctx, client } } describe('Client TypeRT API', () => { @@ -570,4 +615,64 @@ describe('Client TypeRT API', () => { expect(failure.message).toContain('internal: host failed') expect(failure.cause).toBe(rpcError) }) + + it('owns each $on subscription in the calling fiber', async () => { + const { ctx, client } = await benchFiber(vi.fn()) + const seen: string[] = [] + const subscriber = ctx.plugin(Object.assign( + (scope: Context) => { scope.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) }, + { inject: ['remote'] }, + )) + await subscriber + + ctx.emit('remote/host-event', 'fixture/changed', ['settings']) + expect(seen).toEqual(['settings']) + + await subscriber.dispose() + ctx.emit('remote/host-event', 'fixture/changed', ['after fiber disposal']) + expect(seen).toEqual(['settings']) + + await client.dispose() + expect(ctx.get('remote')).toBeUndefined() + }) + + it('isolates a throwing listener from the rest of the same event', async () => { + const ctx = await bench(vi.fn()) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const seen: string[] = [] + const disposeFirst = ctx.remote.$on('fixture/changed', () => { + throw new Error('fixture listener failure') + }) + ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) + try { + ctx.emit('remote/host-event', 'fixture/changed', ['credentials']) + + expect(seen).toEqual(['credentials']) + expect(consoleError).toHaveBeenCalledWith( + 'client api: Remote event "fixture/changed" listener threw:', + expect.any(Error), + ) + disposeFirst() + ctx.emit('remote/host-event', 'fixture/changed', ['commands']) + expect(seen).toEqual(['credentials', 'commands']) + expect(consoleError).toHaveBeenCalledTimes(1) + } finally { + consoleError.mockRestore() + } + }) + + it('exposes subscription as the only forwarded-event verb', () => { + expectTypeOf().toHaveProperty('$on') + expectTypeOf().not.toHaveProperty('$dispatch') + }) + + it('drops a forwarded event nobody subscribes to', async () => { + const ctx = await bench(vi.fn()) + const seen: string[] = [] + ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) + + ctx.emit('remote/host-event', 'fixture/idle', [1]) + + expect(seen).toEqual([]) + }) }) diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 241a86ff9f..490c06c13a 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -26,6 +26,10 @@ "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -47,26 +51,35 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "dependencies": { "@deepseek-ai/dsh-type-meta": "workspace:^" }, "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index ce67fb05d7..417937c2c3 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -6,6 +6,22 @@ import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta' export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta' export type {} from '@deepseek-ai/dsh-goal/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' +// The owner packages' client-safe `./types` exports supply the `Events` +// signatures `$on` hands to a listener, so a consumer reads the very +// declaration the Host emits rather than a flattened restatement of it. +export type {} from '@deepseek-ai/dsh-commands/types' +export type {} from '@deepseek-ai/dsh-credentials/types' +export type {} from '@deepseek-ai/dsh-settings/types' +/** + * The Gateway Client face's own declaration merges, type-only: the internal + * `remote/host-event` delivery event a carrier owner emits and the Remote + * service subscribes to. Erased at emit, so this facade still carries no + * runtime edge to the Gateway implementation. + */ +export type {} from '@deepseek-ai/dsh-api-gateway/client' declare module '@deepseek-ai/cordis' { interface Context { diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index 4cd70f4a78..b95864fc5f 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -1,5 +1,16 @@ /** Host BFF entry and Loader shell for the Remote contribution assembly. */ +// import type { TypeRTForwardableEvent } from '@deepseek-ai/dsh-type-meta' +// import { API_REMOTE_FORWARDED_EVENTS } from './types.ts' + +// // The owner packages' client-safe `./types` exports carry the cordis `Events` +// // declarations for every allowlisted event. Pulling them into this face is what +// // makes the shape assertion below judge real signatures rather than an empty +// // event vocabulary. +// import type {} from '@deepseek-ai/dsh-commands/types' +// import type {} from '@deepseek-ai/dsh-credentials/types' +// import type {} from '@deepseek-ai/dsh-settings/types' + export { ApiRemoteSessionNotFound, ApiRemoteSubagentSessionOwnership, @@ -13,6 +24,18 @@ export type { ApiRemoteAgentResult, ApiRemoteLookupError, } from './agent-lookup.ts' +export { API_REMOTE_FORWARDED_EVENTS } from './types.ts' +export type { ApiRemoteForwardedEvent } from './types.ts' + +// Shape gate over the allowlist, kept in the Host face because the Host's event +// vocabulary is the authoritative one. It pins three things at compile time: +// every entry NAMES a declared event (the predicate is keyed on `keyof +// Events`), no entry BINDS a Scope (a scoped event's `ThisParameterType` is not +// `unknown`, which is how "must not depend on AgentScope" is stated statically), +// and every entry is ONE-WAY (a waterfall or bail shape returns something other +// than void and is excluded). Widening the array to an event that fails any of +// these fails here, not on the wire. +// API_REMOTE_FORWARDED_EVENTS satisfies readonly TypeRTForwardableEvent[] /** Host plugin body; the selected contributions mount only in Client environments. */ export function apply(): void {} diff --git a/packages/api/remotes/src/invariant.ts b/packages/api/remotes/src/invariant.ts index f93b63e98b..1e0598a423 100644 --- a/packages/api/remotes/src/invariant.ts +++ b/packages/api/remotes/src/invariant.ts @@ -1,8 +1,8 @@ /** Package-owned invariant companion for `@deepseek-ai/dsh-api-remotes`. */ -/* jscpd:ignore-start */ import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import { API_REMOTE_FORWARDED_EVENTS } from './types.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-api-remotes' @@ -11,8 +11,38 @@ export const name = 'api-remotes-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** No runtime invariant: TypeRT and the Agent/Session registries own the observed relationships. */ -const install: InvariantInstaller = () => {} +/** The allowlist as a lookup over the live dispatch stream's plain event names. */ +const FORWARDED_EVENTS: ReadonlySet = new Set(API_REMOTE_FORWARDED_EVENTS) + +/** + * Judge one observed dispatch of an allowlisted event against what verbatim + * forwarding can carry. The Host face's `TypeRTForwardableEvent` assertion + * judges each name's DECLARED signature; only the dispatch stream shows how a + * producer actually emitted it, and neither deviation below is visible to the + * compiler. A Scope carrier would be silently dropped on the way to a consumer + * because `ctx.remote.$on` has no scoped form, and a waterfall or bail dispatch + * expects a return value that a one-way carrier can never deliver back. + * @param mode - dispatch mode reported by the event bus. + * @param event - dispatched event name. + * @param carrier - the dispatch `this`; `null` when the event is unscoped. + * @param fail - reporter bound to this package. + */ +function validateDispatch(mode: string, event: string, carrier: unknown, fail: InvariantFailure): void { + if (!FORWARDED_EVENTS.has(event)) return + if (carrier !== null) { + fail(`forwarded host event "${event}" was dispatched with a Scope carrier, which consumers can never receive`) + } + if (mode !== 'emit') { + fail(`forwarded host event "${event}" was dispatched as "${mode}", but forwarding to consumers is one-way`) + } +} + +/** Install the forwarded-event dispatch-shape check over the live event bus. */ +const install: InvariantInstaller = (ctx, fail) => { + ctx.on('internal/dispatch', (mode, event, _args, thisArg) => { + validateDispatch(mode, event, thisArg, fail) + }, { global: true }) +} /** * Register this package's invariant companion. @@ -21,4 +51,3 @@ const install: InvariantInstaller = () => {} */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/api/remotes/src/types.ts b/packages/api/remotes/src/types.ts new file mode 100644 index 0000000000..ec0aa8a01b --- /dev/null +++ b/packages/api/remotes/src/types.ts @@ -0,0 +1,29 @@ +/** + * The one home of this application's forwarded-Host-event allowlist, listed in + * `tsconfig.host.json` AND `tsconfig.client.json` so the Host forwarding loop + * and the consumer `ctx.remote.$on` key face read the same declaration instead + * of two copies that could drift. + * + * @module @deepseek-ai/dsh-api-remotes/types + */ + +/** + * Host events this application forwards to consumers verbatim: no projection, + * no redaction, no renaming. The wire name is the Host cordis event name and + * the payload is its argument list, so this array is simultaneously the whole + * control point over what a consumer can receive and the legal key set of + * `ctx.remote.$on`. Forwarding one more event is an entry here and nothing + * else. + */ +export const API_REMOTE_FORWARDED_EVENTS = [ + 'commands/change', + 'credentials/updated', + 'settings/document-updated', +] as const + +/** Type projection of the allowlist; the consumer and the Host read this one. */ +export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number] + +declare module '@deepseek-ai/dsh-type-meta' { + interface TypeRTRemoteEventSelection extends Record {} +} diff --git a/packages/api/remotes/tests/invariant.spec.ts b/packages/api/remotes/tests/invariant.spec.ts new file mode 100644 index 0000000000..1037035a52 --- /dev/null +++ b/packages/api/remotes/tests/invariant.spec.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import InvariantService from '@deepseek-ai/dsh-invariants' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { API_REMOTE_FORWARDED_EVENTS } from '@deepseek-ai/dsh-api-remotes' +import type { ApiRemoteForwardedEvent } from '@deepseek-ai/dsh-api-remotes' +import * as ApiRemotesInvariant from '@deepseek-ai/dsh-api-remotes/invariant' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(InvariantService) + await ctx.plugin(ApiRemotesInvariant) + return ctx +} + +/** + * One legal emission per allowlisted event. `Events` types each emit by name, + * so the three arities (0, 2, 1) cannot share a single loop body; keying the + * table by {@link ApiRemoteForwardedEvent} makes the compiler reject it as soon + * as the allowlist grows, which keeps "every listed event is exercised" true + * without a single argument-list assertion. + */ +const legalEmission: Record void> = { + 'commands/change': ctx => { ctx.emit('commands/change') }, + 'credentials/updated': ctx => { ctx.emit('credentials/updated', credentialRef('DEMO_TOKEN')) }, + 'settings/document-updated': ctx => { + ctx.emit('settings/document-updated', settingsNamespace('demo'), 1) + }, +} + +describe('forwarded host event invariants', () => { + it('accepts an unscoped one-way dispatch of every allowlisted event', async () => { + const ctx = await setup() + for (const event of API_REMOTE_FORWARDED_EVENTS) { + expect(() => { legalEmission[event](ctx) }).not.toThrow() + } + }) + + it('ignores an owner package event the allowlist does not select', async () => { + const ctx = await setup() + // `settings/updated` is the resolved-value event, deliberately left out of + // the allowlist while its sibling `settings/document-updated` is in it, so + // this pins that the check discriminates by name rather than by owner. + expect(() => { + ctx.emit('settings/updated', settingsNamespace('demo'), { a: 1 }, { a: 2 }, 'update') + }).not.toThrow() + // The carrier that fails an allowlisted event must pass unremarked here. + expect(() => { + ctx.emit({}, 'settings/updated', settingsNamespace('demo'), { a: 1 }, { a: 2 }, 'update') + }).not.toThrow() + }) + + it('rejects an allowlisted event dispatched with a Scope carrier', async () => { + const ctx = await setup() + expect(() => { ctx.emit({}, 'commands/change') }) + .toThrow(/"commands\/change" was dispatched with a Scope carrier/) + }) + + it('rejects an allowlisted event dispatched as anything but one-way', async () => { + const ctx = await setup() + expect(() => { ctx.bail('commands/change') }) + .toThrow(/"commands\/change" was dispatched as "bail"/) + }) +}) diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index bc26c0b13f..d537747e77 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -6,15 +6,28 @@ "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" }, "files": [ - "src/client/index.ts" + "src/client/index.ts", + "src/types.ts" ], "references": [ { "path": "../../../vendor/cordis" }, + { + "path": "../gateway" + }, + { + "path": "../../credentials/credentials" + }, { "path": "../../goal/goal" }, + { + "path": "../../interaction/commands" + }, + { + "path": "../../settings/settings" + }, { "path": "../../typert/type-meta" } diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index 1082a0d94b..0f7fd354d3 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -8,7 +8,8 @@ "files": [ "src/agent-lookup.ts", "src/index.ts", - "src/invariant.ts" + "src/invariant.ts", + "src/types.ts" ], "references": [ { @@ -20,9 +21,18 @@ { "path": "../../core/session" }, + { + "path": "../../credentials/credentials" + }, + { + "path": "../../interaction/commands" + }, { "path": "../../session/session-persistence" }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" }, diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index d783245002..fa8ae8f1a9 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -152,14 +152,14 @@ describe('connection client apply', () => { sockets[1]!.receive(JSON.stringify({ type: 'server-request', rpcId: 'host-browser', - method: 'host/commands-changed', - payload: { type: 'host/commands-changed' }, + method: 'host/remote-event', + payload: { type: 'host/remote-event', event: 'commands/change', args: [] }, })) expect(await muxFrame).toMatchObject({ value: { rpcId: 'mux-browser', payload: { type: 'session/subscribed', lastSeq: 8 } }, }) expect(await hostFrame).toMatchObject({ - value: { rpcId: 'host-browser', payload: { type: 'host/commands-changed' } }, + value: { rpcId: 'host-browser', payload: { type: 'host/remote-event', event: 'commands/change' } }, }) expect(errors).toHaveBeenCalledTimes(2) await vi.waitFor(() => { expect(envelopes.flat()).toHaveLength(2) }) diff --git a/packages/client/connection/tests/websocket-downlink.spec.ts b/packages/client/connection/tests/websocket-downlink.spec.ts index 9d53a82820..fecd7ea224 100644 --- a/packages/client/connection/tests/websocket-downlink.spec.ts +++ b/packages/client/connection/tests/websocket-downlink.spec.ts @@ -93,7 +93,7 @@ describe('WebSocket downlinks', () => { }, async function * (signal) { try { - yield { rpcId: RpcId('host-1'), payload: { type: 'host/commands-changed' } } + yield { rpcId: RpcId('host-1'), payload: { type: 'host/remote-event', event: 'commands/change', args: [] } } await untilAbort(signal) } finally { hostAborted = true @@ -116,8 +116,8 @@ describe('WebSocket downlinks', () => { expect(await hostFrame).toEqual({ type: 'server-request', rpcId: 'host-1', - method: 'host/commands-changed', - payload: { type: 'host/commands-changed' }, + method: 'host/remote-event', + payload: { type: 'host/remote-event', event: 'commands/change', args: [] }, }) const muxClosed = once(mux, 'close') diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index d01d7eafb6..5bba2ff7fc 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -150,29 +150,6 @@ declare module '@deepseek-ai/cordis' { * @param key - the mutated SlotMap key. */ 'slots/changed'(key: string): void - /** - * The host command registry changed (host/commands-changed passthrough). - * Pure invalidation signal: subscribers refetch `command.list` in the - * background rather than diffing. - * @mode emit - */ - 'commands/changed'(): void - /** - * One settings namespace's resolved value changed on the host - * (host/settings-changed passthrough). Subscribers refetch - * `settings.describe`; the frame carries no values. - * @mode emit - * @param ns - the namespace whose resolved value changed. - */ - 'settings/changed'(ns: string): void - /** - * One credential reference's state changed on the host - * (host/credentials-changed passthrough). The ref is an - * environment-variable NAME — never a value. - * @mode emit - * @param ref - the reference whose configured state changed. - */ - 'credentials/changed'(ref: string): void /** * The host provider topology changed (host/models-changed passthrough). * Subscribers refetch `llm.providers`/`llm.models`/`session.models`. @@ -241,16 +218,15 @@ export function apply(ctx: Context): void { onHostEnvelope: (envelope) => { sessions.handleHostEnvelope(envelope) workspaces.handleHostEnvelope(envelope) - // Typed-event bridge: the session layer ignores registry frames (no - // session routing); consumers (command directory caches, the settings - // and model services) subscribe on ctx. + // Forwarded-event bridge: the session layer ignores registry frames (no + // session routing). This plugin only carries the frame onto the internal + // `remote/host-event` plumbing event; the Remote service subscribes there + // and fans out to `ctx.remote.$on`, so no consumer reads a frame. const frame = envelope.payload - if (frame.type === 'host/commands-changed') ctx.emit('commands/changed') + if (frame.type === 'host/remote-event') ctx.emit('remote/host-event', frame.event, frame.args) else if (frame.type === 'host/session-preset-changed') { ctx.emit('session/preset-changed', frame.sessionId, frame.agentPreset) } - else if (frame.type === 'host/settings-changed') ctx.emit('settings/changed', frame.ns) - else if (frame.type === 'host/credentials-changed') ctx.emit('credentials/changed', frame.ref) else if (frame.type === 'host/models-changed') ctx.emit('models/changed') }, onConnected: () => { diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index cc1f6c374c..4c2550384c 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -1,16 +1,45 @@ /** - * Wire-to-typed-event bridge: host/commands-changed - * → ctx 'commands/changed'; host/session-preset-changed → - * ctx 'session/preset-changed'; each established connection generation → - * ctx 'connection/reset' (the forced cache-invalidation broadcast). + * Wire-to-typed-event bridge: a `host/remote-event` frame is republished + * verbatim on the internal `remote/host-event` plumbing event (the Remote + * service's fan-out to `ctx.remote.$on` is api-gateway's own coverage); + * host/session-preset-changed → ctx 'session/preset-changed'; + * `host/models-changed` still broadcasts the typed `models/changed`; each + * established connection generation → ctx 'connection/reset' (the forced + * cache-invalidation broadcast). */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +// Type-only: the api-remotes facade carries both the allowlist's selection seat +// and the owner packages' `./types` declarations, which together give `$on` its +// key face and per-event listener signatures. +import type {} from '@deepseek-ai/dsh-api-remotes/client' import * as RuntimeClient from '../src/client/index.ts' import { FakeApiClient } from './fake-api.ts' +/** + * Compile-time face of `ctx.remote.$on`, asserted by type-checking this file + * rather than by running it: the allowlist narrows the key set, and each + * listener's parameters come from the owner package's own cordis `Events` + * declaration (so a brand cannot be flattened on the way to a consumer). + * @param ctx - any client Context carrying the Remote service. + */ +function forwardedEventContracts(ctx: Context): void { + ctx.remote.$on('settings/document-updated', (namespace, source) => { + // @ts-expect-error -- the brand survives the wire: a bare string is not a SettingsNamespace + const bare: typeof namespace = 'plain-string' + void bare; void namespace; void source + }) + ctx.remote.$on('credentials/updated', () => {}) + ctx.remote.$on('commands/change', () => {}) + // @ts-expect-error -- client-local event outside the allowlist + ctx.remote.$on('slots/changed', () => {}) + // @ts-expect-error -- declared host event the allowlist does not select + ctx.remote.$on('skills/change', () => {}) +} +void forwardedEventContracts + interface Bench { ctx: Context sinks: ConnectionSinks | undefined @@ -33,41 +62,64 @@ async function mount(): Promise { }, } ctx.reflect.provide('connection', handle) - ctx.reflect.provide('remote', {}) await ctx.plugin(RuntimeClient).await() return bench } describe('wire event bridge', () => { - it('broadcasts commands/changed on a host/commands-changed frame, not on other host frames', async () => { + it('republishes a forwarded host event verbatim, and routes no other host frame there', async () => { const bench = await mount() - let changed = 0 - bench.ctx.on('commands/changed', () => { changed++ }) - bench.sinks?.onHostEnvelope?.({ rpcId: 'r1' as never, payload: { type: 'host/commands-changed' } }) - expect(changed).toBe(1) + const seen: unknown[][] = [] + bench.ctx.on('remote/host-event', (event, args) => { seen.push([event, ...args]) }) + bench.sinks?.onHostEnvelope?.({ + rpcId: 'r1' as never, + payload: { type: 'host/remote-event', event: 'commands/change', args: [] }, + }) + expect(seen).toEqual([['commands/change']]) + bench.sinks?.onHostEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'host/session-status', sessionId: 's1' as never, running: true }, }) - expect(changed).toBe(1) + expect(seen).toEqual([['commands/change']]) }) - it('broadcasts the settings/credentials/models invalidations with their frame payloads', async () => { + it('carries each forwarded event name with its own argument list, unfiltered', async () => { const bench = await mount() const seen: unknown[][] = [] - bench.ctx.on('settings/changed', ns => seen.push(['settings', ns])) - bench.ctx.on('credentials/changed', ref => seen.push(['credentials', ref])) - bench.ctx.on('models/changed', () => seen.push(['models'])) - bench.sinks?.onHostEnvelope?.({ rpcId: 'r3' as never, payload: { type: 'host/settings-changed', ns: 'llm-pi-ai' } }) - bench.sinks?.onHostEnvelope?.({ rpcId: 'r4' as never, payload: { type: 'host/credentials-changed', ref: 'OPENAI_API_KEY' } }) - bench.sinks?.onHostEnvelope?.({ rpcId: 'r5' as never, payload: { type: 'host/models-changed' } }) + bench.ctx.on('remote/host-event', (event, args) => { seen.push([event, ...args]) }) + + bench.sinks?.onHostEnvelope?.({ + rpcId: 'r3' as never, + payload: { type: 'host/remote-event', event: 'settings/document-updated', args: ['llm-pi-ai', 7] }, + }) + bench.sinks?.onHostEnvelope?.({ + rpcId: 'r4' as never, + payload: { type: 'host/remote-event', event: 'credentials/updated', args: ['OPENAI_API_KEY'] }, + }) + // The carrier does not second-guess the name: selecting what a consumer can + // receive is the allowlist's job, and dropping an unsubscribed name is the + // Remote service's. This plugin republishes whatever the frame carried. + bench.sinks?.onHostEnvelope?.({ + rpcId: 'r5' as never, + payload: { type: 'host/remote-event', event: 'nobody/listening', args: ['ignored'] }, + }) + expect(seen).toEqual([ - ['settings', 'llm-pi-ai'], - ['credentials', 'OPENAI_API_KEY'], - ['models'], + ['settings/document-updated', 'llm-pi-ai', 7], + ['credentials/updated', 'OPENAI_API_KEY'], + ['nobody/listening', 'ignored'], ]) }) + it('still broadcasts the typed models/changed invalidation (its host frame is unchanged)', async () => { + const bench = await mount() + let models = 0 + bench.ctx.on('models/changed', () => { models++ }) + bench.sinks?.onHostEnvelope?.({ rpcId: 'r6' as never, payload: { type: 'host/models-changed' } }) + expect(models).toBe(1) + }) + it('broadcasts session/preset-changed with the recomposed session and its new preset', async () => { const bench = await mount() const seen: Array<[string, string]> = [] diff --git a/packages/client/test-runtime/src/index.ts b/packages/client/test-runtime/src/index.ts index e94030a1f1..d7692aa6af 100644 --- a/packages/client/test-runtime/src/index.ts +++ b/packages/client/test-runtime/src/index.ts @@ -39,6 +39,7 @@ export { FixtureSession, TestSessions } from './sessions.ts' export { stubSettingsScope } from './settings-scope.ts' export type { StubSettingsScope } from './settings-scope.ts' export { TestWorkspaces } from './workspaces.ts' +export { TestRemote } from './remote.ts' export { conversationSnapshot, workspaceListState } from './fixtures.ts' export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts' export { makeTranslate } from './translate.ts' diff --git a/packages/client/test-runtime/src/remote.ts b/packages/client/test-runtime/src/remote.ts new file mode 100644 index 0000000000..143352cb1c --- /dev/null +++ b/packages/client/test-runtime/src/remote.ts @@ -0,0 +1,55 @@ +/** Test-owned Remote face: `$on` subscriptions driven by the internal forwarded-event plumbing. */ +import type { Context } from '@deepseek-ai/cordis' + +/** + * Remote service test double for the forwarded-event path. Feature specs need + * `ctx.remote.$on` to exist (their plugins inject `remote`) and need forwarded + * host events to reach those subscribers, but not the generated namespaces or + * the wire — so this double implements subscription and dispatch only. + * + * Dispatch is driven the same way production drives it: by the internal + * `remote/host-event` event the connection sink emits. A spec therefore + * exercises its refresh chains with `ctx.emit('remote/host-event', name, args)`, + * the exact signal `client/runtime` republishes from a `host/remote-event` + * frame, rather than reaching into this double. + * + * `$mount` rejects: a spec that reaches a generated namespace through this + * double has outgrown it and needs the real Client Remote service. + */ +export class TestRemote { + private readonly subscriptions = new Map void>>() + + /** + * Register the double as `ctx.remote` and bind its dispatch to the plumbing event. + * @param ctx - the spec's root Context. + */ + constructor(ctx: Context) { + ctx.provide('remote', this) + ctx.on('remote/host-event', (event, args) => { + const listeners = this.subscriptions.get(event) + if (listeners === undefined) return + for (const listener of [...listeners]) listener(...args as never[]) + }) + } + + /** + * Subscribe to one forwarded host event. + * @param event - forwarded host event name. + * @param listener - receives the Host argument list verbatim. + * @returns disposer removing this subscription. + */ + $on(event: string, listener: (...args: never[]) => void): () => void { + const listeners = this.subscriptions.get(event) ?? new Set() + this.subscriptions.set(event, listeners) + listeners.add(listener) + return () => { listeners.delete(listener) } + } + + /** + * Generated-namespace mount, unsupported by this double. + * @returns never; always rejects. + */ + $mount(): Promise<() => Promise> { + return Promise.reject(new Error('TestRemote: $mount needs the real Client Remote service')) + } +} diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index e5d4e27790..aab0727a7f 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -36,7 +36,8 @@ "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-conversation", - "@deepseek-ai/dsh-client-ui-settings" + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-api-remotes" ], "platform": "web" } @@ -47,6 +48,8 @@ }, "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", @@ -56,10 +59,10 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "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-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 150867a177..05abfbd851 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -14,6 +14,9 @@ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: pulls the ctx.remote merge and the forwarded-event key face +// (the settings invalidation rides the allowlist) into this program. +import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the settings shell's SlotMap merge (the 'settings.section' entry). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' @@ -43,7 +46,7 @@ export type { AgentPresetOption, AgentPresetSettingsState } from './settings-sto export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.ts' /** Required services (cordis fiber inject). */ -export const inject = ['slots', 'locale', 'connection'] +export const inject = ['slots', 'locale', 'connection', 'remote'] /** * Mount the General-settings row. @@ -71,15 +74,17 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => { // The roster is a live directory and the default is a settings field, so // both an external settings edit and a reconnect can move this row. - const refresh = (ns?: string): void => { - if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return + const refresh = (): void => { void controller.load() // The section reads the same roster and marks the same default, so a // change made from either surface converges both. if (section.store.getSnapshot().status !== 'idle') void section.load() } const disposers = [ - ctx.on('settings/changed', refresh), + ctx.remote.$on('settings/document-updated', (ns) => { + if (ns !== AGENT_PRESET_SETTINGS_NS) return + refresh() + }), ctx.on('connection/reset', () => { refresh() }), ] return () => { for (const dispose of disposers) dispose() } @@ -132,8 +137,8 @@ export function apply(ctx: ClientContext): void { // the next session keeps offering the previous default until a reload, // which is exactly the session the setting claims to govern. A staged // pick survives: `load()` prefers it over the refreshed fallback. - const settingsMoved = scope.on('settings/changed', (ns?: string) => { - if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return + const settingsMoved = scope.remote.$on('settings/document-updated', (ns) => { + if (ns !== AGENT_PRESET_SETTINGS_NS) return void seat.load() }) // Authoring writes a FILE, not a setting, so nothing on the wire diff --git a/packages/client/ui-agent-preset/tests/apply.spec.ts b/packages/client/ui-agent-preset/tests/apply.spec.ts index 96f47975ee..cf87c541c8 100644 --- a/packages/client/ui-agent-preset/tests/apply.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.spec.ts @@ -10,7 +10,7 @@ import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' -import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client' import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx' import type { AgentPresetLabelInjected } from '../src/client/AgentPresetLabel.tsx' @@ -78,6 +78,9 @@ async function bench() { await ctx.plugin(SlotsService).await() const locale = new LocaleService(ctx) ctx.provide('locale', locale) + // The plugins inject `remote`; forwarded events reach them through the + // same `remote/host-event` signal the connection sink republishes. + new TestRemote(ctx) const calls: string[] = [] ctx.provide('connection', { api: { @@ -169,7 +172,7 @@ function sessionsDouble(state: { describe('ui-agent-preset apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'connection']) + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote']) }) it('registers the General row and the settings section', async () => { @@ -250,11 +253,11 @@ describe('ui-agent-preset apply', () => { await section.load() const before = calls.length - ctx.emit('settings/changed', 'agent-presets') + ctx.emit('remote/host-event', 'settings/document-updated', ['agent-presets', 1]) await vi.waitFor(() => { expect(calls.length).toBe(before + 2) }) const afterRelevant = calls.length - ctx.emit('settings/changed', 'llm-deepseek') + ctx.emit('remote/host-event', 'settings/document-updated', ['llm-deepseek', 1]) await Promise.resolve() // Both surfaces re-read on their own namespace; an unrelated one moves @@ -282,7 +285,7 @@ describe('ui-agent-preset apply', () => { await ctx.plugin({ inject: [...inject], apply }).await() const before = calls.length - ctx.emit('settings/changed', 'agent-presets') + ctx.emit('remote/host-event', 'settings/document-updated', ['agent-presets', 1]) await vi.waitFor(() => { expect(calls.length).toBeGreaterThan(before) }) // Only the General row reloads: a section nobody opened has nothing to @@ -333,11 +336,11 @@ describe('ui-agent-preset apply', () => { // An unrelated namespace moves nothing: the chip re-reads on its own // setting, not on every settings write in the process. moveDefault() - ctx.emit('settings/changed', 'llm-deepseek') + ctx.emit('remote/host-event', 'settings/document-updated', ['llm-deepseek', 1]) await Promise.resolve() expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard') - ctx.emit('settings/changed', 'agent-presets') + ctx.emit('remote/host-event', 'settings/document-updated', ['agent-presets', 1]) await vi.waitFor(() => { expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('minimal') }) diff --git a/packages/client/ui-agent-preset/tsconfig.json b/packages/client/ui-agent-preset/tsconfig.json index 2b21a7e1e2..ea7292b023 100644 --- a/packages/client/ui-agent-preset/tsconfig.json +++ b/packages/client/ui-agent-preset/tsconfig.json @@ -40,6 +40,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../api/remotes/tsconfig.client.json" } ] } diff --git a/packages/client/ui-command/package.json b/packages/client/ui-command/package.json index 93c0cf3910..7a13ed5bd5 100644 --- a/packages/client/ui-command/package.json +++ b/packages/client/ui-command/package.json @@ -35,7 +35,8 @@ "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-slash", - "@deepseek-ai/dsh-client-ui-conversation" + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-api-remotes" ], "platform": "web" } @@ -49,6 +50,8 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", @@ -57,10 +60,10 @@ "@deepseek-ai/dsh-client-ui-slash": "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-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", diff --git a/packages/client/ui-command/src/client/index.ts b/packages/client/ui-command/src/client/index.ts index f8de391fad..765e14f751 100644 --- a/packages/client/ui-command/src/client/index.ts +++ b/packages/client/ui-command/src/client/index.ts @@ -45,7 +45,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { const NS = 'command' /** Required services: the '/' source registry plus the scope + wire faces the service reads, and the copy's locale registry. */ -export const inject = ['slash', 'sessions', 'connection', 'locale'] +export const inject = ['slash', 'sessions', 'connection', 'locale', 'remote'] /** * Client plugin body: mount the service, then register the popupSelect shell diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index 00515c1e41..02d5b1f14b 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -11,6 +11,9 @@ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the ctx.remote merge and the forwarded-event key face +// (`commands/change` rides the allowlist) into this program. +import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick, SubmitOutcome, @@ -93,7 +96,7 @@ function fuzzyCandidates(candidates: readonly SlashCandidate[], rawQuery: string /** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */ export class CommandService extends Service implements CommandServiceContract { - static inject = ['slash', 'sessions', 'connection'] + static inject = ['slash', 'sessions', 'connection', 'remote'] private readonly directory: CommandDirectory private readonly live: LiveState = { contributions: new Map(), decorations: new Map(), popups: new Map() } @@ -123,7 +126,7 @@ export class CommandService extends Service implements CommandServiceContract { matchEnter: (session, line, signal) => this.matchEnter(session, line, signal), warm: (session) => { this.directory.warm(session.sessionId) }, }), 'command: slash source') - ctx.on('commands/changed', () => { this.directory.invalidateAll() }) + ctx.remote.$on('commands/change', () => { this.directory.invalidateAll() }) // A preset switch changes which commands one session's agent resolves and // registers nothing globally, so the registry-wide signal above never // fires for it: repull that key alone, soft, so the old snapshot serves diff --git a/packages/client/ui-command/tests/browser-plugin.spec.ts b/packages/client/ui-command/tests/browser-plugin.spec.ts index ae2afff1f5..af60a45e16 100644 --- a/packages/client/ui-command/tests/browser-plugin.spec.ts +++ b/packages/client/ui-command/tests/browser-plugin.spec.ts @@ -14,6 +14,7 @@ import type { SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' import type { CommandServiceContract } from '../src/client/contract.ts' import type { PopupSelectInjected } from '../src/client/PopupSelectView.tsx' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply, CommandService, inject } from '../src/client/index.ts' const sid = (k: string): SessionId => k as SessionId @@ -38,6 +39,8 @@ async function bench() { name: 'root', children: { 'conversation.input.overlay': { kind: 'list', scope: 'session' } }, } as never, (() => null) as never) ctx.provide('locale', new LocaleService(ctx)) + // CommandService injects `remote` for the forwarded directory invalidation. + new TestRemote(ctx) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() const mint = (key: string) => { @@ -50,7 +53,7 @@ async function bench() { describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['slash', 'sessions', 'connection', 'locale']) + expect(inject).toEqual(['slash', 'sessions', 'connection', 'locale', 'remote']) }) it('mounts ctx.command, registers the source and the overlay entry, and folds up on disposal', async () => { diff --git a/packages/client/ui-command/tests/service.spec.ts b/packages/client/ui-command/tests/service.spec.ts index b5029761fa..320b1bf600 100644 --- a/packages/client/ui-command/tests/service.spec.ts +++ b/packages/client/ui-command/tests/service.spec.ts @@ -10,6 +10,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts' @@ -78,6 +79,9 @@ async function bench(opts: BenchOptions = {}) { : undefined, }) ctx.provide('connection', { api }) + // CommandService injects `remote`; the directory invalidation arrives on the + // same `remote/host-event` signal the connection sink republishes. + new TestRemote(ctx) /** Notices the fake conversation face collected (runDetached routing). */ const notices: Array<{ scope: SessionId | undefined; level: 'info' | 'error'; text: string }> = [] ctx.provide('conversation', { @@ -611,7 +615,7 @@ describe('directory invalidation events', () => { }, }) await warm(proj('s1')) - ctx.emit('commands/changed') + ctx.emit('remote/host-event', 'commands/change', []) await new Promise(resolve => setTimeout(resolve, 0)) expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined() expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined() diff --git a/packages/client/ui-command/tsconfig.json b/packages/client/ui-command/tsconfig.json index f83486aa36..d87e874d01 100644 --- a/packages/client/ui-command/tsconfig.json +++ b/packages/client/ui-command/tsconfig.json @@ -34,6 +34,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../api/remotes/tsconfig.client.json" } ] } diff --git a/packages/client/ui-models/package.json b/packages/client/ui-models/package.json index af55b2a5cc..bb4dd16fb8 100644 --- a/packages/client/ui-models/package.json +++ b/packages/client/ui-models/package.json @@ -34,7 +34,8 @@ "inject": [ "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-settings", - "@deepseek-ai/dsh-client-locale" + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-api-remotes" ], "platform": "web" } @@ -45,6 +46,8 @@ }, "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-schema-form": "workspace:^", @@ -52,10 +55,10 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "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-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", diff --git a/packages/client/ui-models/src/client/index.ts b/packages/client/ui-models/src/client/index.ts index 1de35aa342..3262c0c1d3 100644 --- a/packages/client/ui-models/src/client/index.ts +++ b/packages/client/ui-models/src/client/index.ts @@ -12,6 +12,9 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: pulls the ctx.remote merge and the forwarded-event key face +// (settings/credentials invalidations ride the allowlist) into this program. +import type {} from '@deepseek-ai/dsh-api-remotes/client' import { ModelsSection } from './ModelsSection.tsx' import type { ModelsSectionInjected } from './ModelsSection.tsx' import { DeepSeekOnboardingDialog } from './DeepSeekOnboardingDialog.tsx' @@ -48,7 +51,7 @@ export function refreshIfLoaded(controller: ModelsSettingsStore): void { * ui-settings' apply, whose activation order relative to this one is NOT * constrained; registration depends on each slot through `slots.inject()`. */ -export const inject = ['slots', 'locale', 'connection'] +export const inject = ['slots', 'locale', 'connection', 'remote'] /** * Register the Models section once the `settings.section` declaration is on @@ -82,8 +85,8 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => { const refresh = (): void => { refreshIfLoaded(controller) } const disposers = [ - ctx.on('settings/changed', refresh), - ctx.on('credentials/changed', refresh), + ctx.remote.$on('settings/document-updated', refresh), + ctx.remote.$on('credentials/updated', refresh), ctx.on('models/changed', refresh), ctx.on('connection/reset', refresh), ] diff --git a/packages/client/ui-models/tests/apply.spec.ts b/packages/client/ui-models/tests/apply.spec.ts index dc648e939b..e077441fe0 100644 --- a/packages/client/ui-models/tests/apply.spec.ts +++ b/packages/client/ui-models/tests/apply.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' -import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-models/client' import { ModelsSection } from '../src/client/ModelsSection.tsx' import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx' @@ -18,6 +18,9 @@ async function bench() { await ctx.plugin(SlotsService).await() const locale = new LocaleService(ctx) ctx.provide('locale', locale) + // The plugins inject `remote`; forwarded events reach them through the + // same `remote/host-event` signal the connection sink republishes. + new TestRemote(ctx) // The apply path only captures the wire face; no call leaves this fake // until a section actually loads. ctx.provide('connection', { api: {} } as never) @@ -39,7 +42,7 @@ function declare(slots: SlotsService): () => void { describe('ui-models apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'connection']) + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote']) }) it('registers the models nav entry for declarations before or after apply', async () => { @@ -135,8 +138,8 @@ describe('pushed invalidations', () => { declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() // The fake wire face has no methods: a fetch attempt would throw. - b.ctx.emit('settings/changed', 'llm-pi-ai') - b.ctx.emit('credentials/changed', 'OPENAI_API_KEY') + b.ctx.emit('remote/host-event', 'settings/document-updated', ['llm-pi-ai', 1]) + b.ctx.emit('remote/host-event', 'credentials/updated', ['OPENAI_API_KEY']) b.ctx.emit('models/changed') b.ctx.emit('connection/reset') }) @@ -167,7 +170,7 @@ describe('pushed invalidations', () => { )() injected.controller.store.update((state) => { state.status = 'ready' }) const load = vi.spyOn(injected.controller, 'load').mockResolvedValue() - b.ctx.emit('credentials/changed', 'DEEPSEEK_API_KEY') + b.ctx.emit('remote/host-event', 'credentials/updated', ['DEEPSEEK_API_KEY']) expect(load).toHaveBeenCalledTimes(1) }) }) diff --git a/packages/client/ui-models/tsconfig.json b/packages/client/ui-models/tsconfig.json index 79e61ffcba..71f8e9cc98 100644 --- a/packages/client/ui-models/tsconfig.json +++ b/packages/client/ui-models/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../api/remotes/tsconfig.client.json" } ] } diff --git a/packages/client/ui-permission/package.json b/packages/client/ui-permission/package.json index 41c3c7eb30..f7391e2c3f 100644 --- a/packages/client/ui-permission/package.json +++ b/packages/client/ui-permission/package.json @@ -35,7 +35,8 @@ "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-command" + "@deepseek-ai/dsh-client-ui-command", + "@deepseek-ai/dsh-api-remotes" ], "platform": "web" } @@ -46,6 +47,8 @@ }, "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", @@ -56,14 +59,15 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-schema-form": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", diff --git a/packages/client/ui-permission/src/client/index.ts b/packages/client/ui-permission/src/client/index.ts index 66b15ed115..d055b10abe 100644 --- a/packages/client/ui-permission/src/client/index.ts +++ b/packages/client/ui-permission/src/client/index.ts @@ -16,6 +16,9 @@ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: pulls the ctx.remote merge and the forwarded-event key face +// (the settings invalidation rides the allowlist) into this program. +import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { ClientContext, SessionFace } from '@deepseek-ai/dsh-client-runtime/client' import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client' import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client' @@ -38,7 +41,7 @@ export type { } from './settings-store.ts' /** Required services (cordis fiber inject). */ -export const inject = ['command', 'sessions', 'slots', 'locale', 'connection'] +export const inject = ['command', 'sessions', 'slots', 'locale', 'connection', 'remote'] const ACCESS_NS = 'permission.access' @@ -118,12 +121,12 @@ export function apply(ctx: ClientContext): void { }) ctx.effect(() => { - const refresh = (ns?: string): void => { - if (ns !== undefined && ns !== PERMISSION_SETTINGS_NS) return - refreshPermissionIfLoaded(controller) - } + const refresh = (): void => { refreshPermissionIfLoaded(controller) } const disposers = [ - ctx.on('settings/changed', refresh), + ctx.remote.$on('settings/document-updated', (ns) => { + if (ns !== PERMISSION_SETTINGS_NS) return + refresh() + }), ctx.on('connection/reset', () => { refresh() }), ] return () => { diff --git a/packages/client/ui-permission/tests/browser-plugin.spec.ts b/packages/client/ui-permission/tests/browser-plugin.spec.ts index 4575941b4a..7b6f5e2a19 100644 --- a/packages/client/ui-permission/tests/browser-plugin.spec.ts +++ b/packages/client/ui-permission/tests/browser-plugin.spec.ts @@ -12,6 +12,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-command/client' import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client' import { @@ -37,6 +38,9 @@ async function bench() { const locale = new LocaleService(ctx) locale.setLocale('en') ctx.provide('locale', locale) + // The plugin injects `remote`; forwarded events reach it through the same + // `remote/host-event` signal the connection sink republishes. + new TestRemote(ctx) ctx.slots.register({ name: 'root', children: { @@ -158,8 +162,8 @@ describe('ui-permission browser plugin', () => { it('disposal removes the decoration (HMR safety)', async () => { const b = await bench() expect(b.decoration()).toBeDefined() - b.ctx.emit('settings/changed', 'another') - b.ctx.emit('settings/changed', 'permission') + b.ctx.emit('remote/host-event', 'settings/document-updated', ['another', 1]) + b.ctx.emit('remote/host-event', 'settings/document-updated', ['permission', 1]) b.ctx.emit('connection/reset') await b.fiber.dispose() expect(b.decoration()).toBeUndefined() diff --git a/packages/client/ui-permission/tsconfig.json b/packages/client/ui-permission/tsconfig.json index bfc71e2e3c..987c3e429f 100644 --- a/packages/client/ui-permission/tsconfig.json +++ b/packages/client/ui-permission/tsconfig.json @@ -43,6 +43,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../api/remotes/tsconfig.client.json" } ] } diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index e720ad810a..8dc142fd91 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -35,7 +35,8 @@ "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-connection" + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-api-remotes" ], "platform": "web" } @@ -50,6 +51,8 @@ "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", @@ -58,10 +61,10 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "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-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 9d57a0eba9..d4c1aa0ca5 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -12,6 +12,9 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' // Type-only: pulls ctx.locale and the 'settings.general.item' SlotMap merge. import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: pulls the ctx.remote merge and the forwarded-event key face +// (the settings invalidation rides the allowlist) into this program. +import type {} from '@deepseek-ai/dsh-api-remotes/client' import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx' import { GeneralSection } from './GeneralSection.tsx' import { SettingsDocumentAction } from './SettingsDocumentAction.tsx' @@ -51,7 +54,7 @@ const NS = 'settings' * ui-settings' apply, whose activation order relative to this one is NOT * constrained; registrations depend on their slots through `slots.inject()`. */ -export const inject = ['slots', 'locale', 'connection'] +export const inject = ['slots', 'locale', 'connection', 'remote'] /** * Register the `settings` dictionaries, the chrome content, and the General @@ -83,12 +86,12 @@ export function apply(ctx: ClientContext): void { }) ctx.effect(() => { - const refresh = (ns?: string): void => { - if (ns !== undefined && ns !== WELCOME_NOTICE_SETTINGS_NAMESPACE) return - refreshWelcomeIfLoaded(welcomeController) - } + const refresh = (): void => { refreshWelcomeIfLoaded(welcomeController) } const disposers = [ - ctx.on('settings/changed', refresh), + ctx.remote.$on('settings/document-updated', (ns) => { + if (ns !== WELCOME_NOTICE_SETTINGS_NAMESPACE) return + refresh() + }), ctx.on('connection/reset', () => { refresh() refreshDocumentIfLoaded(documentController) diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts index 4d7301faa1..98db5699df 100644 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' -import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' import { GeneralSection } from '../src/client/GeneralSection.tsx' @@ -33,6 +33,9 @@ async function bench(isLoopback = true) { await ctx.plugin(SlotsService).await() const locale = new LocaleService(ctx) ctx.provide('locale', locale) + // The plugins inject `remote`; forwarded events reach them through the + // same `remote/host-event` signal the connection sink republishes. + new TestRemote(ctx) const settingsDescribe = vi.fn(() => Promise.resolve({ rpcId: 'settings-general' as never, result: { @@ -86,7 +89,7 @@ function generalEntry(slots: SlotsService) { describe('ui-settings-general apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'connection']) + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote']) }) it('fills all six seats for declarations before or after apply', async () => { @@ -167,9 +170,9 @@ describe('ui-settings-general apply', () => { const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)() await controller.load() expect(b.settingsDescribe).toHaveBeenCalledOnce() - b.ctx.emit('settings/changed', 'unrelated') + b.ctx.emit('remote/host-event', 'settings/document-updated', ['unrelated', 1]) expect(b.settingsDescribe).toHaveBeenCalledOnce() - b.ctx.emit('settings/changed', WELCOME_NOTICE_SETTINGS_NAMESPACE) + b.ctx.emit('remote/host-event', 'settings/document-updated', [WELCOME_NOTICE_SETTINGS_NAMESPACE, 1]) await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) }) b.ctx.emit('connection/reset') await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) }) diff --git a/packages/client/ui-settings-general/tsconfig.json b/packages/client/ui-settings-general/tsconfig.json index 5e37578f91..07f5269510 100644 --- a/packages/client/ui-settings-general/tsconfig.json +++ b/packages/client/ui-settings-general/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../api/remotes/tsconfig.client.json" } ] } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 9e50c18d11..73c28856cf 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -13,6 +13,11 @@ import type { } from '@deepseek-ai/dsh-llm' import type { JsonValue } from './json.ts' +// The lossless-JSON payload type belongs to this client-safe face too: a wire +// contract carrying JSON data must not import the root entry, which merges +// `ctx.sessions` (a Host-only SessionStore) into every consumer's program. +export type { JsonValue } from './json.ts' + /** Identifies one session in the store (and its persistence artifacts). */ export type SessionId = Branded<'SessionId'> diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json index 68c6be2f1b..8fbcf111bf 100644 --- a/packages/credentials/credentials/package.json +++ b/packages/credentials/credentials/package.json @@ -22,12 +22,17 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", diff --git a/packages/credentials/credentials/src/index.ts b/packages/credentials/credentials/src/index.ts index b4fb1569f1..d58a4420a1 100644 --- a/packages/credentials/credentials/src/index.ts +++ b/packages/credentials/credentials/src/index.ts @@ -9,10 +9,9 @@ */ import { Context, Service } from '@deepseek-ai/cordis' -import type { Branded } from '@deepseek-ai/dsh-brand' +import type { CredentialRef } from './types.ts' -/** Nominal reference to one credential: a POSIX-style environment-variable name. */ -export type CredentialRef = Branded<'CredentialRef'> +export type { CredentialRef } from './types.ts' const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ @@ -50,22 +49,6 @@ declare module '@deepseek-ai/cordis' { interface Context { credentials: Credentials } - - interface Events { - /** - * Committed change to a provider-managed credential source: a `set`, an - * `unset`, or an external edit observed in storage. Ambient - * process-environment changes are not observable and never emit. Listener - * failures are contained and logged — a sync throw and an async rejection - * alike — without changing the committed operation's outcome, except - * `INVARIANT`-coded failures, which rethrow after every listener ran; - * that rethrow reaches the emitter only from synchronous listeners, so - * invariant checks on this event must not be async functions. - * @param ref - the reference whose stored value changed. - * @mode emit - */ - 'credentials/updated'(ref: CredentialRef): void - } } /** diff --git a/packages/credentials/credentials/src/types.ts b/packages/credentials/credentials/src/types.ts new file mode 100644 index 0000000000..5922664cbd --- /dev/null +++ b/packages/credentials/credentials/src/types.ts @@ -0,0 +1,31 @@ +/** + * Client-safe type surface of the credential-reference seam: the reference + * brand and the seam's Cordis event declaration. Types only — no runtime code, + * and nothing here reaches a Host-only symbol, so a Client compilation face + * reads exactly the signature the Host emits. + * + * @module @deepseek-ai/dsh-credentials/types + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Nominal reference to one credential: a POSIX-style environment-variable name. */ +export type CredentialRef = Branded<'CredentialRef'> + +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * Committed change to a provider-managed credential source: a `set`, an + * `unset`, or an external edit observed in storage. Ambient + * process-environment changes are not observable and never emit. Listener + * failures are contained and logged — a sync throw and an async rejection + * alike — without changing the committed operation's outcome, except + * `INVARIANT`-coded failures, which rethrow after every listener ran; + * that rethrow reaches the emitter only from synchronous listeners, so + * invariant checks on this event must not be async functions. + * @param ref - the reference whose stored value changed. + * @mode emit + */ + 'credentials/updated'(ref: CredentialRef): void + } +} diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 2474a05df0..147dc9ed7d 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -6,7 +6,7 @@ import { randomUUID } from 'node:crypto' import { mkdir, stat } from 'node:fs/promises' import { dirname } from 'node:path' -import type { Context } from '@deepseek-ai/cordis' +import type { Context, Events } from '@deepseek-ai/cordis' import { installModelSelection } from '@deepseek-ai/dsh-agent' import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent' import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model' @@ -15,8 +15,8 @@ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { contentHasImage, createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import { isAppendSurfaceEvent, lastActivityTime } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' +import { isAppendSurfaceEvent, isJsonValue, lastActivityTime } from '@deepseek-ai/dsh-session' +import type { JsonValue, Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' import { SubagentError } from '@deepseek-ai/dsh-subagent' @@ -96,6 +96,7 @@ import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' import { ApiRemoteSessionNotFound as SessionNotFound, ApiRemoteSubagentSessionOwnership as SubagentSessionOwnership, + API_REMOTE_FORWARDED_EVENTS, apiRemoteSubagentOwnershipError, createApiRemoteAgentResolver, hasApiRemoteSubagentOwner, @@ -414,6 +415,27 @@ function frame(payload: F): RpcRequest { return { rpcId: RpcId(randomUUID()), payload } } +/** + * Narrow one allowlisted host event's argument list to the JSON values the + * wrapper frame carries. A rejected argument is an allowlist mistake (the + * forwarded path applies no projection), not hostile input, so it fails loud + * here rather than degrading to a dropped or lossy frame. Exported for the + * test that owns this decision: every currently allowlisted event has a + * statically JSON-safe payload, so a type-legal `ctx.emit` cannot reach the + * rejection branch. + * @param event - forwarded host event name, named in the failure. + * @param args - the emitter's argument list. + * @returns the same arguments typed as JSON values. + */ +export function assertJsonArgs(event: string, args: readonly unknown[]): JsonValue[] { + for (const [index, arg] of args.entries()) { + if (!isJsonValue(arg)) { + throw new Error(`forwarded host event "${event}" argument ${index} is not lossless JSON data`) + } + } + return args as JsonValue[] +} + /** Queue the subscription baseline frame. */ function subscribeSession(queue: FrameQueue>, session: Session): void { queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 })) @@ -3441,9 +3463,27 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro workspace: changedWorkspaceView(change.key, change.value), })) }), - ctx.on('commands/change', () => { - queue.push(frame({ type: 'host/commands-changed' })) - }), + // Allowlisted host events ride one verbatim wrapper frame each. The + // allowlist is api-remotes', and `ctx.remote.$on` is the consumer + // face; nothing here projects, redacts, or renames. Registered ahead + // of the derived frames below so a forwarded event still precedes the + // invalidation derived from it (`settings/document-updated` before + // its `host/models-changed`), which is the order a client sees. + ...API_REMOTE_FORWARDED_EVENTS.map(name => ctx.on( + name, + // cordis keys `on` by literal event name, so subscribing from a + // runtime list erases the handler type once. The erasure is safe + // because the allowlist's shape assertion already proves each name + // is a real, non-scoped, void-returning event, and assertJsonArgs + // proves the payload is JSON-safe before it reaches the queue. + ((...args: unknown[]) => { + queue.push(frame({ + type: 'host/remote-event', + event: name, + args: assertJsonArgs(name, args), + })) + }) as Events[typeof name], + )), // The recompose itself registers nothing (it re-parents the agent's // scope onto a standing mount that may already exist), so the // logged selection is the only commit point a client can follow. @@ -3461,7 +3501,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // configuration client still has to re-read (its held revision is // stale, and the field's meaning changed). const name = String(ns) - queue.push(frame({ type: 'host/settings-changed', ns: name })) // A provider's own settings carry its model catalog and endpoint, // so a change there invalidates the model list even when the route // set is untouched — `llm/adapters-updated` alone misses it. The @@ -3473,9 +3512,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro queue.push(frame({ type: 'host/models-changed' })) } }), - ctx.on('credentials/updated', (ref) => { - queue.push(frame({ type: 'host/credentials-changed', ref: String(ref) })) - }), ctx.on('llm/adapters-updated', () => { queue.push(frame({ type: 'host/models-changed' })) }), diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index 02516c13bb..f5e99894b7 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -83,10 +83,12 @@ export const hostFrameSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }), z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }), z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }), - z.object({ type: z.literal('host/commands-changed') }), + // args stays wide, the same posture as session/projection's value: the frame + // arrives from JSON.parse, so every element is already a JSON value, and the + // structural contract belongs to the owner package's cordis `Events` + // declaration — the host validated JSON-safety before forwarding. + z.object({ type: z.literal('host/remote-event'), event: z.string().min(1), args: z.array(z.unknown()) }), z.object({ type: z.literal('host/session-preset-changed'), sessionId: sessionIdSchema, agentPreset: z.string() }), - z.object({ type: z.literal('host/settings-changed'), ns: z.string() }), - z.object({ type: z.literal('host/credentials-changed'), ref: z.string() }), z.object({ type: z.literal('host/models-changed') }), z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }), ]) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 73bb9d8bc2..7b200944d9 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -11,7 +11,7 @@ import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-a import type { Message } from '@deepseek-ai/dsh-llm/types' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { CallId } from '@deepseek-ai/dsh-llm/brand' -import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' +import type { JsonValue, SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' import type { RpcError, RpcId, RpcRequest } from './rpc.ts' import type { TaskView } from './tasks.ts' @@ -140,36 +140,29 @@ export type HostFrame = | { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] } | { type: 'host/archived-sessions-changed'; archivedSessionIds: SessionId[] } /** - * The command registry changed (`commands/change` passthrough). Pure - * invalidation signal, no payload: clients refetch `command.list` in the - * background rather than diffing. + * One allowlisted host cordis event forwarded verbatim. The allowlist is + * owned by `@deepseek-ai/dsh-api-remotes` (`API_REMOTE_FORWARDED_EVENTS`), + * which is also the only control point over what a consumer can receive. + * `event` is the host's own event name and `args` its argument list: this + * path applies no projection, no redaction, and no renaming, so the payload + * contract is the owner package's cordis `Events` declaration rather than + * anything stated here. Delivery lands on `ctx.remote.$on`, not on a + * per-event frame variant. */ - | { type: 'host/commands-changed' } + | { type: 'host/remote-event'; event: string; args: JsonValue[] } /** * One blank session was recomposed onto another agent preset (the logged * `agent-preset/selected` commit point, read off the session stream). The - * registry-wide `host/commands-changed` cannot stand in for it: recomposing - * re-parents that agent's scope without registering anything, so a - * preset already mounted for another session produces no registry change - * at all. Clients refetch the catalogs this session's composition decides - * (`command.list`, `skill.list`) for this sessionId alone, and fold the - * preset id into their session row — the RPC echo reaches only the client - * that issued the switch, so the row is where every other one learns it. + * registry-wide `commands/change` forwarded above cannot stand in for it: + * recomposing re-parents that agent's scope without registering anything, + * so a preset already mounted for another session produces no registry + * change at all. Clients refetch the catalogs this session's composition + * decides (`command.list`, `skill.list`) for this sessionId alone, and fold + * the preset id into their session row — the RPC echo reaches only the + * client that issued the switch, so the row is where every other one learns + * it. */ | { type: 'host/session-preset-changed'; sessionId: SessionId; agentPreset: string } - /** - * One settings namespace's resolved value changed (`settings/updated` - * passthrough) — an RPC write, an external `settings.yaml` edit, or a - * provider reload all converge here. Clients refetch `settings.describe`; - * values never ride the frame (they would need redaction and can go stale). - */ - | { type: 'host/settings-changed'; ns: string } - /** - * One credential reference's state changed (`credentials/updated` - * passthrough): a set/unset over this wire or an external `.env` edit. - * The ref is an environment-variable NAME — never a value. - */ - | { type: 'host/credentials-changed'; ref: string } /** * The provider topology changed (`llm/adapters-updated` passthrough): * routes registered or dropped, or the configurable directory moved. Pure diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 038c1050f7..9abe116d31 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -23,7 +23,7 @@ import SkillService from '@deepseek-ai/dsh-skill' import type { HostFrame } from '../src/api/index.ts' import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' -import { createApiProxy } from '../src/api-proxy.ts' +import { assertJsonArgs, createApiProxy } from '../src/api-proxy.ts' const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' } @@ -269,7 +269,7 @@ describe('skill.list', () => { }) }) -describe('host/commands-changed frame', () => { +describe('forwarded commands/change frame', () => { it('broadcasts on registry change', async () => { const ctx = await harness() const api = createApiProxy(ctx, DEFAULTS) @@ -277,7 +277,29 @@ describe('host/commands-changed frame', () => { const stream = api.events.host({ rpcId: RpcId('t-host'), payload: {} }, abort.signal) const collected = collect(stream, 1, abort) ctx.commands.register({ name: 'late', description: 'l', handler: () => ({ kind: 'success' }) }) - expect(await collected).toEqual([{ type: 'host/commands-changed' }]) + // Verbatim forwarding: the wire name is the host's own event name and + // `args` is its argument list (empty for this pure invalidation). + expect(await collected).toEqual([{ type: 'host/remote-event', event: 'commands/change', args: [] }]) + }) + + // The guard belongs to the forwarding boundary, so it is tested there rather + // than through a malformed `ctx.emit`: every currently allowlisted event has a + // statically JSON-safe payload, so no type-legal emit can reach the rejection + // branch. These cases stand in for a future allowlist entry whose payload the + // wire cannot carry — a composition mistake that must fail loud. + describe('assertJsonArgs', () => { + it('passes a JSON-safe argument list through unchanged', () => { + const args = ['llm-deepseek', 7, null, { nested: ['ok'] }] + expect(assertJsonArgs('settings/document-updated', args)).toEqual(args) + expect(assertJsonArgs('commands/change', [])).toEqual([]) + }) + + it('names the offending event and argument position when a payload is not lossless JSON', () => { + expect(() => assertJsonArgs('credentials/updated', [1n])) + .toThrow('forwarded host event "credentials/updated" argument 0 is not lossless JSON data') + expect(() => assertJsonArgs('settings/document-updated', ['ns', () => {}])) + .toThrow('forwarded host event "settings/document-updated" argument 1 is not lossless JSON data') + }) }) }) diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 286a6d3f4f..2115d1491a 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -218,6 +218,21 @@ async function collectHost( return frames } +/** + * One forwarded `settings/document-updated` frame for `ns`. The revision rides + * the host's own argument list, so it is matched by shape rather than pinned to + * a per-test count. + * @param ns - the namespace whose stored section changed. + * @returns the expected wrapper frame. + */ +function forwardedSettings(ns: string): HostFrame { + return { + type: 'host/remote-event', + event: 'settings/document-updated', + args: [ns, expect.any(Number) as unknown as number], + } +} + describe('settings domain', () => { it('reports an actionable error when no settings provider is mounted', async () => { const ctx = await harness({ settings: false }) @@ -376,7 +391,7 @@ describe('settings domain', () => { const api = createApiProxy(ctx, DEFAULTS) expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns)) .toEqual(['ui-onboarding', 'ui-theme']) - const frames = await collectHost(api, ['host/settings-changed'], 2, async () => { + const frames = await collectHost(api, ['host/remote-event'], 2, async () => { expectOk(await api.settings.mutate(request({ ns: 'ui-onboarding', ops: [{ op: 'set', path: ['welcomeNoticeVersion'], value: 'v1' }], @@ -386,10 +401,7 @@ describe('settings domain', () => { ops: [{ op: 'set', path: ['preference'], value: 'dark' }], }))) }) - expect(frames).toEqual([ - { type: 'host/settings-changed', ns: 'ui-onboarding' }, - { type: 'host/settings-changed', ns: 'ui-theme' }, - ]) + expect(frames).toEqual([forwardedSettings('ui-onboarding'), forwardedSettings('ui-theme')]) }) it('serves the agent-preset namespace, so a browser preset picker can persist its choice', async () => { @@ -425,11 +437,11 @@ describe('settings domain', () => { const ctx = await harness() ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } }) const api = createApiProxy(ctx, DEFAULTS) - const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 2, async () => { + const frames = await collectHost(api, ['host/remote-event', 'host/models-changed'], 2, async () => { await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://base' } })) }) expect(frames).toEqual([ - { type: 'host/settings-changed', ns: 'llm-deepseek' }, + forwardedSettings('llm-deepseek'), { type: 'host/models-changed' }, ]) // The resolved value never moved: base already said https://base. @@ -445,10 +457,10 @@ describe('settings domain', () => { base: { defaultPreset: 'read-only' }, }) const api = createApiProxy(ctx, DEFAULTS) - const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 1, async () => { + const frames = await collectHost(api, ['host/remote-event', 'host/models-changed'], 1, async () => { await permission.update({ defaultPreset: 'workspace-write' }) }) - expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'permission' }]) + expect(frames).toEqual([forwardedSettings('permission')]) }) it('invalidates the model catalog when the Agent default selection changes', async () => { @@ -461,11 +473,11 @@ describe('settings domain', () => { // The shared section names the selection every blank session resolves to, // so an externally edited default — another tab, a // hand-edited settings.yaml — has to reach an open selector as well. - const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 2, async () => { + const frames = await collectHost(api, ['host/remote-event', 'host/models-changed'], 2, async () => { await defaultModel.replace({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) }) expect(frames).toEqual([ - { type: 'host/settings-changed', ns: 'agent-default-model' }, + forwardedSettings('agent-default-model'), { type: 'host/models-changed' }, ]) }) @@ -488,14 +500,14 @@ describe('settings domain', () => { const ctx = await harness() ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } }) const api = createApiProxy(ctx, DEFAULTS) - const frames = await collectHost(api, ['host/settings-changed'], 1, async () => { + const frames = await collectHost(api, ['host/remote-event'], 1, async () => { const view = expectOk(await api.settings.update(request({ ns: 'llm-deepseek', patch: { apiKey: 'sk-new', baseURL: 'https://next' } }))) expect(view.value).toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://next' }) expect(view.user).toEqual({ baseURL: 'https://next' }) expect(view.secrets).toEqual([{ path: ['apiKey'], set: true }]) expect(JSON.stringify(view)).not.toContain('sk-new') }) - expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'llm-deepseek' }]) + expect(frames).toEqual([forwardedSettings('llm-deepseek')]) }) it('replace resets the user layer wholesale', async () => { @@ -560,7 +572,7 @@ describe('credentials domain', () => { const api = createApiProxy(ctx, DEFAULTS) const before = expectOk(await api.credentials.describe(request({ refs: ['OPENAI_API_KEY'] }))) expect(before.credentials).toEqual({ OPENAI_API_KEY: { configured: false, writable: true } }) - const frames = await collectHost(api, ['host/credentials-changed'], 2, async () => { + const frames = await collectHost(api, ['host/remote-event'], 2, async () => { expectOk(await api.credentials.set(request({ ref: 'OPENAI_API_KEY', value: 'sk-secret' }))) const after = expectOk(await api.credentials.describe(request({ refs: ['OPENAI_API_KEY'] }))) expect(after.credentials).toEqual({ OPENAI_API_KEY: { configured: true, source: 'file', writable: true } }) @@ -568,8 +580,8 @@ describe('credentials domain', () => { expectOk(await api.credentials.unset(request({ ref: 'OPENAI_API_KEY' }))) }) expect(frames).toEqual([ - { type: 'host/credentials-changed', ref: 'OPENAI_API_KEY' }, - { type: 'host/credentials-changed', ref: 'OPENAI_API_KEY' }, + { type: 'host/remote-event', event: 'credentials/updated', args: ['OPENAI_API_KEY'] }, + { type: 'host/remote-event', event: 'credentials/updated', args: ['OPENAI_API_KEY'] }, ]) }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 789639b70d..c3af246240 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -505,7 +505,8 @@ describe('events frame schemas', () => { createdAt: '0', updatedAt: '0', } }, { type: 'host/workspace-removed', workspaceId: 'w' }, - { type: 'host/commands-changed' }, + { type: 'host/remote-event', event: 'commands/change', args: [] }, + { type: 'host/remote-event', event: 'settings/document-updated', args: ['ns', 3] }, { type: 'host/session-preset-changed', sessionId: 's', agentPreset: 'minimal' }, { type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } }, ] diff --git a/packages/interaction/commands/src/index.ts b/packages/interaction/commands/src/index.ts index f30f246d64..56b5d4da14 100644 --- a/packages/interaction/commands/src/index.ts +++ b/packages/interaction/commands/src/index.ts @@ -123,16 +123,6 @@ declare module '@deepseek-ai/cordis' { interface Context { commands: CommandService } - - interface Events { - /** - * A command was registered or unregistered. This is an unfiltered registry - * notification because a global or scoped change may affect any UI view. - * Observer failures are contained and cannot veto the registry mutation. - * @mode emit - */ - 'commands/change'(): void - } } /** diff --git a/packages/interaction/commands/src/types.ts b/packages/interaction/commands/src/types.ts index 2b81071d37..27309355d4 100644 --- a/packages/interaction/commands/src/types.ts +++ b/packages/interaction/commands/src/types.ts @@ -1,5 +1,8 @@ /** - * Durable command event vocabulary shared with type-only consumers. + * Durable command event vocabulary and the registry's Cordis event + * declaration, shared with type-only consumers. Client-safe: nothing here + * reaches a Host-only symbol, so a Client compilation face reads the same + * `commands/change` signature the Host emits. * * @module @deepseek-ai/dsh-commands/types */ @@ -19,6 +22,18 @@ export interface CommandSourceMap { /** The union over {@link CommandSourceMap} — who issued a command line. */ export type CommandSource = CommandSourceMap[keyof CommandSourceMap] +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * A command was registered or unregistered. This is an unfiltered registry + * notification because a global or scoped change may affect any UI view. + * Observer failures are contained and cannot veto the registry mutation. + * @mode emit + */ + 'commands/change'(): void + } +} + declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index 9899ded2cc..08424a08d8 100644 --- a/packages/settings/settings/package.json +++ b/packages/settings/settings/package.json @@ -22,12 +22,17 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index cacb1c5993..1640accc6a 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -8,15 +8,13 @@ import { Context, Service } from '@deepseek-ai/cordis' import type z from '@deepseek-ai/schemastery' -import type { Branded } from '@deepseek-ai/dsh-brand' import { redactSecrets } from './redact.ts' import type { RedactedSecret } from './redact.ts' +import type { SettingsNamespace, SettingsUpdateSource } from './types.ts' export { redactSecrets } from './redact.ts' export type { RedactedSecret, RedactedValue } from './redact.ts' - -/** Nominal id of one registered settings namespace. */ -export type SettingsNamespace = Branded<'SettingsNamespace'> +export type { SettingsNamespace, SettingsUpdateSource } from './types.ts' const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/ @@ -35,9 +33,6 @@ export function settingsNamespace(value: string): SettingsNamespace { /** When a namespace's changes take effect for its owner. */ export type SettingsApplies = 'live' | 'restart' -/** Origin of one committed settings change. */ -export type SettingsUpdateSource = 'update' | 'provider' - /** Registration options beyond the namespace schema. */ export interface SettingsRegisterOptions { /** Composition-layer values resolved below the user layer (entry-config subset). */ @@ -137,38 +132,6 @@ declare module '@deepseek-ai/cordis' { interface Context { settings: Settings } - - interface Events { - /** - * Committed change to one registered namespace's resolved value. Emitted - * after the provider persisted (for `update`) or published (`provider`) - * the change; never emitted when the resolved value is deep-equal. - * Listener failures are contained and logged — a sync throw and an async - * rejection alike — except `INVARIANT`-coded failures, which rethrow - * after every listener ran; that rethrow reaches the emitter only from - * synchronous listeners, so invariant checks on this event must not be - * async functions. - * @param ns - the namespace whose resolved value changed. - * @param next - the new resolved value. - * @param prev - the previous resolved value. - * @param source - whether the change entered through `update()` or the provider. - * @mode emit - */ - 'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void - - /** - * One registered namespace's RAW user section changed, whether or not the - * resolved value did. `settings/updated` is the consumer-facing event and - * stays deep-equal-gated; this one exists for configuration surfaces, - * which must learn that a field went from inherited to overridden (same - * resolved value, different meaning) and that their held revision is - * stale. Listener containment matches `settings/updated`. - * @param ns - the namespace whose stored section changed. - * @param revision - the namespace's new revision. - * @mode emit - */ - 'settings/document-updated'(ns: SettingsNamespace, revision: number): void - } } /** diff --git a/packages/settings/settings/src/types.ts b/packages/settings/settings/src/types.ts new file mode 100644 index 0000000000..0b3e3b0e10 --- /dev/null +++ b/packages/settings/settings/src/types.ts @@ -0,0 +1,50 @@ +/** + * Client-safe type surface of the user-settings seam: the namespace brand, the + * commit-origin union, and the seam's Cordis event declarations. Types only — + * no runtime code, and nothing here reaches a Host-only symbol, so a Client + * compilation face reads exactly the signatures the Host emits. + * + * @module @deepseek-ai/dsh-settings/types + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Nominal id of one registered settings namespace. */ +export type SettingsNamespace = Branded<'SettingsNamespace'> + +/** Origin of one committed settings change. */ +export type SettingsUpdateSource = 'update' | 'provider' + +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * Committed change to one registered namespace's resolved value. Emitted + * after the provider persisted (for `update`) or published (`provider`) + * the change; never emitted when the resolved value is deep-equal. + * Listener failures are contained and logged — a sync throw and an async + * rejection alike — except `INVARIANT`-coded failures, which rethrow + * after every listener ran; that rethrow reaches the emitter only from + * synchronous listeners, so invariant checks on this event must not be + * async functions. + * @param ns - the namespace whose resolved value changed. + * @param next - the new resolved value. + * @param prev - the previous resolved value. + * @param source - whether the change entered through `update()` or the provider. + * @mode emit + */ + 'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void + + /** + * One registered namespace's RAW user section changed, whether or not the + * resolved value did. `settings/updated` is the consumer-facing event and + * stays deep-equal-gated; this one exists for configuration surfaces, + * which must learn that a field went from inherited to overridden (same + * resolved value, different meaning) and that their held revision is + * stale. Listener containment matches `settings/updated`. + * @param ns - the namespace whose stored section changed. + * @param revision - the namespace's new revision. + * @mode emit + */ + 'settings/document-updated'(ns: SettingsNamespace, revision: number): void + } +} diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 40a8b5b2ad..3becc67c83 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -49,6 +49,7 @@ export type { TypeRTContextRegistry, TypeRTContextWire, TypeRTDisposer, + TypeRTForwardableEvent, TypeRTHostContextProvider, TypeRTHostContextResolver, TypeRTLocalRegistry, @@ -64,6 +65,8 @@ export type { TypeRTRemoteScopeMap, TypeRTRemoteScopeNamespace, TypeRTRemoteContribution, + TypeRTRemoteEvent, + TypeRTRemoteEventSelection, TypeRTRemoteMap, TypeRTRemoteNamespace, TypeRTRemoteNamespaceMap, diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index 622698bf6c..a4899a7249 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-type-meta/types */ -import type { Context } from '@deepseek-ai/cordis' +import type { Context, Events } from '@deepseek-ai/cordis' declare const LOOKUP_HOST: unique symbol declare const LOOKUP_WIRE: unique symbol @@ -42,6 +42,24 @@ export interface TypeRTRemoteMap {} /** Merge-extensible scoped Remote method signatures generated for consumers. */ export interface TypeRTRemoteScopeMap {} +/** + * Cordis event names whose shape a one-way Remote delivery can carry: unbound + * from any Scope and returning `void`. Which ones are actually forwarded is the + * Host assembly's selection; this predicate only excludes shapes the carrier + * cannot represent. + */ +export type TypeRTForwardableEvent = { + [Event in keyof Events]: unknown extends ThisParameterType + ? ReturnType extends void ? Event : never + : never +}[keyof Events] + +/** Merge-extensible forwarding selection declared once by the Host assembly. */ +export interface TypeRTRemoteEventSelection {} + +/** Legal `$on` keys: selected events that exist in the current compilation face. */ +export type TypeRTRemoteEvent = Extract + /** * Resolve one direct Remote namespace from the generated flat endpoint map. * @template Namespace - wire namespace before the endpoint slash. @@ -184,6 +202,15 @@ export interface TypeRTClientRemote extends TypeRTRemoteNamespaceMap { * @returns disposer after namespace services and concrete methods are ready. */ $mount(contribution: TypeRTRemoteContribution): Promise + /** + * Subscribe to one forwarded Host event; delivery is one-way, in registration + * order, and isolates a throwing listener from the rest. + * @template Event - forwarded event name selected by the Host assembly. + * @param event - forwarded Host event name, unchanged on the wire. + * @param listener - receives the Host's argument list as declared by Cordis `Events`. + * @returns disposer owned by the calling fiber. + */ + $on(event: Event, listener: Events[Event]): () => void } /** @@ -425,4 +452,18 @@ declare module '@deepseek-ai/cordis' { interface Context { typert: TypeRTService } + + interface Events { + /** + * The carrier received one allowlisted host event forwarded over the wire. + * Declared here because both compilation faces share this package; only the + * consumer side participates, where the Client half owning the host frame + * sink emits it and the Remote service is its only subscriber, turning it + * into `$on` callbacks. The Host neither emits nor observes it. + * @mode emit + * @param event - forwarded host event name, exactly as the Host emitted it. + * @param args - the Host argument list, already JSON-decoded. + */ + 'remote/host-event'(event: string, args: readonly unknown[]): void + } } diff --git a/packages/typert/type-meta/tests/type-meta.spec.ts b/packages/typert/type-meta/tests/type-meta.spec.ts index aa77541128..42ab3d8831 100644 --- a/packages/typert/type-meta/tests/type-meta.spec.ts +++ b/packages/typert/type-meta/tests/type-meta.spec.ts @@ -1,7 +1,7 @@ import { execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it } from 'vitest' +import { describe, expect, expectTypeOf, it } from 'vitest' import { bindTypeRTGateway, GatewayService, @@ -9,12 +9,38 @@ import { RemoteScope, remoteMethods, type TypeRTContext, + type TypeRTForwardableEvent, + type TypeRTRemoteEvent, } from '@deepseek-ai/dsh-type-meta' +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * Test-only one-way event: bound to no Scope and returning nothing. + * @param value - marker payload. + */ + 'meta-fixture/forwardable'(value: string): void + /** + * Test-only Scope-bound event, which no carrier can deliver one-way. + * @param value - marker payload. + */ + 'meta-fixture/scoped'(this: Context, value: string): void + /** + * Test-only answered event, whose result no one-way delivery can return. + * @param value - marker payload. + * @returns the replacement value. + */ + 'meta-fixture/answered'(value: string): string + } +} + declare module '@deepseek-ai/dsh-type-meta' { interface TypeRTContextMap { metaFixture: TypeRTContext } + + interface TypeRTRemoteEventSelection extends + Record<'meta-fixture/forwardable' | 'meta-fixture/absent', true> {} } describe('type-meta Remote declarations', () => { @@ -209,6 +235,16 @@ describe('type-meta Remote declarations', () => { expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api/goals' })).toThrow('namespace') expect(() => bindTypeRTGateway({}, 'goals', { namespace: 'api goals' })).toThrow('namespace') }) + + it('admits only one-way event shapes and only selected events that exist', () => { + expectTypeOf<'meta-fixture/forwardable'>().toExtend() + expectTypeOf<'meta-fixture/scoped'>().not.toExtend() + expectTypeOf<'meta-fixture/answered'>().not.toExtend() + + expectTypeOf<'meta-fixture/forwardable'>().toExtend() + expectTypeOf<'meta-fixture/scoped'>().not.toExtend() + expectTypeOf<'meta-fixture/absent'>().not.toExtend() + }) }) function methodContext( diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 08f588d320..e1c65bc8cd 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -173,17 +173,14 @@ export const EVENT_SCOPE_PAGE: Record = { * `packages/x/x/src/**`, so a declared event either renders onto a subsystems * page (via {@link EVENT_SCOPE_PAGE}) or names itself here — never vanishes * silently. Keys are full event names, not scopes: client-face events share - * scopes with rendered host events (`commands/changed` beside `commands/*`), + * scopes with rendered host events (`models/changed` beside `llm/*`), * so a scope-level exemption would mask a host-face regression. */ export const EVENT_WALK_EXEMPTIONS: Record = { - 'commands/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the API', 'connection/reset': 'client-face transport signal — packages/client/runtime/README.md owns the API', - 'credentials/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the API', 'locale/change': 'client-face locale switch signal — packages/client/locale/README.md owns the API', 'models/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the API', 'session/preset-changed': 'client-face per-session catalog invalidation signal — packages/client/runtime/README.md owns the API', - 'settings/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the API', 'slash/input-begin-command': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the API', 'slash/input-consume-token': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the API', 'slash/input-insert-reference': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the API', diff --git a/tsconfig.base.json b/tsconfig.base.json index 30a0e9953b..c3614f011f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -74,6 +74,9 @@ "@deepseek-ai/dsh-commands/brand": ["./packages/interaction/commands/src/brand.ts"], "@deepseek-ai/dsh-commands/types": ["./packages/interaction/commands/src/types.ts"], "@deepseek-ai/dsh-tasks/brand": ["./packages/tasks/tasks/src/brand.ts"], + "@deepseek-ai/dsh-credentials/types": ["./packages/credentials/credentials/src/types.ts"], + "@deepseek-ai/dsh-settings/types": ["./packages/settings/settings/src/types.ts"], + "@deepseek-ai/dsh-api-remotes/types": ["./packages/api/remotes/src/types.ts"], "@deepseek-ai/dsh-compact/checkpoint": ["./packages/compact/compact/src/checkpoint.ts"], "@deepseek-ai/dsh-compact/types": ["./packages/compact/compact/src/types.ts"], "@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"], From 04b2f7bcc98d028429eeef56b990209bb3532c59 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:32:50 +0800 Subject: [PATCH 16/31] test(web): keep the browser e2e out of the Client build graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These e2e boot a real harness and read Host services, so they type-check in the Host aggregate; importing a Client package there pulls its whole project — and everything it references — into the Host build phase, where a Client project waiting on generated artifacts cannot compile. The welcome-notice constants are mirrored in the scaffold instead, which lets apps/cli drop the Client project references that only served as their owner map. --- apps/cli/package.json | 1 - apps/cli/tsconfig.json | 45 ------------------- .../tests/onboarding-deepseek-config.e2e.ts | 6 +-- apps/web/tests/remote-welcome.e2e.ts | 2 +- apps/web/tests/scaffold.ts | 16 +++++-- 5 files changed, 16 insertions(+), 54 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 772ad7de32..694732fe72 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -27,7 +27,6 @@ "@deepseek-ai/dsh-agent-tool-mode": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-base": "workspace:^", - "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", "@deepseek-ai/dsh-command-compact": "workspace:^", "@deepseek-ai/dsh-command-goal": "workspace:^", "@deepseek-ai/dsh-compact-basic": "workspace:^", diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index cecf7a4bb9..09fe87edc3 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -70,51 +70,6 @@ }, { "path": "../../packages/bash/tool-bash" - }, - { - "path": "../../packages/client/connection" - }, - { - "path": "../../packages/client/hmr" - }, - { - "path": "../../packages/client/runtime" - }, - { - "path": "../../packages/client/ui-theme" - }, - { - "path": "../../packages/client/ui-settings" - }, - { - "path": "../../packages/client/ui-settings-general" - }, - { - "path": "../../packages/client/ui-models" - }, - { - "path": "../../packages/client/ui-permission" - }, - { - "path": "../../packages/client/locale" - }, - { - "path": "../../packages/client/ui-layout" - }, - { - "path": "../../packages/client/ui-sidebar" - }, - { - "path": "../../packages/client/ui-conversation" - }, - { - "path": "../../packages/client/ui-plan" - }, - { - "path": "../../packages/client/ui-trajectory" - }, - { - "path": "../../packages/client/ui-question" } ] } diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 14b741eb80..77eba279ba 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -10,14 +10,12 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_ACK_FIELD, + WELCOME_NOTICE_VERSION, WELCOME_NOTICE_COPY, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts' import { settingsNamespace } from '@deepseek-ai/dsh-settings' -import { - WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE, - WELCOME_NOTICE_VERSION, -} from '@deepseek-ai/dsh-client-ui-settings-general' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-deepseek-config', import.meta.url)) const WELCOME_EXPECTED = join(SNAPSHOT_DIR, 'welcome.expected.md') diff --git a/apps/web/tests/remote-welcome.e2e.ts b/apps/web/tests/remote-welcome.e2e.ts index 483d6c369c..7187936ae9 100644 --- a/apps/web/tests/remote-welcome.e2e.ts +++ b/apps/web/tests/remote-welcome.e2e.ts @@ -5,10 +5,10 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, webSnapshotMode, + WELCOME_NOTICE_COPY, type WebScaffold, } from './scaffold.ts' import { ZH_BROWSER_LOCALE } from './support.ts' -import { WELCOME_NOTICE_COPY } from '@deepseek-ai/dsh-client-ui-settings-general' const MODE = webSnapshotMode() diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 0f13282b49..87f75b383d 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -41,9 +41,19 @@ import { loadOverlayPatches, } from '@deepseek-ai/dsh-app-boot' import { dshHomePath } from '@deepseek-ai/dsh-paths' -import { - WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION, -} from '@deepseek-ai/dsh-client-ui-settings-general' +// Client packages must not be imported here: these e2e type-check in the Host +// aggregate, so a Client import pulls that package's whole project — and every +// project it references — into the Host build graph. Mirrored from +// packages/client/ui-settings-general/src/onboarding-copy.ts; a drift makes the +// pre-acknowledgement stop suppressing the notice, which fails loudly. +// import { +// WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION, WELCOME_NOTICE_COPY, +// } from '@deepseek-ai/dsh-client-ui-settings-general' +export const WELCOME_NOTICE_SETTINGS_NAMESPACE = 'ui-onboarding' +export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion' +export const WELCOME_NOTICE_VERSION = '2026-07-30.7' +export const WELCOME_NOTICE_COPY = { zh: { title: '内测声明', continueLabel: '继续' } } as const + import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { LlmAdapter } from '@deepseek-ai/dsh-llm' import type { From 77ccffa35e70070d92b7c604019d36f76be39412 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:06:25 +0800 Subject: [PATCH 17/31] feat(api-remotes): enforce the forwardable shape and resolve the invariant companion The allowlist's satisfies assertion is active again, so a name that is not a declared event, binds a Scope, or is not one-way fails at the allowlist rather than on the wire; the owner packages' ./types imports are what give it a real event vocabulary to judge. The companion's own paths entry lets its test resolve it from source instead of a built artifact, matching the api-gateway entry. --- packages/api/remotes/src/index.ts | 20 ++++++++++---------- tsconfig.base.json | 1 + 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index b95864fc5f..1d592f49f9 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -1,15 +1,15 @@ /** Host BFF entry and Loader shell for the Remote contribution assembly. */ -// import type { TypeRTForwardableEvent } from '@deepseek-ai/dsh-type-meta' -// import { API_REMOTE_FORWARDED_EVENTS } from './types.ts' +import type { TypeRTForwardableEvent } from '@deepseek-ai/dsh-type-meta' +import { API_REMOTE_FORWARDED_EVENTS } from './types.ts' -// // The owner packages' client-safe `./types` exports carry the cordis `Events` -// // declarations for every allowlisted event. Pulling them into this face is what -// // makes the shape assertion below judge real signatures rather than an empty -// // event vocabulary. -// import type {} from '@deepseek-ai/dsh-commands/types' -// import type {} from '@deepseek-ai/dsh-credentials/types' -// import type {} from '@deepseek-ai/dsh-settings/types' +// The owner packages' client-safe `./types` exports carry the cordis `Events` +// declarations for every allowlisted event. Pulling them into this face is what +// makes the shape assertion below judge real signatures rather than an empty +// event vocabulary. +import type {} from '@deepseek-ai/dsh-commands/types' +import type {} from '@deepseek-ai/dsh-credentials/types' +import type {} from '@deepseek-ai/dsh-settings/types' export { ApiRemoteSessionNotFound, @@ -35,7 +35,7 @@ export type { ApiRemoteForwardedEvent } from './types.ts' // and every entry is ONE-WAY (a waterfall or bail shape returns something other // than void and is excluded). Widening the array to an event that fails any of // these fails here, not on the wire. -// API_REMOTE_FORWARDED_EVENTS satisfies readonly TypeRTForwardableEvent[] +API_REMOTE_FORWARDED_EVENTS satisfies readonly TypeRTForwardableEvent[] /** Host plugin body; the selected contributions mount only in Client environments. */ export function apply(): void {} diff --git a/tsconfig.base.json b/tsconfig.base.json index c3614f011f..afaa558abd 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -77,6 +77,7 @@ "@deepseek-ai/dsh-credentials/types": ["./packages/credentials/credentials/src/types.ts"], "@deepseek-ai/dsh-settings/types": ["./packages/settings/settings/src/types.ts"], "@deepseek-ai/dsh-api-remotes/types": ["./packages/api/remotes/src/types.ts"], + "@deepseek-ai/dsh-api-remotes/invariant": ["./packages/api/remotes/src/invariant.ts"], "@deepseek-ai/dsh-compact/checkpoint": ["./packages/compact/compact/src/checkpoint.ts"], "@deepseek-ai/dsh-compact/types": ["./packages/compact/compact/src/types.ts"], "@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"], From 01ecb43ebcdb304543c84d8c414f755b7e2cc468 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:06:25 +0800 Subject: [PATCH 18/31] docs: state the Host-face rule for the browser e2e and settle the follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/web/tests/README.md records why these e2e type-check in the Host aggregate and why importing a Client package there pulls its project tree into the Host build graph, with mirroring as the standing answer. The Agent Note drops the directory-picker face split (assessed and declined) and the grep-level gate in favour of that README. docs: regenerate the catalogs and retarget the moved declarations The forwarded-event change moved three owner packages' cordis `Events` declarations and their branded types into client-safe `./types` modules, and the settings-scope split moves the shell spec into ui-settings-general. Point the type-equivalence manifest and the affected Agent Note at those homes, register the new `remote/*` event scope and the `ctx.settingsScope` service in the catalog partition, and re-run the generators. `$on` joins the documented `TypeRTClientRemote` surface, and the two Agent Note fences that quote a bare member signature are marked `ignore-check`: they are declaration fragments, not compilable units. refactor(client): make ui-settings the settings domain's base layer The settings-namespace transport lived in client/runtime, where every feature could value-import it because runtime is a platform module. It belongs to the settings domain, but moving it into ui-settings as a shared function fails twice: the client bundle purity gate forbids cross-plugin value imports, and ui-settings reached ui-sidebar for its shell, so any feature depending on it closed a cycle through ui-layout and ui-theme. Both halves move. `ctx.settingsScope` is now a cordis service — the collaboration shape the purity gate prescribes, and the service proxy binds `this.ctx` to the caller, so a bound scope's disposer belongs to the calling fiber. The shell ui-settings used to own (the `sidebar.settings` occupant, its navigation, and the nav-row projection) moves to ui-settings-general, which already owns the chrome and the General section. What stays in ui-settings is what carries no `ui-*` dependency: the scope service and the canonical settings slot types, `settings.general.item` included. That type was parked in the locale package precisely because the declarer was unreachable without a cycle; every registrant now depends on this base layer, so it comes home. The scope CONTRACT stays in client/runtime: a feature service accepts a scope through its own signature without depending on the surface that binds it. The forwarded settings invalidation replaces the deleted client-side `settings/changed` event, so the transport reads `ctx.remote.$on`. It reaches `$on` through the gateway's Client half plus the allowlist's type-only subpath rather than api-remotes' Client face: that face imports a Host-tsdown-generated artifact, and this package is reachable from the Host build graph through its callers. refactor(client): reach the settings transport through ctx.settingsScope Every feature that owns a preference row switches from value-importing a shared binder to the settings domain's service, and declares the two injections that binding needs: `settingsScope` for the transport and `remote` for the forwarded invalidation it subscribes to on the caller's own context. The rows stay with the features that own the preferences — Language with locale, Appearance with ui-theme, Composer Enter with ui-conversation. Only their route to the transport changes, so no settings surface moves and no feature gains a dependency on the shell. The `settings.general.item` slot type now arrives from ui-settings, the base layer every registrant already depends on, which retires the re-export outlet ui-theme kept and the parked declaration in the locale package. client/runtime drops its settings-form and schemastery dependencies with the transport that used them. test(client): bind the settings transport in the specs that boot a preference row Every bench that activates a plugin owning a preference row now supplies the two services that plugin injects: the forwarded-event port and the scope service. Specs that exercise no settings path get the minimal doubles; the ones that do drive their refresh chains through `remote/host-event`, the same signal client/runtime republishes from a forwarded frame, replacing the deleted client-side `settings/changed` event. Also fixes a publication defect the built-invariant gate catches once it runs: api-remotes' invariant companion shared the allowlist module with the package index, so rolldown hoisted it into a third chunk beside the two bundled entries — a file the mechanically derived publication list does not carry, leaving an installed companion unable to import it. The companion now reads the allowlist through this package's own published `./types` subpath, which the bundle keeps external, so each entry stays self-contained. The dynamic-subscription cast in apiproxy is gone: after the vendored cordis rescope, `on` accepts the rest-parameter handler directly, and the allowlist's shape assertion still carries the safety argument. fix(client): carry the settings-scope move across the release manifests Rebasing onto the publishable release set replaced every manifest's dependency block, so the packages this change touches restate their additions in the workspace-protocol form: the base layer's own transport dependencies, and the `ui-settings` plus `remote` edges each preference-row owner now needs. ui-settings-general takes clsx with the shell it received, and client/runtime drops the settings-form and schemastery dependencies that left with the transport. fix(api-gateway): give each $on subscription its own registration and containment Two defects in the forwarded-event subscription table, both raised in review: A set keyed on listener identity stored one entry when two callers subscribed the same function object to the same event, so the first frame reached it once instead of twice and either disposer silenced the surviving registration. Subscriptions are now records addressed by registration, which is what "the disposer belongs to the calling fiber" requires. A listener declared void may still be `async`, and the synchronous `try/catch` could not see its rejection: the promise was dropped and surfaced as an unhandled rejection outside the documented containment. Delivery now attaches a rejection handler when a listener returns a promise, so both failure modes are logged and isolated alike. Delivery also iterates a snapshot, so a listener that subscribes or disposes during a frame no longer changes who receives that frame, and production matches the TestRemote double instead of relying on live Set iteration order. Both fixes are pinned by tests that fail against the previous implementation. The double gains its own spec for the `$mount` refusal and the unsubscribed-name drop — per-file coverage reaches it — plus a note that it propagates a throwing listener where production contains one, so no spec mistakes it for the containment guarantee. Three prose corrections: `assertJsonArgs` states where its throw actually surfaces (the emitter's listener containment, not load or emit time), the browser e2e README names every standing Client import rather than claiming one exception, and two comments and a test title state the forwarded event instead of the deleted client-side one. refactor(remote): deliver forwarded frames through ctx.remote.$dispatch The carrier used to relay each decoded frame over an internal `remote/host-event` cordis event so the delivery port could stay off the Remote contract. The relay was the wrong shape twice over: it put a client-face event into a scan whose subject is the Host vocabulary, forcing a walk exemption for something that is not a Host event at all, and it made a direct handoff between two Client plugins look like a broadcast any plugin participates in. `TypeRTClientRemote` now carries both roles of one surface — consumers subscribe with `$on`, and whoever owns the Host frame sink hands frames over with `$dispatch` — so client/runtime calls the Remote service directly and the event declaration is gone. A cordis service method is the collaboration shape the client bundle purity gate prescribes, and it needs no relay to satisfy it. The trade is that the handoff is now developer-visible: any plugin holding `ctx.remote` can synthesize a forwarded event. That is the exposure the relay already had — `ctx.emit` was equally reachable — stated in the contract instead of hidden behind a private subscriber. runtime reaches `ctx.remote` through the gateway's Client face rather than api-remotes': that face imports a Host-tsdown-generated artifact, and this project sits in the Host build graph. refactor(api-remotes): keep the allowlist value out of types.ts `src/types.ts` carries only types by package convention, but it held the forwarded-event array, so the type-only subpath published runtime code. The array moves to `src/remote-events.ts` and `types.ts` derives its projection from it; both compiler faces list both files, so the Host forwarding loop and the consumer key face still read one declaration and the package's exports are unchanged. The invariant companion returns to an empty installer. Its dispatch-shape check was the only reason the companion imported the allowlist, which made the two bundled entries share a module: rolldown hoisted it into a third chunk that the mechanically derived publication list does not carry, so an installed companion could not import it. Dropping the check retires that coupling along with the subpath-import and bundle-external workarounds it needed, and the shape the check enforced at runtime is the part the Host face's `TypeRTForwardableEvent` assertion already refuses at compile time. test(ui-task): bind the locale plugin's new injections in its bench The bench boots the real locale plugin, which now injects the settings-scope service and the forwarded-event port, so it stayed pending and left `ctx.locale` undefined. Supplies both doubles like the other benches that boot a plugin owning a preference row. docs: close the documentation gates for the forwarded-event surface Regenerates the two graph catalogs and re-records every bilingual pair this branch edited. Several pairs needed real work beyond the record: - The generators write only the English side, so the Chinese sides of `event-producer-consumer` and `module-graph` had drifted: the former still listed the three deleted client-face events and pointed at declaration sites this branch moved into `types.ts` modules, and the latter carried a stale dependency graph. - `TypeRTClientRemote`'s documented declaration gains `$dispatch` on both sides. - The pairing contract requires both sides to link the same target, so the apiproxy README and the design note now link the English note from both languages, and the note's code blocks are byte-identical across the pair (a translated comment inside a fence counts as divergence). - `apps/web/tests/README.md` gains its Chinese counterpart; the browser e2e lane documents a discipline reviewers apply, so it belongs in the bilingual corpus rather than in the pairing exemption list. - Four fences in the design note are marked `ignore-check`: each quotes a member signature, a union arm, or a snippet that names symbols it does not import, so none is a compilable unit. docs(agent-note): transition the forwarded-event note to implemented The design shipped in this PR, so the pair moves into `implemented/` and takes that folder's skeleton: `## Proposal` becomes a present-tense `## Decision`, and `## Acceptance criteria` plus `## Risks` fold into `## Verification` (what pins the behavior) and `## Consequences` (what the shipped shape costs). Facts that moved after the proposal are corrected rather than preserved: the allowlist value now lives in `remote-events.ts` beside a type-only `types.ts`, the delivery port is `$dispatch` rather than an internal cordis event, and the invariant companion is an explained empty installer. `Verification` states the two `$on` defects the review found — independent registration identity and async-rejection containment — since those are now the properties tests pin. Supersession is partial, so five active notes stay active and gain a cross-link each: `web-config-plane`, `web-client-session-scope`, `config-plane-boundaries`, `versioned-gui-welcome-onboarding`, and `permission-default-for-new-sessions` each described a frame this change replaced. Only the mechanism sentence is annotated; every conclusion those notes own is untouched, and `host/models-changed` remains apiproxy's own derived frame in all of them. Also pins the disposer's idempotence: calling one `$on` disposer twice must not splice a surviving twin registration out from under its owner. fix: docs fix: test --- ...ession-scope-and-provide-channel.i18n.yaml | 4 +- ...lient-session-scope-and-provide-channel.md | 2 +- ...nt-session-scope-and-provide-channel.zh.md | 2 +- ...26-07-30-config-plane-boundaries.i18n.yaml | 4 +- .../2026-07-30-config-plane-boundaries.md | 2 +- .../2026-07-30-config-plane-boundaries.zh.md | 2 +- .../2026-07-30-web-config-plane.i18n.yaml | 4 +- .../2026-07-30-web-config-plane.md | 2 +- .../2026-07-30-web-config-plane.zh.md | 2 +- ...2026-08-10-remote-event-delivery.i18n.yaml | 6 + .../2026-08-10-remote-event-delivery.md | 94 +++-- .../2026-08-10-remote-event-delivery.zh.md | 103 +++-- ...rding-step-owned-takeover-chrome.i18n.yaml | 4 +- ...6-onboarding-step-owned-takeover-chrome.md | 2 +- ...nboarding-step-owned-takeover-chrome.zh.md | 2 +- ...versioned-gui-welcome-onboarding.i18n.yaml | 4 +- ...-07-30-versioned-gui-welcome-onboarding.md | 2 +- ...-30-versioned-gui-welcome-onboarding.zh.md | 2 +- ...mission-default-for-new-sessions.i18n.yaml | 4 +- ...-31-permission-default-for-new-sessions.md | 2 +- ...-permission-default-for-new-sessions.zh.md | 2 +- apps/web/tests/README.i18n.yaml | 6 + apps/web/tests/README.md | 46 +++ apps/web/tests/README.zh.md | 37 ++ .../tests/onboarding-deepseek-config.e2e.ts | 2 +- docs/development.i18n.yaml | 4 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 19 +- docs/event-producer-consumer.zh.md | 31 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 356 ++++++++++-------- docs/module-graph.zh.md | 356 ++++++++++-------- docs/persistence-catalog.i18n.yaml | 2 +- docs/persistence-catalog.md | 32 +- docs/subsystems/commands.i18n.yaml | 4 +- docs/subsystems/commands.md | 4 +- docs/subsystems/commands.zh.md | 4 +- docs/subsystems/credentials.i18n.yaml | 4 +- docs/subsystems/credentials.md | 4 +- docs/subsystems/credentials.zh.md | 4 +- docs/subsystems/settings.i18n.yaml | 4 +- docs/subsystems/settings.md | 6 +- docs/subsystems/settings.zh.md | 6 +- docs/subsystems/typert.i18n.yaml | 4 +- docs/subsystems/typert.md | 21 ++ docs/subsystems/typert.zh.md | 21 ++ knip.json | 14 +- packages/api/gateway/README.i18n.yaml | 4 +- packages/api/gateway/README.md | 2 +- packages/api/gateway/README.zh.md | 2 +- packages/api/gateway/src/client/index.ts | 54 ++- packages/api/gateway/tests/client.spec.ts | 67 +++- packages/api/remotes/README.i18n.yaml | 4 +- packages/api/remotes/src/client/index.ts | 7 +- packages/api/remotes/src/index.ts | 4 +- packages/api/remotes/src/invariant.ts | 39 +- packages/api/remotes/src/remote-events.ts | 21 ++ packages/api/remotes/src/types.ts | 24 +- packages/api/remotes/tests/invariant.spec.ts | 65 ---- packages/api/remotes/tsconfig.client.json | 1 + packages/api/remotes/tsconfig.host.json | 1 + packages/client/locale/package.json | 11 +- .../client/locale/src/client/LanguageRow.tsx | 2 +- packages/client/locale/src/client/index.ts | 13 +- .../locale/src/client/settings-contract.ts | 26 -- packages/client/locale/tests/apply.spec.ts | 11 +- .../client/locale/tests/invariant.spec.ts | 6 +- packages/client/locale/tsconfig.json | 3 + packages/client/runtime/package.json | 9 +- .../src/client/contract/settings-scope.ts | 63 ++++ packages/client/runtime/src/client/index.ts | 21 +- .../client/runtime/tests/wire-events.spec.ts | 21 +- packages/client/runtime/tsconfig.json | 6 +- packages/client/test-runtime/src/remote.ts | 33 +- .../client/test-runtime/tests/remote.spec.ts | 43 +++ .../src/client/section-store.ts | 7 +- .../ui-agent-preset/tests/apply.spec.ts | 12 +- .../client/ui-command/tests/service.spec.ts | 6 +- packages/client/ui-conversation/package.json | 21 +- .../ui-conversation/src/client/apply.ts | 9 +- .../tests/apply-inject.spec.tsx | 5 +- .../tests/assembly-surfaces.spec.tsx | 14 +- .../ui-conversation/tests/chat-apply.spec.tsx | 5 +- packages/client/ui-conversation/tsconfig.json | 3 + .../tests/produced-files.spec.tsx | 5 +- packages/client/ui-layout/tests/apply.spec.ts | 4 + packages/client/ui-models/tests/apply.spec.ts | 8 +- packages/client/ui-permission/package.json | 6 +- .../client/ui-permission/src/client/index.ts | 2 + .../tests/browser-plugin.spec.ts | 6 +- packages/client/ui-permission/tsconfig.json | 3 + .../ui-settings-general/README.i18n.yaml | 4 +- .../client/ui-settings-general/package.json | 12 +- .../src/client/SettingsRoot.module.css | 0 .../src/client/SettingsRoot.tsx | 2 +- .../ui-settings-general/src/client/index.ts | 82 +++- .../src/client/shell-contract.ts | 59 +++ .../ui-settings-general/tests/apply.spec.ts | 6 +- .../tests/settings-root.spec.tsx | 2 +- .../tests/shell.spec.ts} | 28 +- .../client/ui-settings-general/tsconfig.json | 3 + packages/client/ui-settings/package.json | 33 +- .../ui-settings/src/client/contract/slots.ts | 66 +--- .../client/ui-settings/src/client/index.ts | 113 +----- .../src/client/settings-scope.ts | 174 ++++----- .../tests/settings-scope.spec.ts | 27 +- packages/client/ui-settings/tsconfig.json | 17 +- .../ui-subagent/tests/browser-plugin.spec.ts | 7 + .../ui-task/tests/browser-plugin.spec.ts | 6 +- packages/client/ui-theme/package.json | 11 +- .../ui-theme/src/client/AppearanceRow.tsx | 2 +- packages/client/ui-theme/src/client/index.ts | 17 +- .../ui-theme/src/client/settings-contract.ts | 9 - packages/client/ui-theme/tests/apply.spec.ts | 12 +- .../client/ui-theme/tests/invariant.spec.ts | 6 +- packages/client/ui-theme/tsconfig.json | 3 + .../ui-tool/tests/assembly-surfaces.spec.tsx | 5 +- .../ui-tool/tests/chat-code-subcalls.spec.tsx | 4 + .../ui-tool/tests/toolview-slot.spec.tsx | 8 +- packages/client/ui-trajectory/package.json | 1 + .../client/ui-trajectory/tests/views.spec.tsx | 6 +- .../credentials/credentials/README.i18n.yaml | 4 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 21 +- .../apiproxy/tests/api-proxy-config.spec.ts | 5 +- packages/settings/settings/README.i18n.yaml | 4 +- packages/typert/type-meta/README.i18n.yaml | 4 +- packages/typert/type-meta/README.md | 2 +- packages/typert/type-meta/README.zh.md | 2 +- packages/typert/type-meta/src/types.ts | 26 +- pnpm-lock.yaml | 103 +++-- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 6 +- 135 files changed, 1664 insertions(+), 1176 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml rename .agents/notes/{proposed => implemented}/architecture/2026-08-10-remote-event-delivery.md (60%) rename .agents/notes/{proposed => implemented}/architecture/2026-08-10-remote-event-delivery.zh.md (54%) create mode 100644 apps/web/tests/README.i18n.yaml create mode 100644 apps/web/tests/README.md create mode 100644 apps/web/tests/README.zh.md create mode 100644 packages/api/remotes/src/remote-events.ts delete mode 100644 packages/api/remotes/tests/invariant.spec.ts delete mode 100644 packages/client/locale/src/client/settings-contract.ts create mode 100644 packages/client/runtime/src/client/contract/settings-scope.ts create mode 100644 packages/client/test-runtime/tests/remote.spec.ts rename packages/client/{ui-settings => ui-settings-general}/src/client/SettingsRoot.module.css (100%) rename packages/client/{ui-settings => ui-settings-general}/src/client/SettingsRoot.tsx (99%) create mode 100644 packages/client/ui-settings-general/src/client/shell-contract.ts rename packages/client/{ui-settings => ui-settings-general}/tests/settings-root.spec.tsx (99%) rename packages/client/{ui-settings/tests/apply.spec.ts => ui-settings-general/tests/shell.spec.ts} (83%) rename packages/client/{runtime => ui-settings}/src/client/settings-scope.ts (62%) rename packages/client/{runtime => ui-settings}/tests/settings-scope.spec.ts (93%) delete mode 100644 packages/client/ui-theme/src/client/settings-contract.ts diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index 03688e1ab2..2e396878a8 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.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-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: 561f5792e40ef740cdac56af5efb2d08fa15333c -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 78440131c4e09c9a458009fbe5a2a34a707a481c +2026-07-25-web-client-session-scope-and-provide-channel.md: cca090096dfff8dee1f221a87cdf406252a52a34 +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 701a4f585c41a23be12883417c5ae0074431096c diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index 561f5792e4..cca090096d 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -106,7 +106,7 @@ Slot scope is the closed set `root | session-maybe | session`: ### Host wire smalls - The summary `blank` column and the `host/session-added` frame's `blank` field (see the blank bit above). -- The SSE frame `host/commands-changed` (a pure invalidation signal); the client routes it into the typed events `commands/changed` and `connection/reset` (broadcast after each connection generation is established; wire-derived caches uniformly treat prior state as stale). +- The SSE frame `host/commands-changed` (a pure invalidation signal); the client routes it into the typed events `commands/changed` and `connection/reset` (broadcast after each connection generation is established; wire-derived caches uniformly treat prior state as stale). The commands frame and its typed client event were later replaced by verbatim forwarding of `commands/change` through `ctx.remote.$on` ([forwarded Remote events](2026-08-10-remote-event-delivery.md)); `connection/reset` is unchanged, and the invalidation-not-diffing contract this bullet states still holds. - `command.list/execute` and `skill.list` are uniformly single-addressed by `sessionId` (a session always has an Agent; `agentFor`'s resume semantics come ready-made); the command-surface narrative lives in the [command surfaces note](2026-07-25-web-command-surfaces-and-assembly.md). - The `session.create` request shape: workspaceId/cwd as either-or, plus an optional caller-preallocated sessionId (a same-id same-cwd retry is idempotent; a different cwd reports `session-conflict`). diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 78440131c4..701a4f585c 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -106,7 +106,7 @@ slot scope 是闭集 `root | session-maybe | session`: ### host wire 小件 - summary `blank` 列与 `host/session-added` 帧 `blank` 字段(见上文 blank 位)。 -- SSE(Server-Sent Events)帧 `host/commands-changed`(纯失效信号);client 路由为类型事件 `commands/changed` 与 `connection/reset`(连接代建立后广播,wire 派生缓存一律视旧态为陈旧)。 +- SSE(Server-Sent Events)帧 `host/commands-changed`(纯失效信号);client 路由为类型事件 `commands/changed` 与 `connection/reset`(连接代建立后广播,wire 派生缓存一律视旧态为陈旧)。 该 commands 帧及其类型化 client 事件后来被「`commands/change` 经 `ctx.remote.$on` 原样转发」取代([转发的 Remote 事件](2026-08-10-remote-event-delivery.md));`connection/reset` 不变,本条陈述的「失效而非差分」契约依然成立。 - `command.list/execute`、`skill.list` 一律 `sessionId` 单址(会话恒有 Agent,`agentFor` 的恢复语义现成);命令面叙述见[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.md)。 - `session.create` 请求形状:workspaceId/cwd 二选一 + 可选调用方预分配 sessionId(同 id 同 cwd 重试幂等,异 cwd 报 `session-conflict`)。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml index ffa3375b74..db54057b3c 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.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-30-config-plane-boundaries.md -2026-07-30-config-plane-boundaries.md: 383163d5315ce0fcb39d06ac8b5f7762758fa9b2 -2026-07-30-config-plane-boundaries.zh.md: 0a412d5b8517c98accd056b690129f4e485f0e13 +2026-07-30-config-plane-boundaries.md: f2c4981d6ddf772ee050db0be67dad0b063e2f50 +2026-07-30-config-plane-boundaries.zh.md: deec710e3c54da58333145a902f22154d15bafbb diff --git a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md index 383163d531..f2c4981d6d 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md +++ b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md @@ -26,7 +26,7 @@ Three smaller defects sat beside them. `llm/adapters-updated` documented contain **Staleness is detected, not ordered away.** Each namespace carries a monotonic `revision` over its RAW section; writes may carry `expectedRevision`, and a mismatch rejects with `SettingsConflictError` → `settings-conflict` on the wire, both revisions attached. The editor captures the revision it opened at and, on conflict, tells the user to reopen rather than replaying its snapshot. -**The raw layer gets its own event.** `settings/updated` stays gated on the resolved value — that is what a consumer means by change. `settings/document-updated (ns, revision)` fires on any raw-section change, because a configuration surface must learn that a field went from inherited to overridden (same resolved value, different meaning) and that its held revision is stale. The host frame `host/settings-changed` now rides this event, and a change to an exposed provider namespace also emits `host/models-changed`: that namespace holds the provider's catalog, which no route change announces. +**The raw layer gets its own event.** `settings/updated` stays gated on the resolved value — that is what a consumer means by change. `settings/document-updated (ns, revision)` fires on any raw-section change, because a configuration surface must learn that a field went from inherited to overridden (same resolved value, different meaning) and that its held revision is stale. The host frame `host/settings-changed` rode this event, and a change to an exposed provider namespace also emits `host/models-changed`: that namespace holds the provider's catalog, which no route change announces. (`settings/document-updated` is now forwarded to consumers verbatim instead of being reframed — see [forwarded Remote events](2026-08-10-remote-event-delivery.md) — which does not change why the raw layer needs its own event; the derived `host/models-changed` still rides it.) ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md index 0a412d5b85..deec710e3c 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md @@ -26,7 +26,7 @@ Status: implemented **过期是被检测出来的,而不是靠排序绕过去的。**每个 namespace 都带有一个针对其**原始**分节的单调 `revision`;写入可携带 `expectedRevision`,不匹配即以 `SettingsConflictError` 拒绝——在协议上是 `settings-conflict`,并附上两个 revision。编辑器记住自己打开时的 revision,冲突时请用户重新打开,而不是把自己的快照重放上去。 -**原始层拥有自己的事件。**`settings/updated` 仍以解析值为门槛——那才是消费方所说的"变化"。`settings/document-updated (ns, revision)` 则在任何原始分节变化时触发,因为配置界面必须知道某个字段从继承变成了覆盖(解析值相同,含义不同),也必须知道自己持有的 revision 已经过期。host 帧 `host/settings-changed` 现在搭乘这个事件;而已暴露提供方 namespace 的变更还会额外发出 `host/models-changed`:该 namespace 正持有这个提供方的目录,而没有任何路由变更会宣告它。 +**原始层拥有自己的事件。**`settings/updated` 仍以解析值为门槛——那才是消费方所说的"变化"。`settings/document-updated (ns, revision)` 则在任何原始分节变化时触发,因为配置界面必须知道某个字段从继承变成了覆盖(解析值相同,含义不同),也必须知道自己持有的 revision 已经过期。host 帧 `host/settings-changed` 曾搭乘这个事件(现在 `settings/document-updated` 改为原样转发给消费端,见[转发的 Remote 事件](2026-08-10-remote-event-delivery.md)——这不改变原始层为何需要自己的事件,派生的 `host/models-changed` 仍搭乘它);而已暴露提供方 namespace 的变更还会额外发出 `host/models-changed`:该 namespace 正持有这个提供方的目录,而没有任何路由变更会宣告它。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index eec7c013a4..c7b36ebf1a 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.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-30-web-config-plane.md -2026-07-30-web-config-plane.md: 1af86fc7917cc40b0544bb5c6254994a87ad7a55 -2026-07-30-web-config-plane.zh.md: 15916fe86e2f5e19e9752721917317aa8c39442d +2026-07-30-web-config-plane.md: 4398ab8c23001d1d75ece439de5d03a57005e3b5 +2026-07-30-web-config-plane.zh.md: b06b45788525d2cd77bf8950251ff84928a27c0a diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index 1af86fc791..4398ab8c23 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -12,7 +12,7 @@ The request-level configuration seam made LLM adapter configuration restart-free ## Decision -**Wire domains on the compiled RPC map, rejections as codes, invalidations as frames.** `settings.describe/openDocument/update/replace/mutate`, `credentials.describe/set/unset`, `llm.providers`, and `llm.models` (claiming the reserved `host.listModels` surface) join `RpcMethodMap`, so the seven compiler-locked wiring sites keep contract, schema, handler, and client in lockstep. Seam rejections fold into `settings-rejected {ns}` / `credential-rejected {ref}` business errors (HTTP stays a carrier), and three `HostFrame`s — `host/settings-changed {ns}`, `host/credentials-changed {ref}`, `host/models-changed` — follow the `host/commands-changed` shape so every client converges without polling. Settings reads, native actions, and writes join `pickDirectory`/`openPath` in the connection guard's privileged set: loopback + same-origin or 403, because a LAN-exposed dsh web must not accept configuration access from another origin. +**Wire domains on the compiled RPC map, rejections as codes, invalidations as frames.** `settings.describe/openDocument/update/replace/mutate`, `credentials.describe/set/unset`, `llm.providers`, and `llm.models` (claiming the reserved `host.listModels` surface) join `RpcMethodMap`, so the seven compiler-locked wiring sites keep contract, schema, handler, and client in lockstep. Seam rejections fold into `settings-rejected {ns}` / `credential-rejected {ref}` business errors (HTTP stays a carrier), and three `HostFrame`s — `host/settings-changed {ns}`, `host/credentials-changed {ref}`, `host/models-changed` — follow the `host/commands-changed` shape so every client converges without polling. (The settings, credentials, and commands frames were later replaced by verbatim forwarding of the owning Host events; see [forwarded Remote events](2026-08-10-remote-event-delivery.md). `host/models-changed` remains this package's own derived frame, and the convergence-without-polling decision stands.) Settings reads, native actions, and writes join `pickDirectory`/`openPath` in the connection guard's privileged set: loopback + same-origin or 403, because a LAN-exposed dsh web must not accept configuration access from another origin. **`describe()` grows layers and structural secret redaction.** `SettingsDescriptor` carries `base`/`user` beside the effective value, so the form marks "overridden" by presence in the user layer, not value inequality (an override *equal* to the base is still an override). `describe({ redactSecrets: true })` — mandatory at every wire face — strips `role('secret')` subtrees from all three layers via a pure structural walk of the schema (object/dict/array containers; a secret-role subtree is one opaque leaf) and enumerates the stripped slots as `{path, set}`, so a page can render write-only inputs without ever receiving a value. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index 15916fe86e..b06b457885 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -**wire 领域挂上编译期 RPC 映射,拒绝落为错误码,失效落为帧。**`settings.describe/openDocument/update/replace/mutate`、`credentials.describe/set/unset`、`llm.providers` 与 `llm.models`(认领预留的 `host.listModels` 面)一同加入 `RpcMethodMap`,七处由编译器锁定的接线位点因此让约定、schema、处理器与客户端保持步调一致。seam 侧的拒绝折叠为 `settings-rejected {ns}`/`credential-rejected {ref}` 业务错误(HTTP 仍只是载体),三个 `HostFrame`——`host/settings-changed {ns}`、`host/credentials-changed {ref}`、`host/models-changed`——沿用 `host/commands-changed` 的形状,因此每个客户端都无需轮询即可收敛。settings 读取、原生操作与写入和 `pickDirectory`/`openPath` 一起进入连接守卫的特权集合:回环 + 同源,否则 403,因为暴露在局域网上的 dsh web 绝不能接受来自其他源的配置访问。 +**wire 领域挂上编译期 RPC 映射,拒绝落为错误码,失效落为帧。**`settings.describe/openDocument/update/replace/mutate`、`credentials.describe/set/unset`、`llm.providers` 与 `llm.models`(认领预留的 `host.listModels` 面)一同加入 `RpcMethodMap`,七处由编译器锁定的接线位点因此让约定、schema、处理器与客户端保持步调一致。seam 侧的拒绝折叠为 `settings-rejected {ns}`/`credential-rejected {ref}` 业务错误(HTTP 仍只是载体),三个 `HostFrame`——`host/settings-changed {ns}`、`host/credentials-changed {ref}`、`host/models-changed`——沿用 `host/commands-changed` 的形状,因此每个客户端都无需轮询即可收敛。(settings、credentials 与 commands 三个帧后来被「原样转发所属 host 事件」取代,见[转发的 Remote 事件](2026-08-10-remote-event-delivery.md);`host/models-changed` 仍是本包自有的派生帧,此处「无需轮询即收敛」的决策依然成立。)settings 读取、原生操作与写入和 `pickDirectory`/`openPath` 一起进入连接守卫的特权集合:回环 + 同源,否则 403,因为暴露在局域网上的 dsh web 绝不能接受来自其他源的配置访问。 **`describe()` 增加分层与结构化 secret 脱敏。**`SettingsDescriptor` 在生效值之外携带 `base`/`user`,表单据此按「字段是否出现在用户层」来标记「已覆盖」,而非按值是否不等(与 base *相等*的覆盖仍然是覆盖)。`describe({ redactSecrets: true })`——在每个 wire 面都强制启用——经由对 schema 的纯结构遍历(object/dict/array 容器;secret 角色子树整体是一个不透明叶节点)从全部三层剥除 `role('secret')` 子树,并把剥除的槽位枚举为 `{path, set}`,页面因此不必收到任何值就能渲染只写输入框。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml new file mode 100644 index 0000000000..2cc3f97d27 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md +2026-08-10-remote-event-delivery.md: 58af845e66e24f49a484ae2cb216e995417a5df5 +2026-08-10-remote-event-delivery.zh.md: dd96e6082d0eedd541e128b059abaa195a6035a2 diff --git a/.agents/notes/proposed/architecture/2026-08-10-remote-event-delivery.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md similarity index 60% rename from .agents/notes/proposed/architecture/2026-08-10-remote-event-delivery.md rename to .agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md index d6e6555263..58af845e66 100644 --- a/.agents/notes/proposed/architecture/2026-08-10-remote-event-delivery.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md @@ -1,22 +1,22 @@ # Agent Note: Remote event delivery (ctx.remote.$on) -Status: proposed +Status: implemented English | [中文](2026-08-10-remote-event-delivery.zh.md) ## Problem -[TypeRT Gateway targeted method calls](2026-08-02-typert-remote-method-calls.md) cover only the request/response shape and deliberately leave Session event streams and stateful interactions to separate designs. Every **one-way Host-to-consumer push** therefore still rides the legacy API Proxy. +[TypeRT Gateway targeted method calls](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md) cover only the request/response shape and deliberately leave Session event streams and stateful interactions to separate designs. Every **one-way Host-to-consumer push** therefore still rides the legacy API Proxy. The Host owns a family of pure invalidation events — "a registry changed, refetch it" — whose payloads are already JSON and whose emission never binds an AgentScope: `commands/change`, `credentials/updated`, `settings/document-updated`. Reaching one UI subscriber takes four hops: the Host cordis event, a hand-written `HostFrame` variant plus its zod branch in apiproxy, a hand-written bridge in client/runtime that re-emits it as a Client cordis event, and finally the consumer's `ctx.on(...)`. Adding one such event edits five places (frame union, zod union, host-stream listener, client bridge, a duplicated Client-side `Events` declaration), and not one of them states a new fact: the name, the payload type, and the emission point were all declared by the owner package's cordis `Events` merge. That duplicated declaration is also **lossy**: the Client side restates it as `settings/changed(ns: string)`, flattening a branded type into bare `string` — the opposite of the Remote method contract, where a consumer type points at the business package's one canonical symbol. -## Proposal +## Decision -Add one one-way subscription verb to the consumer Remote surface, `ctx.remote.$on(event, listener)`, driven by an allowlist and forwarding verbatim: +The consumer Remote surface carries one one-way subscription verb, `ctx.remote.$on(event, listener)`, driven by an allowlist and forwarding verbatim: -- `packages/api/remotes/src/types.ts` holds the allowlist of forwardable Host events, and it is the single control point over what a consumer may subscribe to. That file is listed in the `files` of **both** of this package's faces, so the Host forwarding loop and the consumer key surface read one declaration. +- `packages/api/remotes/src/remote-events.ts` holds the allowlist of forwardable Host events, and it is the single control point over what a consumer may subscribe to. `src/types.ts` beside it derives the type projection and fills the selection seat, staying type-only per the package convention. Both files are listed in the `files` of **both** of this package's faces, so the Host forwarding loop and the consumer key surface read one declaration. - The wire event name **is** the Host cordis event name (`settings/document-updated`) with no `host/` prefix, and the payload **is** the Host argument list, element for element, with no projection, redaction, or renaming. - The carrier reuses the existing host stream: `HostFrame` gains one wrapper variant, `host/remote-event`. No new downlink. - Event **signatures** get no second table. Each owner package moves its cordis `Events` declaration into its client-safe, type-only `./types` export, so both faces read the same declaration and `$on`'s listener type is `Events[Event]` itself. "Verbatim" then holds by construction rather than by proof. @@ -24,7 +24,7 @@ Add one one-way subscription verb to the consumer Remote surface, `ctx.remote.$o When an `Events` entry's signature reaches a Host-only symbol (a Service, `Agent`, a Context), the answer is to **split the code until the entry lands cleanly in `./types`** — never a declaration half-left in `index.ts`, and never a structurally equivalent shadow type in `./types`. None of the three packages needed that here: their entries reach only `SettingsNamespace`, `SettingsUpdateSource`, and `CredentialRef`, all pure types. -This change migrates the three **pure passthrough** events and deletes their `HostFrame` variants. Everything with derivation stays untouched: `host/models-changed` (a fan-in of `llm/adapters-updated` with provider/agent-default namespace filtering), `host/workspace-changed`/`-removed`/`host/archived-sessions-changed` (view derivation plus per-connection dedup state), and `host/session-added`/`-removed`/`host/session-status`/`host/agent-error` (live-object projection or frame-time derived fields). +The three **pure passthrough** events ride this path, and their `HostFrame` variants are gone. Everything with derivation stays untouched: `host/models-changed` (a fan-in of `llm/adapters-updated` with provider/agent-default namespace filtering), `host/workspace-changed`/`-removed`/`host/archived-sessions-changed` (view derivation plus per-connection dedup state), and `host/session-added`/`-removed`/`host/session-status`/`host/agent-error` (live-object projection or frame-time derived fields). `skills/change`, `tools/change`, and `system-prompt/change` have the same shape but **no consumer today**; under "require a current owner and need" they stay out of the allowlist and are recorded here only as the extension seat. @@ -32,7 +32,7 @@ This change migrates the three **pure passthrough** events and deletes their `Ho type-meta gains one **shape predicate**, one **selection seat**, and **one** member on `TypeRTClientRemote`. No runtime code: -```ts +```ts ignore-check /** Cordis events shaped for one-way remote delivery: no Scope binding, void return. */ export type TypeRTForwardableEvent = { [Event in keyof Events]: unknown extends ThisParameterType @@ -47,36 +47,36 @@ export interface TypeRTRemoteEventSelection {} export type TypeRTRemoteEvent = Extract ``` -```ts +```ts ignore-check /** Subscribe to one forwarded Host event; the returned disposer belongs to the calling fiber. */ $on(event: Event, listener: Events[Event]): () => void ``` `Events` resolves per program: the full Host vocabulary in the Host program, whatever the Client face can see in the Client program. The same predicate therefore holds on both sides without dragging Host declarations into the Client. -**The consumer surface has `$on` and no `$dispatch`.** The port that hands a decoded frame to the subscription table stays out of the developer-visible contract, and it cannot be a module-level function reaching across Client plugins: the client bundle purity gate (`packages/client/tsdown.client.ts`) admits value imports only from `CLIENT_EXTERNALS`, the `INLINE_SAFE` wire layer, and generated `/remote` contributions — and inlining around it would copy `ClientRemoteService` into the runtime bundle, making `instanceof` permanently false. +**The surface separates the consumer verb from the carrier handoff**: consumers subscribe with `$on`, and whoever owns the Host frame sink hands each decoded frame over with `$dispatch`. It cannot be a module-level function reaching across Client plugins — the client bundle purity gate (`packages/client/tsdown.client.ts`) admits value imports only from `CLIENT_EXTERNALS`, the `INLINE_SAFE` wire layer, and generated `/remote` contributions, and inlining around it would copy `ClientRemoteService` into the runtime bundle, making `instanceof` permanently false. A cordis service method is the collaboration shape that gate prescribes: -The port is therefore **one internal Client cordis event**, declared in `dsh-type-meta` (a single-face package both sides already depend on, so no new dependency): - -```ts -'remote/host-event'(event: string, args: readonly unknown[]): void +```ts ignore-check +$dispatch(event: string, args: readonly unknown[]): void ``` -client/runtime — the owner of the host frame sink — emits it; `ClientRemoteService` is the only subscriber and turns it into `$on` callbacks through a private `dispatch`. This is the repository's existing cross-plugin plumbing shape: `connection/reset` is declared and emitted by runtime, subscribed by `ui-command`, and pinned by `runtime/tests/wire-events.spec.ts`. The `event` parameter is `string`, not `TypeRTRemoteEvent`: this is a wire boundary, and a name nobody subscribed to is dropped silently. +client/runtime — the owner of the host frame sink — calls it directly, so the frame reaches the subscription table without an intermediate event to relay it. The `event` parameter is `string`, not `TypeRTRemoteEvent`: this is a wire boundary, and a name nobody subscribed to is dropped silently. Delivery shares no implementation with the cordis event system: one-way only, no waterfall/bail/parallel/serial modes and no `@mode` concept (`ReturnType extends void` is the static expression of that rule), no `this` binding, no `EventOptions`, `prepend`, or priority. Listeners run in registration order, and one that throws is contained and logged — it must never take down the frame pump (the same posture `ConnectionController` already applies to its sinks). -### The allowlist: one file both faces read +### The allowlist: one declaration both faces read -`packages/api/remotes/src/types.ts` is listed in the `files` of both `tsconfig.host.json` and `tsconfig.client.json`, and is the allowlist's single home: +`packages/api/remotes/src/remote-events.ts` is listed in the `files` of both `tsconfig.host.json` and `tsconfig.client.json`, and is the allowlist's single home; `src/types.ts` derives its type face: -```ts +```ts ignore-check +// remote-events.ts — the value export const API_REMOTE_FORWARDED_EVENTS = [ 'commands/change', 'credentials/updated', 'settings/document-updated', ] as const +// types.ts — the type face, derived export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number] declare module '@deepseek-ai/dsh-type-meta' { @@ -88,7 +88,7 @@ Forwarding one more event is therefore **one line in that array**: the type proj The Host face adds one shape assertion, binding the Host event vocabulary to that same array: -```ts +```ts ignore-check API_REMOTE_FORWARDED_EVENTS satisfies readonly TypeRTForwardableEvent[] ``` @@ -100,7 +100,7 @@ JSON-safety is a runtime concern: before forwarding, apiproxy validates each arg ### Wire contract (apiproxy) -```ts +```ts ignore-check | { type: 'host/remote-event'; event: string; args: JsonValue[] } ``` @@ -108,8 +108,6 @@ The zod branch keeps `args: z.array(z.unknown())`: the frame arrives from `JSON. `events.host()` subscribes by allowlist when the stream opens (each stream owns its disposers, so no broadcast set is needed). **The registration position is part of the contract**: this block must sit *before* the `settings/document-updated` listener. Cordis fires in registration order, and `host/models-changed` is an invalidation frame *derived* from that same Host event; placing the forwarded frame after the derived one flips the relative order of two frames from one emit compared with the previous behavior (two config cases observe it). -Cordis keys `on` by literal event name, so subscribing from a runtime list erases the handler type once. That is the only type assertion this change introduces; its safety rests on the allowlist predicate and the `isJsonValue` check. - `api/events.ts` is a wire contract file the browser side also compiles, so every type it references must come from an owner package's **client-safe, type-only subpath**, never the package root. Evidence: importing one type from `@deepseek-ai/dsh-session` root drags the root's `declare module 'cordis' { interface Context { sessions: SessionStore } }` into the Client compilation face and overrides the Client's `ctx.sessions: ISessions`, producing 18 errors in the unrelated `ui-slash` and `ui-conversation`. `JsonValue` therefore needs a re-export from `dsh-session/src/types.ts`. ### The apps/web browser e2e belong to the Host face @@ -118,20 +116,20 @@ The `apps/web/tests/**` e2e type-check in the root **`tsconfig.host.json`**: the That yields a discipline this design depends on: **when those tests import a value or a type from a Client package, they pull that package's whole project — and every project it references — into the Host build graph**. Four consumers (`ui-settings-general`, `ui-models`, `ui-permission`, `ui-command`) reference `api/remotes`' Client face, and that face cannot compile until Host tsdown has generated `@deepseek-ai/dsh-goal/remote`. The result is a build-order deadlock: Host tsc needs the Client face, which needs the generated artifact, which Host tsdown produces after Host tsc. -This change therefore **mirrors** the few Client-owned symbols on the test side (`scaffold.ts` exports the mirrored welcome-notice constants; the two chat e2e keep importing `dsh-client-runtime/client` because the `runtime` project is already in the Host graph), which lets those four consumers leave the Host graph. The 15 Client project references in `apps/cli/tsconfig.json` then lose their owner-map role and are deleted as a group. Each mirrored value matches its source verbatim; a drift shows up as a missed selector or an unsuppressed notice, both loud failures. +The few Client-owned symbols are therefore **mirrored** on the test side (`scaffold.ts` exports the mirrored welcome-notice constants; the two chat e2e keep importing `dsh-client-runtime/client` because the `runtime` project is already in the Host graph), which lets those four consumers leave the Host graph. The 15 Client project references in `apps/cli/tsconfig.json` lost their owner-map role and are gone. Each mirrored value matches its source verbatim; a drift shows up as a missed selector or an unsuppressed notice, both loud failures. ### Change inventory | Location | Change | |---|---| -| `dsh-type-meta` | `src/types.ts` gains `TypeRTForwardableEvent`, `TypeRTRemoteEventSelection`, `TypeRTRemoteEvent`, and the `'remote/host-event'` declaration; `TypeRTClientRemote` gains `$on`. Types only, no runtime | -| `api/gateway` Client half | `ClientRemoteService` implements `$on` (subscription table, `ctx.effect` ownership for the calling fiber, registration-order delivery with listener failures contained) and subscribes to `'remote/host-event'`; `dispatch` stays private | -| `api/remotes` | New `src/types.ts` (allowlist, type projection, selection seat) listed in both faces' `files`; a `./types` export with `lib/types/**/*.js` added to `files`; the Host face adds the shape assertion and `import type {}` for the three owner `./types`; the Client half re-exports those three plus `@deepseek-ai/dsh-api-gateway/client`; `./invariant` asserts the runtime relation for allowlisted events (`thisArg === null` and `mode === 'emit'`) | +| `dsh-type-meta` | `src/types.ts` gains `TypeRTForwardableEvent`, `TypeRTRemoteEventSelection`, and `TypeRTRemoteEvent`; `TypeRTClientRemote` gains `$on` and `$dispatch`. Types only, no runtime | +| `api/gateway` Client half | `ClientRemoteService` implements `$on` (subscriptions addressed by registration, `ctx.effect` ownership for the calling fiber) and `$dispatch` (snapshot delivery in registration order, containing a listener that throws or rejects) | +| `api/remotes` | New `src/remote-events.ts` (the allowlist value) and `src/types.ts` (type projection, selection seat), both listed in both faces' `files`; a `./types` export with `lib/types/**/*.js` added to `files`; the Host face adds the shape assertion and `import type {}` for the three owner `./types`; the Client half re-exports those three plus `@deepseek-ai/dsh-api-gateway/client` | | Root `tsconfig.base.json` | Three `paths` entries (`dsh-settings/types`, `dsh-credentials/types`, `dsh-api-remotes/types`), all pointing at the **source** plane | | `dsh-commands` / `dsh-settings` / `dsh-credentials` | The `interface Events` sub-block moves into each package's client-safe `./types` (settings and credentials create that export, moving the brands and pure types with it; `index` keeps re-exporting them and keeps the constructors; `files` gains `lib/types/**/*.js`) | -| `host/apiproxy` | `HostFrame` gains `host/remote-event` and loses `host/commands-changed`/`-settings-changed`/`-credentials-changed` with their zod branches; `events.host()` subscribes by allowlist ahead of the `settings/document-updated` listener and validates through `assertJsonArgs`; the `settings/document-updated` listener stays to keep feeding `host/models-changed` | +| `host/apiproxy` | `HostFrame` gains `host/remote-event` and loses `host/commands-changed`/`-settings-changed`/`-credentials-changed` with their zod branches; `events.host()` subscribes by allowlist ahead of the `settings/document-updated` listener and validates through `assertJsonArgs`; that listener stays to keep feeding `host/models-changed` | | `dsh-session` | `src/types.ts` re-exports `JsonValue` so wire contract files can use the client-safe subpath | -| `client/runtime` | The bridge's three `ctx.emit` branches collapse into `ctx.emit('remote/host-event', frame.event, frame.args)`; the `Events` merge drops `commands/changed`, `settings/changed`, and `credentials/changed` (`models/changed` stays) | +| `client/runtime` | The bridge's three `ctx.emit` branches collapse into `ctx.remote.$dispatch(frame.event, frame.args)`, adding a `remote` injection; the `Events` merge drops `commands/changed`, `settings/changed`, and `credentials/changed` (`models/changed` stays) | | Five consumers | ui-command / ui-models / ui-settings-general / ui-permission / ui-agent-preset subscribe through `ctx.remote.$on(...)`, following `ui-goal`'s precedent for the type-only facade import and the `'remote'` injection | | `client/connection` | The fixture's `emitHost` produces `host/remote-event` | | `apps/web/tests` + `apps/cli` | Client symbols mirrored on the test side (see above); `apps/cli/tsconfig.json` drops its 15 Client project references | @@ -148,28 +146,28 @@ This change therefore **mirrors** the few Client-owned symbols on the test side **Move the apps/web browser e2e into the Client aggregate.** "Client tests belong to the Client face" looks right and fails immediately with 21 errors: those tests use Host services, and in the Client program `ctx.sessions` is `ISessions`. -**Split `directory-picker-browse`/`-native` into Host and Client faces** so no Client package reaches the Host graph. The direction is right — they are genuinely unsplit dual-half packages — but it is a separate concern from this capability seam and lands in another owner's packages; recorded as its own follow-up. +**Split `directory-picker-browse`/`-native` into Host and Client faces** so no Client package reaches the Host graph. The direction is right — they are genuinely unsplit dual-half packages — but the change lands in another owner's packages and buys only a cleaner build graph; once this design mirrors the Client symbols on the test side, it no longer needs the split. **Assessed and declined.** -## Acceptance criteria +## Verification -- Emitting the three Host events puts one `host/remote-event` frame each on the real host stream, with `event` the Host name and `args` equal element for element (a real composition test). -- The allowlist rejects three candidate classes at the type level: a name that is not an event, a Scope-bound event (`goal/changed`), and an event whose return is not `void`. -- `$on`'s key surface equals the allowlist: `$on('slots/changed', …)` and `$on('skills/change', …)` must both fail to compile. -- `TypeRTClientRemote` has **no** `$dispatch`: the developer-visible contract is `$on` plus the existing `$mount` and generated namespaces. -- A non-JSON-safe argument makes `assertJsonArgs` throw rather than degrade silently; that function is unit-tested directly instead of driving a malformed emit through the event bus. -- `ctx.remote.$on`'s disposer belongs to the calling fiber: disposing the fiber removes the subscription. One throwing listener affects neither its siblings nor later frames. -- For one emit, the forwarded frame and the invalidation frame derived from the same Host event keep the pre-change relative order. -- On the consumer side, `$on('settings/document-updated', …)` resolves `ns` as `SettingsNamespace` — the brand survives the wire. -- The three `HostFrame` variants, the three Client-side `Events` declarations, and the three bridge branches disappear in the same change; `host/models-changed` behavior is unchanged. -- `pnpm run build` passes. +What pins this behavior: -## Risks +- A real composition test puts one `host/remote-event` frame on the real host stream per Host emit, with `event` the Host name and `args` equal element for element. +- Type-level negatives reject three candidate classes: a name that is not an event, a Scope-bound event (`goal/changed`), and an event whose return is not `void`. `$on('slots/changed', …)` (Client-local) and `$on('skills/change', …)` (declared but unselected) both fail to compile, so `$on`'s key surface equals the allowlist. +- On the consumer side, `$on('settings/document-updated', …)` resolves `ns` as `SettingsNamespace`: the brand survives the wire. +- `$on`'s disposer belongs to the calling fiber, and two registrations of one function object retire independently — a table keyed on listener identity would collapse them, so subscriptions are addressed by registration. +- Delivery contains a listener that throws AND one that rejects a returned promise: the declared return is `void`, so nobody awaits an async listener, and its rejection would otherwise escape this containment entirely. Delivery iterates a snapshot, so subscribing or disposing mid-frame cannot change who receives that frame. +- For one emit, the forwarded frame and the invalidation frame derived from the same Host event keep the pre-change relative order (two config cases observe it). +- `assertJsonArgs` is unit-tested directly rather than by driving a malformed emit through the event bus: a typed `ctx.emit` cannot construct one, since every allowlisted event has a statically JSON-safe payload. +- The three `HostFrame` variants, the three Client-side `Events` declarations, and the three bridge branches are gone in the same change; `host/models-changed` behavior is unchanged. -- **Tenancy inside a legacy frame union.** The new contract temporarily lives in apiproxy's `HostFrame`, so a reader may assume apiproxy owns Remote events. The frame's JSDoc names `api-remotes` as the allowlist owner, and apiproxy's README records the tenancy under known limitations. -- **The shared file breaks api/remotes' face-disjointness contract.** `src/types.ts` belongs to both projects, so each emits an identical declaration into the shared `lib/types`. Content is byte-identical and the `.tsbuildinfo` files stay separate, so this is harmless in practice — but the README's build-boundary section must state the exception and its cause (the `paths` entry points at source). -- **Any Client plugin can `ctx.emit('remote/host-event', …)`** and synthesize a Host event. This is the same exposure `connection/reset` already has for a fabricated reconnect; the Client is one trust domain. Tests pin the event-to-`$on` conversion and do not pretend the port authenticates its source. -- **The allowlist's shape assertion is currently commented out** in `packages/api/remotes/src/index.ts`, together with the allowlist import and the three owner `./types` type-only imports it needs. The three static guarantees described above are therefore inactive right now: adding a Scope-bound or misspelled name would not fail to compile. Restoring it does not change the build graph (those four consumers already left the Host graph) and is required before the pull request. -- **Mirrored test values can drift.** Nothing mechanically checks the Client constants mirrored in `apps/web/tests` against their source; the safety net is only that a drift misses a selector. A grep-level gate forbidding `@deepseek-ai/dsh-client-*` imports under `apps/web/tests` would close this and is not part of this change. -- **The dynamic subscription erases a handler type.** Subscribing by allowlist requires one erasure at `ctx.on(name, …)`; if the predicate is later relaxed, that assertion loses its static backing. -- **Capabilities given up.** No projected or redacted payloads, no Scope-bound events (`agentCtx.remote.$on`), and no replay on reconnect — these are pure invalidation signals, and the existing `connection/reset` already covers refetching after a reconnect. The mux stream's session events, answerable frames, and snapshot baselines stay out of scope. -- **Client packages remain in the Host graph.** Twelve projects (`connection`, `runtime`, `ui-slots`, and kin) still reach it through the unsplit `directory-picker-browse`/`-native` pair and `api/gateway → client/connection`. They compile and no longer implicate api/remotes' Client face, so they do not block this change; the root fix is the follow-up above. +## Consequences + +- **Tenancy inside a legacy frame union.** The contract lives in apiproxy's `HostFrame`, so a reader may assume apiproxy owns Remote events. The frame's JSDoc names `api-remotes` as the allowlist owner, and apiproxy's README records the tenancy under known limitations. When the host stream moves off that package, the wrapper moves with it and the consumer contract does not change. +- **Two files break api/remotes' face-disjointness contract.** `src/remote-events.ts` and `src/types.ts` belong to both projects, so each emits an identical declaration into the shared `lib/types`. Content is byte-identical and the `.tsbuildinfo` files stay separate, so this is harmless in practice; the README's build-boundary section states the exception and its cause (the `paths` entry points at source). +- **The carrier handoff is developer-visible.** Any Client plugin holding `ctx.remote` can call `$dispatch` and synthesize a forwarded event. That exposure predates the verb — `ctx.emit` was equally reachable while an internal event relayed the frame — and matches what `connection/reset` already allows for a fabricated reconnect; the Client is one trust domain. Tests pin the handoff-to-`$on` conversion and do not pretend the port authenticates its caller. +- **A malformed argument fails in the emitter's containment, not at load.** `assertJsonArgs` throws inside the forwarding listener, so the emitting seam's listener containment logs it and drops that frame: loud in the Host log rather than at load or at the emit point. +- **Mirrored test values can drift.** Nothing mechanically checks the Client constants mirrored in `apps/web/tests` against their source; the safety net is only that a drift misses a selector. The rule lives in `apps/web/tests/README.md` and is held by review — a grep-level gate was considered and deliberately skipped. +- **Capabilities given up.** No projected or redacted payloads, no Scope-bound events (`agentCtx.remote.$on`), and no replay on reconnect — these are pure invalidation signals, and `connection/reset` already covers refetching after a reconnect. The mux stream's session events, answerable frames, and snapshot baselines stay out of scope. +- **Client packages remain in the Host graph.** Twelve projects (`connection`, `runtime`, `ui-slots`, and kin) still reach it through the unsplit `directory-picker-browse`/`-native` pair and `api/gateway → client/connection`. They compile and no longer implicate api/remotes' Client face, so they did not block this change; splitting those packages would remove a few but was assessed and declined. The two chat e2e importing `dsh-client-runtime/client` rely on `runtime` already being in that graph — incidental, not a guarantee. +- **The invariant companion holds no runtime check.** An earlier revision asserted the dispatch shape (`thisArg === null`, `mode === 'emit'`) over the live event bus, which coupled the companion to the allowlist value and made rolldown hoist it into a third bundle chunk the mechanical publication list does not carry. The Host face's `TypeRTForwardableEvent` assertion already refuses both deviations at compile time, so the companion is an explained empty installer. diff --git a/.agents/notes/proposed/architecture/2026-08-10-remote-event-delivery.zh.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md similarity index 54% rename from .agents/notes/proposed/architecture/2026-08-10-remote-event-delivery.zh.md rename to .agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md index 2d34b2555e..dd96e6082d 100644 --- a/.agents/notes/proposed/architecture/2026-08-10-remote-event-delivery.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md @@ -1,30 +1,30 @@ # Agent Note: Remote 事件投递(ctx.remote.$on) -Status: proposed +Status: implemented [English](2026-08-10-remote-event-delivery.md) | 中文 ## 问题 -[TypeRT Remote 方法调用](2026-08-02-typert-remote-method-calls.zh.md)只覆盖「一次请求一个结果」的定向调用,明确把 Session 事件流与有状态交互留在别处;Host 向消费端的**单向事件推送**因此仍然全部压在遗留的 API Proxy 上。 +[TypeRT Remote 方法调用](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md)只覆盖「一次请求一个结果」的定向调用,明确把 Session 事件流与有状态交互留在别处;Host 向消费端的**单向事件推送**因此仍然全部压在遗留的 API Proxy 上。 Host 上一族「注册表变了,重新拉一次」的纯失效事件(`commands/change`、`credentials/updated`、`settings/document-updated`)既不依赖 AgentScope、载荷也本来就是 JSON,却要穿过四跳才能到达一个 UI 订阅者:host cordis 事件 → apiproxy 手写 `HostFrame` 变体 + zod → client/runtime 手写桥 `ctx.emit(...)` → 消费者 `ctx.on(...)`。每加一个这类事件要改 5 处(帧联合、zod 联合、host 流监听、client 桥、client 侧重复的 `Events` 声明),而这 5 处没有一处是在陈述新事实——事件名、载荷类型、发射时机全都由 owner 包早已在 cordis `Events` 里声明过。 那份重复声明还是**有损**的:client 侧写成 `settings/changed(ns: string)`,brand 类型在这一跳被拍平成裸 `string`,与 Remote 方法侧「消费端类型指向业务包唯一符号」的既有契约相反。 -## 提案 +## 决策 -给消费端 Remote 面补一个单向事件订阅动词 `ctx.remote.$on(event, listener)`;**名单驱动、原样转发**: +消费端 Remote 面持有一个单向事件订阅动词 `ctx.remote.$on(event, listener)`;**名单驱动、原样转发**: -- `packages/api/remotes/src/types.ts` 持有一份可转发 host 事件名单,它同时是「消费端能订阅什么」的唯一控制点。该文件**同时列进本包 host 与 client 两个 face 的 `files`**,两侧读同一份。 +- `packages/api/remotes/src/remote-events.ts` 持有一份可转发 host 事件名单,它同时是「消费端能订阅什么」的唯一控制点。旁边的 `src/types.ts` 由它派生类型投影并填充 selection 座位,按包约定保持纯类型。两个文件**都同时列进本包 host 与 client 两个 face 的 `files`**,两侧读同一份。 - wire 上的事件名 **就是 host cordis 事件原名**(`settings/document-updated`),不加 `host/` 前缀;载荷 **就是 host 的实参列表**,逐元素原样过 JSON,无投影、无脱敏、无改名。 - 载体**寄生现有 host 流**:`HostFrame` 加一个包裹帧 `host/remote-event`,不新开下行通道。 - 事件**签名**不另立表:owner 包把自己的 cordis `Events` 声明搬进 client-safe 的 `./types` 纯类型出口,两侧读**同一份**——`$on` 的 listener 类型就是 `Events[Event]` 本身。「原样」不需要证明,是构造性成立的。 - 但**只借 cordis 的类型形状,不接 cordis 的事件系统**:投递语义、注册表、异常处置全归 TypeRT 自己。 -一条 `Events` 条目若签名里够到了 host-only 符号(Service、`Agent`、Context 等),处理方式是**把代码拆到能干净落进 `./types` 为止**;不接受「一半留 index、一半搬走」的分裂声明,也不接受在 `./types` 里造结构等价的影子类型。本次三个包都不需要拆:它们的条目只够到 `SettingsNamespace`、`SettingsUpdateSource`、`CredentialRef`,全是纯类型。 +一条 `Events` 条目若签名里够到了 host-only 符号(Service、`Agent`、Context 等),处理方式是**把代码拆到能干净落进 `./types` 为止**;不接受「一半留 index、一半搬走」的分裂声明,也不接受在 `./types` 里造结构等价的影子类型。这三个包都不需要拆:它们的条目只够到 `SettingsNamespace`、`SettingsUpdateSource`、`CredentialRef`,全是纯类型。 -本次只迁**纯透传**的三条并删除对应 `HostFrame` 变体;带派生逻辑的一律不动:`host/models-changed`(`llm/adapters-updated` 与 provider/agent-default 命名空间过滤的 fan-in)、`host/workspace-changed`/`-removed`/`host/archived-sessions-changed`(需 view 派生 + 每连接 dedup 状态)、`host/session-added`/`-removed`/`host/session-status`/`host/agent-error`(需活对象投影或帧时派生字段)。 +**纯透传**的三条走这条路径,对应的 `HostFrame` 变体已删除;带派生逻辑的一律不动:`host/models-changed`(`llm/adapters-updated` 与 provider/agent-default 命名空间过滤的 fan-in)、`host/workspace-changed`/`-removed`/`host/archived-sessions-changed`(需 view 派生 + 每连接 dedup 状态)、`host/session-added`/`-removed`/`host/session-status`/`host/agent-error`(需活对象投影或帧时派生字段)。 `skills/change`、`tools/change`、`system-prompt/change` 是同形状的纯失效事件但目前**没有任何消费者**,按「每个抽象都要有当前 owner 与需求」不进名单,只作为扩展位记录在此。 @@ -32,51 +32,51 @@ Host 上一族「注册表变了,重新拉一次」的纯失效事件(`comma type-meta 加一个**形状谓词**、一个**选择座位**和 `TypeRTClientRemote` 的**一个**成员;零运行时代码: -```ts -/** 形状上可以单向远程投递的 cordis 事件名:不绑 Scope,且返回 void。 */ +```ts ignore-check +/** Cordis events shaped for one-way remote delivery: no Scope binding, void return. */ export type TypeRTForwardableEvent = { [Event in keyof Events]: unknown extends ThisParameterType ? ReturnType extends void ? Event : never : never }[keyof Events] -/** Host 装配声明的转发选择;由 api/remotes 的名单一次性填满,其他包不填。 */ +/** The Host assembly's forwarding selection; api/remotes' allowlist fills it, no other package does. */ export interface TypeRTRemoteEventSelection {} -/** `$on` 的合法键:被选中且当前编译面确实存在的事件。 */ +/** `$on`'s legal keys: selected, and present in the current compilation face. */ export type TypeRTRemoteEvent = Extract ``` -```ts -/** 订阅一条被转发的 host 事件;返回的 disposer 归调用方 fiber。 */ +```ts ignore-check +/** Subscribe to one forwarded Host event; the returned disposer belongs to the calling fiber. */ $on(event: Event, listener: Events[Event]): () => void ``` `Events` 按程序解析:host 程序里是 host 事件全集,client 程序里是 client 编译面看得见的那些——同一个谓词在两侧各自成立,不需要把 host 声明拖进 client。 -**消费端只有 `$on`,没有 `$dispatch`。** 帧到订阅表的投递口不进开发者可见契约,也**不能**是一个跨插件的模块级函数:client bundle 纯度门禁(`packages/client/tsdown.client.ts`)只放行 `CLIENT_EXTERNALS`、`INLINE_SAFE` 那层 wire 契约与 `/remote` 生成物三类值导入,而靠 inline 绕过会把 `ClientRemoteService` 复制一份进 runtime bundle、令 `instanceof` 恒假。 +**契约把消费动词与载体交接分开**:消费方用 `$on` 订阅,持有 host 帧 sink 的一方用 `$dispatch` 把解码后的帧交进来。它**不能**是一个跨插件的模块级函数:client bundle 纯度门禁(`packages/client/tsdown.client.ts`)只放行 `CLIENT_EXTERNALS`、`INLINE_SAFE` 那层 wire 契约与 `/remote` 生成物三类值导入,而靠 inline 绕过会把 `ClientRemoteService` 复制一份进 runtime bundle、令 `instanceof` 恒假。cordis 服务方法正是该门禁指定的协作形态: -投递口因此是**一条客户端内部 cordis 事件**,声明在 `dsh-type-meta`(两侧共用的单 face 包,runtime 本来就依赖它,所以零新增依赖): - -```ts -'remote/host-event'(event: string, args: readonly unknown[]): void +```ts ignore-check +$dispatch(event: string, args: readonly unknown[]): void ``` -持有 host 帧 sink 的 client/runtime 发射它,`ClientRemoteService` 是唯一订阅者并转成 `$on` 回调(`dispatch` 是私有方法,不进 `TypeRTClientRemote`)。这是仓内既有的跨插件 plumbing 形态——`connection/reset` 就是 runtime 声明+发射、`ui-command` 订阅,并有 `runtime/tests/wire-events.spec.ts` 钉住。`event` 形参是 `string` 而非 `TypeRTRemoteEvent`:这是 wire 边界,收到无人订阅的名字即静默丢弃。 +持有 host 帧 sink 的 client/runtime 直接调用它,帧不经中转事件即到达订阅表。`event` 形参是 `string` 而非 `TypeRTRemoteEvent`:这是 wire 边界,收到无人订阅的名字即静默丢弃。 投递语义与 cordis 事件系统不共用实现:只有单向投递,没有 waterfall / bail / parallel / serial 模式,也没有 `@mode` 概念(`ReturnType extends void` 是这条纪律的静态表达);不绑 `this`;没有 `EventOptions`、`prepend`、优先级;按注册顺序逐个调用,单个 listener 抛错就地隔离并记日志——它绝不能拖垮帧泵(沿用 `ConnectionController` 对 sink 异常的既有处置)。 -### 名单:两个 face 共读的一个文件 +### 名单:两个 face 共读的同一份声明 -`packages/api/remotes/src/types.ts` 同时列进 `tsconfig.host.json` 与 `tsconfig.client.json` 的 `files`,是名单的**唯一家**: +`packages/api/remotes/src/remote-events.ts` 同时列进 `tsconfig.host.json` 与 `tsconfig.client.json` 的 `files`,是名单的**唯一家**;`src/types.ts` 由它派生类型面: -```ts +```ts ignore-check +// remote-events.ts — the value export const API_REMOTE_FORWARDED_EVENTS = [ 'commands/change', 'credentials/updated', 'settings/document-updated', ] as const +// types.ts — the type face, derived export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number] declare module '@deepseek-ai/dsh-type-meta' { @@ -88,7 +88,7 @@ declare module '@deepseek-ai/dsh-type-meta' { host 半再加一处形状断言,把 host 事件词汇的约束落到同一份名单上: -```ts +```ts ignore-check API_REMOTE_FORWARDED_EVENTS satisfies readonly TypeRTForwardableEvent[] ``` @@ -100,7 +100,7 @@ API_REMOTE_FORWARDED_EVENTS satisfies readonly TypeRTForwardableEvent[] ### 线协议(apiproxy) -```ts +```ts ignore-check | { type: 'host/remote-event'; event: string; args: JsonValue[] } ``` @@ -108,7 +108,6 @@ zod 侧 `args: z.array(z.unknown())`:帧本身来自 `JSON.parse`,元素必 `events.host()` 打开时按名单挂监听(host 流每条自持 disposers,无需新增广播集合)。**注册位置是契约的一部分**:这段必须挂在 `settings/document-updated` 监听**之前**。cordis 按注册序触发,而 `host/models-changed` 是由同一条 host 事件**派生**出来的失效帧——转发帧排到派生帧之后会让同一次 emit 的两帧顺序相对改动前颠倒(已被两条 config 用例实测到)。规则:**转发帧必须先于由它派生的失效帧**。 -cordis `on` 的键是字面量泛型,按动态名单订阅必须在此擦除一次 handler 类型;这是本变更唯一的类型断言点,其安全性由名单谓词与 `isJsonValue` 校验共同承担。 `api/events.ts` 是浏览器侧也要编译的 wire 契约文件,所以它引用的每个类型都必须走 owner 包的 **client-safe type-only 子路径**,绝不能走包根出口。实证:从 `@deepseek-ai/dsh-session` 根引一个类型,就把根出口的 `declare module 'cordis' { interface Context { sessions: SessionStore } }` 拖进 client 编译面、把 client 的 `ctx.sessions: ISessions` 顶掉,在完全无关的 `ui-slash` / `ui-conversation` 里炸出 18 条错。`JsonValue` 因此需要 `dsh-session/src/types.ts` 补一条 re-export。 @@ -118,20 +117,20 @@ cordis `on` 的键是字面量泛型,按动态名单订阅必须在此擦除 由此得到一条对本设计要紧的连带纪律:**这些测试从客户端包 import 值或类型,会把该包的整个 project——以及它引用的每个 project——拖进 Host 构建图**。`ui-settings-general`/`ui-models`/`ui-permission`/`ui-command` 四个消费者 references `api/remotes` 的 client face,而该 face 必须等 host tsdown 生成 `@deepseek-ai/dsh-goal/remote` 才能编译,于是形成构建期死锁:host tsc → api/remotes client face → `goal/remote` → host tsdown → 排在 host tsc 之后。 -本次的处置是在测试侧**镜像**所需的客户端符号(`scaffold.ts` 导出镜像后的 welcome-notice 常量,两个 chat e2e 直接引 `dsh-client-runtime/client` 因为 `runtime` 工程本来就在 host 图里),从而让那 4 个消费者离开 host 图;`apps/cli/tsconfig.json` 里 15 条 client 工程引用随之失去 owner-map 职责,一并删除。镜像值与源逐字一致,漂移的表现是选择器失配或通知未被抑制,都是响亮失败。 +所需的客户端符号在测试侧**镜像**了一份(`scaffold.ts` 导出镜像后的 welcome-notice 常量,两个 chat e2e 直接引 `dsh-client-runtime/client` 因为 `runtime` 工程本来就在 host 图里),从而让那 4 个消费者离开了 host 图;`apps/cli/tsconfig.json` 里 15 条 client 工程引用随之失去 owner-map 职责,已一并删除。镜像值与源逐字一致,漂移的表现是选择器失配或通知未被抑制,都是响亮失败。 ### 改动清单 | 位置 | 改动 | |---|---| -| `dsh-type-meta` | `src/types.ts` 加 `TypeRTForwardableEvent`、`TypeRTRemoteEventSelection`、`TypeRTRemoteEvent` 与 `'remote/host-event'` 声明;`TypeRTClientRemote` 增 `$on`。纯类型,零运行时 | -| `api/gateway` client 半 | `ClientRemoteService` 实现 `$on`(订阅表、`ctx.effect` 归属调用方 fiber、按注册顺序派发并隔离 listener 异常)+ 订阅 `'remote/host-event'`,`dispatch` 保持私有 | -| `api/remotes` | 新增 `src/types.ts`(名单 + 类型投影 + 选择座位),双列进两个 face 的 `files`;`./types` 出口 + `files` 补 `lib/types/**/*.js`;host 半加形状断言并 `import type {}` 三个 owner 包的 `./types`;client 半 `export type {}` 那三个 `./types` 与 `@deepseek-ai/dsh-api-gateway/client`;`./invariant` 断言名单内事件的运行期关系(`thisArg === null` + `mode === 'emit'`) | +| `dsh-type-meta` | `src/types.ts` 加 `TypeRTForwardableEvent`、`TypeRTRemoteEventSelection`、`TypeRTRemoteEvent`;`TypeRTClientRemote` 增 `$on` 与 `$dispatch`。纯类型,零运行时 | +| `api/gateway` client 半 | `ClientRemoteService` 实现 `$on`(订阅按注册项寻址、`ctx.effect` 归属调用方 fiber)与 `$dispatch`(快照后按注册顺序派发,收容抛出或拒绝的 listener) | +| `api/remotes` | 新增 `src/remote-events.ts`(名单值)与 `src/types.ts`(类型投影 + 选择座位),两者都双列进两个 face 的 `files`;`./types` 出口 + `files` 补 `lib/types/**/*.js`;host 半加形状断言并 `import type {}` 三个 owner 包的 `./types`;client 半 `export type {}` 那三个 `./types` 与 `@deepseek-ai/dsh-api-gateway/client` | | 根 `tsconfig.base.json` | 加 `dsh-settings/types`、`dsh-credentials/types`、`dsh-api-remotes/types` 三条 `paths`,全部指向**源**平面 | | `dsh-commands` / `dsh-settings` / `dsh-credentials` | `interface Events` 子块移入各自 client-safe 的 `./types`(settings/credentials 新建该出口,brand 与纯类型一并移入,index 继续 re-export 并留住构造器;`files` 补 `lib/types/**/*.js`) | -| `host/apiproxy` | `HostFrame` 增 `host/remote-event`、删 `host/commands-changed`/`-settings-changed`/`-credentials-changed` 三变体及其 zod;`events.host()` 按名单挂监听(位置在 `settings/document-updated` 之前)+ `assertJsonArgs`;`settings/document-updated` 监听保留以继续喂 `host/models-changed` | +| `host/apiproxy` | `HostFrame` 增 `host/remote-event`、删 `host/commands-changed`/`-settings-changed`/`-credentials-changed` 三变体及其 zod;`events.host()` 按名单挂监听(位置在 `settings/document-updated` 之前)+ `assertJsonArgs`;该监听保留以继续喂 `host/models-changed` | | `dsh-session` | `src/types.ts` 补 `export type { JsonValue }`,让 wire 契约文件能走 client-safe 子路径 | -| `client/runtime` | 桥里三条 `ctx.emit` 换成一行 `ctx.emit('remote/host-event', frame.event, frame.args)`;`Events` 声明删 `commands/changed`/`settings/changed`/`credentials/changed`(`models/changed` 保留) | +| `client/runtime` | 桥里三条 `ctx.emit` 换成一行 `ctx.remote.$dispatch(frame.event, frame.args)` 并新增 `remote` 注入;`Events` 声明删 `commands/changed`/`settings/changed`/`credentials/changed`(`models/changed` 保留) | | 5 个消费者 | ui-command / ui-models / ui-settings-general / ui-permission / ui-agent-preset 改订 `ctx.remote.$on(...)`;照 `ui-goal` 先例 type-only 引 `@deepseek-ai/dsh-api-remotes/client` 并把 `'remote'` 加进 `inject` | | `client/connection` | fixture 的 `emitHost` 造 `host/remote-event` | | `apps/web/tests` + `apps/cli` | 客户端符号镜像(见上节);`apps/cli/tsconfig.json` 删 15 条 client 工程引用 | @@ -148,28 +147,28 @@ cordis `on` 的键是字面量泛型,按动态名单订阅必须在此擦除 **把 apps/web 的 browser e2e 搬进 client 聚合**。看似「客户端测试归客户端面」,实测立刻 21 条错:它们用 host 服务,而 client 程序里 `ctx.sessions` 是 `ISessions`。已否。 -**给 `directory-picker-browse`/`-native` 做 host/client 双 face 切分**,从根上让客户端包不进 host 图。方向正确(它们确实是未切分的双半包),但与本单的 capability seam 是两件事,且改动落在别人属地——记为独立后续单。 +**给 `directory-picker-browse`/`-native` 做 host/client 双 face 切分**,从根上让客户端包不进 host 图。方向正确(它们确实是未切分的双半包),但改动落在别人属地,而收益只是「构建图更干净」——本设计在测试侧镜像客户端符号之后已经不需要它。**已评估不做**。 -## 验收标准 +## 验证 -- host emit 三条事件后,真实 host 流各出一帧 `host/remote-event`,`event` 为 host 原名、`args` 与实参逐元素相等(真组合测试)。 -- 名单在类型层拒绝三类候选:不存在的事件名、绑 Scope 的事件(`goal/changed`)、非 `void` 返回的事件。 -- `$on` 的键面等于名单:`$on('slots/changed', …)` 与 `$on('skills/change', …)` 都必须编译失败。 -- `TypeRTClientRemote` 上**不存在** `$dispatch`:开发者可见契约只有 `$on`(加既有 `$mount` 与生成的 namespace)。 -- 名单内事件发射非 JSON-safe 实参时,`assertJsonArgs` 抛错而非静默降级(对该函数直接单测,不从事件总线造畸形 emit)。 -- `ctx.remote.$on` 的 disposer 归属调用方 fiber:处置 fiber 后订阅消失。一个 listener 抛错不影响同事件其余 listener,也不中断后续帧投递。 -- 转发帧与由同一条 host 事件派生的失效帧在同一次 emit 里的顺序与改动前逐帧一致。 -- 消费端 `$on('settings/document-updated', …)` 的 `ns` 形参解析为 `SettingsNamespace`(brand 未丢)。 -- 三条 `HostFrame` 变体、client 侧三条 `Events` 声明、client 手写桥的三条分支在同一 PR 内消失;`host/models-changed` 行为不变。 -- `pnpm run build` 全量通过。 +钉住该行为的东西: -## 风险 +- 一个真组合测试:host 每 emit 一次,真实 host 流就出一帧 `host/remote-event`,`event` 为 host 原名、`args` 与实参逐元素相等。 +- 类型层负例拒绝三类候选:不是事件的名字、绑 Scope 的事件(`goal/changed`)、返回值非 `void` 的事件。`$on('slots/changed', …)`(client 本地事件)与 `$on('skills/change', …)`(已声明但未选中)都编译失败——因此 `$on` 的键面恰好等于名单。 +- 消费端 `$on('settings/document-updated', …)` 把 `ns` 解析为 `SettingsNamespace`:brand 穿过 wire 存活。 +- `$on` 的 disposer 归属调用方 fiber;同一个函数对象订阅两次时两条注册各自独立退订——按 listener 身份做键的表会把它们合并,所以订阅按注册项寻址。 +- 投递同时收容抛出的 listener 与拒绝所返回 promise 的 listener:声明返回值是 `void`,没人 await 异步 listener,其拒绝否则会完全逃出这层收容。投递遍历快照,因此派发中订阅或退订都不会改变本帧的接收者集合。 +- 同一次 emit 下,转发帧与由同一条 host 事件派生的失效帧保持改动前的相对顺序(两条 config 用例实测)。 +- `assertJsonArgs` 直接单测,而不是从事件总线造畸形 emit:类型化的 `ctx.emit` 造不出来——名单内每条事件的载荷在静态上都是 JSON-safe 的。 +- 三个 `HostFrame` 变体、三条 client 侧 `Events` 声明、三条桥分支在同一变更内消失;`host/models-changed` 行为不变。 -- **寄生 legacy 帧联合**:新契约暂时住在 apiproxy 的 `HostFrame` 里,读者会误以为 Remote 事件归 apiproxy 拥有。缓解=帧注释指明名单归 `api-remotes`,并在 apiproxy README 的已知欠账里记这条寄居关系。 -- **共享文件破了 api/remotes 的 face 互斥契约**:`src/types.ts` 同时属于两个 project,两侧各自 emit 一份同名声明到共用的 `lib/types`。内容逐字相同、`.tsbuildinfo` 各自独立,实际无害,但 README 的 Build boundary 节必须写明这条例外及其成因(paths 指向 src)。 -- **任一 client 插件都能 `ctx.emit('remote/host-event', …)` 伪造一条 host 事件**:与 `connection/reset` 可被伪造成重连同一量级(client 是单一信任域)。测试只钉「事件到 `$on` 的转换」,不假装它有来源鉴别。 -- **名单的形状断言当前处于注释态**(`packages/api/remotes/src/index.ts`,连同它所需的名单 import 与三条 owner `./types` 的 `import type {}`),因此本节描述的三条静态保证暂未生效:此刻往名单里塞一个 scoped 事件或拼错的名字不会有编译错误。恢复它对构建图无影响(那四个消费者已不在 host 图里),是 PR 前必做项。 -- **测试侧的镜像会漂移**:`apps/web/tests` 里镜像的客户端常量与源之间没有机械校验,只能靠「漂移即选择器失配」这种响亮失败兜底。理想上该加一条 grep 级门禁禁止 `apps/web/tests` 引入 `@deepseek-ai/dsh-client-*`,本轮未加。 -- **动态订阅的类型擦除**:按名单 `ctx.on(name, …)` 必须擦一次 handler 类型;若名单谓词将来被放宽,这处断言就不再有静态支撑。 -- **放弃的能力**:不支持带载荷投影/脱敏的事件、不支持 Scope 化事件(`agentCtx.remote.$on`)、不支持重连重放(纯失效信号,重连后的重新拉取由既有 `connection/reset` 覆盖)。mux 流的 session 事件、可答帧与快照基线不在范围内。 -- **host 图里仍有客户端包**:`connection`、`runtime`、`ui-slots` 等 12 个工程经 `directory-picker-browse|native`(未切分的双半包)与 `api/gateway → client/connection` 仍在 host 构建图内。它们当前都能编译、且不再牵连 api/remotes 的 client face,所以不阻塞;根治留给上面那条独立后续单。 +## 后果 + +- **寄居在 legacy 帧联合里**:契约住在 apiproxy 的 `HostFrame` 中,读者可能误以为 apiproxy 拥有 Remote 事件。该帧的 JSDoc 点名名单归 `api-remotes`,apiproxy README 在 known limitations 记录这项寄居。host 流将来整体搬家时,包裹帧随之搬走,消费端契约不变。 +- **两个文件打破了 api/remotes 的 face 互斥约定**:`src/remote-events.ts` 与 `src/types.ts` 同属两个工程,各自向共享的 `lib/types` 发射一份相同声明。内容逐字节相同、`.tsbuildinfo` 各自独立,实践上无害;README 的构建边界节陈述了这个例外及其成因(`paths` 指向源码面)。 +- **载体交接是开发者可见的**:任何持有 `ctx.remote` 的 client 插件都能调 `$dispatch` 合成一条转发事件。这个暴露面早于该动词存在——先前由内部事件中转帧时,`ctx.emit` 同样可达——与 `connection/reset` 可被伪造成重连同一量级(client 是单一信任域)。测试只钉「交接到 `$on` 的转换」,不假装该端口鉴别调用方。 +- **畸形实参在发射方的收容里失败,而非加载期**:`assertJsonArgs` 在转发监听内抛出,因此由发射 seam 自己的 listener 收容记录并丢弃该帧——响亮地出现在 host 日志里,而不是加载时或 emit 点。 +- **测试侧镜像值可能漂移**:没有任何机制核对 `apps/web/tests` 中镜像的 client 常量与其源;安全网只是漂移会让选择器失配。规则写在 `apps/web/tests/README.md`,由 review 守;grep 级门禁经评估后刻意不做。 +- **放弃的能力**:不支持投影或脱敏载荷、不支持 Scope 化事件(`agentCtx.remote.$on`)、重连不重放——这些都是纯失效信号,且 `connection/reset` 已覆盖重连后的重新拉取。mux 流的会话事件、可应答帧与快照基线不在范围内。 +- **仍有 client 包留在 host 图里**:12 个工程(`connection`、`runtime`、`ui-slots` 等)经未拆分的 `directory-picker-browse`/`-native` 与 `api/gateway → client/connection` 仍可达 host 图。它们都能编译且不再牵连 api/remotes 的 client face,因此没有阻塞本次改动;拆分那些包能减少几个,但经评估后不做。两个 chat e2e 直接引 `dsh-client-runtime/client` 依赖 `runtime` 本来就在图里——属偶然而非保证。 +- **invariant companion 不做运行期检查**:早先的修订曾在活事件总线上断言投递形状(`thisArg === null`、`mode === 'emit'`),这让 companion 与名单值耦合,并使 rolldown 把它提成第三个 bundle chunk——而机械推导的发布文件清单并不携带它。host 面的 `TypeRTForwardableEvent` 断言在编译期已拒绝这两种偏离,因此该 companion 是一个带说明的空 installer。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.i18n.yaml index 874c0c0d34..f789ed37a0 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.md -2026-08-06-onboarding-step-owned-takeover-chrome.md: 4b3bbbc03c4494359297ae6e54bcc9a74c387e80 -2026-08-06-onboarding-step-owned-takeover-chrome.zh.md: ba4f503f7bc318fcb16f4413465a86609fa1bc61 +2026-08-06-onboarding-step-owned-takeover-chrome.md: 35b9d16aaba4ca9f124a9972c43b2a107ea0309f +2026-08-06-onboarding-step-owned-takeover-chrome.zh.md: 2f3331965aa94aa01bf0ce21d4bbb421397ee5bd diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.md b/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.md index 4b3bbbc03c..35b9d16aab 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.md @@ -32,4 +32,4 @@ A future step that registers without wrapping its visible content in `Onboarding ## Testing -`packages/client/ui-primitives/tests/onboarding-surface.spec.tsx` pins the primitive: body portal around the content, mask/stage class presence, `#root` inert held for exactly the mount lifetime, and the no-`#root` composition. `packages/client/ui-settings/tests/settings-root.spec.tsx` pins the inverted shell contract: no takeover chrome and no inert while a mounted step renders nothing. `apps/web/tests/onboarding-deepseek-config.e2e.ts` gains the defect's assembled regression pin: a configured world reloads while every `settings.describe` response is held open at the browser's network boundary — widening the steps' deciding window from loopback-invisible to hundreds of milliseconds, which is what keeps the assertions non-vacuous — and an 8 ms in-page sampler proves the takeover chrome never mounts and `#root` never turns inert. The file's existing scenarios and the step specs (`ui-settings-general`, `ui-models`) pass unchanged — the mask selector and geometry pins survive because the stylesheet moved verbatim. +`packages/client/ui-primitives/tests/onboarding-surface.spec.tsx` pins the primitive: body portal around the content, mask/stage class presence, `#root` inert held for exactly the mount lifetime, and the no-`#root` composition. `packages/client/ui-settings-general/tests/settings-root.spec.tsx` pins the inverted shell contract: no takeover chrome and no inert while a mounted step renders nothing. `apps/web/tests/onboarding-deepseek-config.e2e.ts` gains the defect's assembled regression pin: a configured world reloads while every `settings.describe` response is held open at the browser's network boundary — widening the steps' deciding window from loopback-invisible to hundreds of milliseconds, which is what keeps the assertions non-vacuous — and an 8 ms in-page sampler proves the takeover chrome never mounts and `#root` never turns inert. The file's existing scenarios and the step specs (`ui-settings-general`, `ui-models`) pass unchanged — the mask selector and geometry pins survive because the stylesheet moved verbatim. diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.zh.md index ba4f503f7b..2f3331965a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-onboarding-step-owned-takeover-chrome.zh.md @@ -32,4 +32,4 @@ ## 测试 -`packages/client/ui-primitives/tests/onboarding-surface.spec.tsx` 钉住原语行为:内容外的 body portal、遮罩/展示层类名存在、`#root` 的 `inert` 恰好持续挂载生命周期,以及无 `#root` 的组合。`packages/client/ui-settings/tests/settings-root.spec.tsx` 钉住反转后的外壳约定:已挂载步骤什么都不渲染时,无接管界面框架、无 inert。`apps/web/tests/onboarding-deepseek-config.e2e.ts` 新增本缺陷的整装回归钉:已配置世界刷新页面,同时在浏览器网络边界扣住所有 `settings.describe` 响应——把步骤的判定窗口从 loopback 下不可见拉宽到数百毫秒,这正是断言保持非空洞的关键——页内 8ms 采样器证明接管界面框架从未挂载、`#root` 从未变为 inert。该文件的既有场景与步骤 spec(`ui-settings-general`、`ui-models`)原样通过——样式表逐字迁移,遮罩选择器与几何钉子得以幸存。 +`packages/client/ui-primitives/tests/onboarding-surface.spec.tsx` 钉住原语行为:内容外的 body portal、遮罩/展示层类名存在、`#root` 的 `inert` 恰好持续挂载生命周期,以及无 `#root` 的组合。`packages/client/ui-settings-general/tests/settings-root.spec.tsx` 钉住反转后的外壳约定:已挂载步骤什么都不渲染时,无接管界面框架、无 inert。`apps/web/tests/onboarding-deepseek-config.e2e.ts` 新增本缺陷的整装回归钉:已配置世界刷新页面,同时在浏览器网络边界扣住所有 `settings.describe` 响应——把步骤的判定窗口从 loopback 下不可见拉宽到数百毫秒,这正是断言保持非空洞的关键——页内 8ms 采样器证明接管界面框架从未挂载、`#root` 从未变为 inert。该文件的既有场景与步骤 spec(`ui-settings-general`、`ui-models`)原样通过——样式表逐字迁移,遮罩选择器与几何钉子得以幸存。 diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml index 0762a4d131..45d0a37bf6 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.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-30-versioned-gui-welcome-onboarding.md -2026-07-30-versioned-gui-welcome-onboarding.md: 4707769d4fa9fbf184e09a2e73087dfd326070be -2026-07-30-versioned-gui-welcome-onboarding.zh.md: 002a04b1fc6acc4fc7cfee053bdbbb2abaef047e +2026-07-30-versioned-gui-welcome-onboarding.md: ff4bfc860492d7eca345d55449e4e4726b988854 +2026-07-30-versioned-gui-welcome-onboarding.zh.md: dd293fe9e1e77414c4b352aa50e6b0456f866cce diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md index 4707769d4f..ff4bfc8604 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md @@ -16,7 +16,7 @@ The GUI's credential onboarding begins with a DeepSeek-specific readiness check, **Loopback acknowledgement is durable per Harness profile.** The Host half registers a `ui-onboarding` section in the user-settings seam, stored under the active `$DSH_HOME/settings.yaml`. The connection plugin publishes whether the current page uses a loopback authority as `ctx.connection.isLoopback`; hostname classification remains internal to the connection package, and other client plugins consume the service state instead of importing its implementation. A loopback browser shows the notice unless `welcomeNoticeVersion` equals the owner constant exactly. Continue applies one path mutation with the current version and calls `complete()` only after the Host commits it; a failed write leaves the notice open, and closing the page or process writes nothing. Bumping the constant intentionally makes every profile acknowledge the revised copy once. A non-loopback browser must not call the loopback-only settings API. It presents the same notice, but explicit Continue completes the step only in the current browser process; reload or a new process presents it again. -**Concurrent loopback views converge without stale replacement.** The acknowledgement write omits `expectedRevision` deliberately: every loopback tab writes the same version to one path, so the operation is idempotent and preserves sibling fields instead of rebuilding the section. `settings/document-updated` becomes `host/settings-changed`; an already mounted loopback tab refetches and advances when another tab or an external editor commits the current version. The API proxy exposes this one product namespace through a closed allowlist beside configurable-provider namespaces, without treating its changes as model-catalog invalidations. +**Concurrent loopback views converge without stale replacement.** The acknowledgement write omits `expectedRevision` deliberately: every loopback tab writes the same version to one path, so the operation is idempotent and preserves sibling fields instead of rebuilding the section. `settings/document-updated` reaches the client as an invalidation — through `host/settings-changed` then, and forwarded verbatim now ([forwarded Remote events](../architecture/2026-08-10-remote-event-delivery.md)); an already mounted loopback tab refetches and advances when another tab or an external editor commits the current version. The API proxy exposes this one product namespace through a closed allowlist beside configurable-provider namespaces, without treating its changes as model-catalog invalidations. **Onboarding temporarily owns the viewport as one continuous stage.** A solid product surface replaces the complete application view through a body-level portal and marks the underlying app root inert; the exact required mask remains mounted behind that surface with `position:absolute`, zero left/right/bottom offsets, `top:80px`, `rgba(0, 0, 0, 0.24)`, and `backdrop-filter: blur(2px)`. Welcome and conditional credential setup render as successive pages in this stage instead of independent modals. Both pages reuse the Web UI's black `BrandWordmark`. The welcome page preserves the four authored paragraphs verbatim under the `内测声明` title; every paragraph uses one 16/28 body scale, and only the requested action clause inside the final paragraph receives a subtle 500 weight. A short staggered opacity/vertical entrance supplies pacing without blocking interaction and disappears under reduced motion. The title receives initial focus, Continue is the sole button, and no close, Escape, or mask-click path exists. diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md index 002a04b1fc..dd293fe9e1 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md @@ -16,7 +16,7 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测 **loopback 确认状态按 Harness profile 持久化。** 宿主端在 user-settings seam 中注册 `ui-onboarding` 分节,并存入当前 `$DSH_HOME/settings.yaml`。connection 插件通过 `ctx.connection.isLoopback` 统一发布当前页面是否使用 loopback authority;hostname 判定留在 connection 包内,其他客户端插件只消费服务状态,而不导入其实现。除非 `welcomeNoticeVersion` 与文案所有者文件中的常量精确相等,否则 loopback 浏览器会显示通知。「继续」会以当前版本执行一次路径变更,并且仅在宿主端提交成功后调用 `complete()`;写入失败时通知保持打开,关闭页面或进程则不会写入任何内容。更新该常量会有意要求每个 profile 对修订后的文案重新确认一次。非 loopback 浏览器不能调用仅限 loopback 的 settings API;它仍显示同一通知,但显式点击「继续」只会在当前浏览器进程中完成该步骤,重新加载或新进程会再次显示通知。 -**并发 loopback 视图无需陈旧的整体替换即可收敛。** 确认写入有意省略 `expectedRevision`:每个 loopback 标签页都向同一路径写入相同版本,因此该操作是幂等的,并会保留同级字段,而不是重建整个分节。`settings/document-updated` 会转为 `host/settings-changed`;另一个标签页或外部编辑器提交当前版本后,已挂载的 loopback 标签页会重新拉取状态并推进。API Proxy(`@deepseek-ai/dsh-host-apiproxy`)在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。 +**并发 loopback 视图无需陈旧的整体替换即可收敛。** 确认写入有意省略 `expectedRevision`:每个 loopback 标签页都向同一路径写入相同版本,因此该操作是幂等的,并会保留同级字段,而不是重建整个分节。`settings/document-updated` 以失效通知形式到达客户端——当时经 `host/settings-changed`,现在则是原样转发([转发的 Remote 事件](../architecture/2026-08-10-remote-event-delivery.md));另一个标签页或外部编辑器提交当前版本后,已挂载的 loopback 标签页会重新拉取状态并推进。API Proxy(`@deepseek-ai/dsh-host-apiproxy`)在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。 **引导流程会暂时接管视口,形成一个连续阶段。** 纯色产品界面通过挂载到 `body` 的 portal 取代完整的应用视图,并将底层应用根节点标记为 inert;严格符合要求的遮罩仍挂载在该界面后方,并保留 `position:absolute`、left/right/bottom 偏移量为零、`top:80px`、`rgba(0, 0, 0, 0.24)` 和 `backdrop-filter: blur(2px)`。欢迎页和按条件显示的凭据设置页在这一阶段中依次呈现,而不是各自作为独立的模态窗口。两个页面都复用 Web UI 的黑色 `BrandWordmark`。欢迎页在 `内测声明` 标题下逐字保留既定的四段文案;所有段落统一采用 16/28 的正文字号与行高,只有最后一段中指定的行动语句使用较为克制的 500 字重。短暂的错落式透明度与纵向位移动画营造出节奏感,但不会阻碍交互,并会在用户启用减少动态效果时禁用。初始焦点落在标题上,「继续」是唯一按钮,且不存在关闭、Escape 或点击遮罩的退出路径。 diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml index 4970aa2c7d..0c25e37283 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.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-31-permission-default-for-new-sessions.md -2026-07-31-permission-default-for-new-sessions.md: ffa4c8a07bdd08ca52edbc14fe10372ad76e8cf8 -2026-07-31-permission-default-for-new-sessions.zh.md: c70d431ec9bbe8cdeabfdc8a0052a9c436844f02 +2026-07-31-permission-default-for-new-sessions.md: 63df4514f8503f13eec4b4a47daf057d07062ab3 +2026-07-31-permission-default-for-new-sessions.zh.md: 969fa1aae667e171ba0427a026ef16124fe5ee88 diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md index ffa4c8a07b..63df4514f8 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md @@ -16,7 +16,7 @@ The service reads the current Settings value synchronously at `session/created`. The existing `/permission` command and `permissions` projection remain the current-session path. The browser plugin now contributes the Permission row to `settings.general.item`, reads the dynamic enum from the redacted Settings descriptor, and writes only `defaultPreset` through a revision-checked `settings.mutate`. The row injects its observable through the slot `hooks` compartment instead of binding a renderer-specific hook, and the Permission service sweeps already-live sessions when it mounts so HMR cannot leave an unpinned session. The ownerless General-settings package contributes no placeholder rows. -ApiProxy explicitly adds `permission` to its Web settings allowlist beside the configurable-provider namespaces. This is a local boundary decision, not a general registration flag or a `local-client` access model: registering another Settings namespace still does not expose it. Permission changes emit `host/settings-changed` but not `host/models-changed`. +ApiProxy explicitly adds `permission` to its Web settings allowlist beside the configurable-provider namespaces. This is a local boundary decision, not a general registration flag or a `local-client` access model: registering another Settings namespace still does not expose it. Permission changes reach the client as a settings invalidation — `host/settings-changed` then, the forwarded `settings/document-updated` now ([forwarded Remote events](../architecture/2026-08-10-remote-event-delivery.md)) — but never as `host/models-changed`. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md index c70d431ec9..969fa1aae6 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md @@ -16,7 +16,7 @@ Web「通用」设置页将「权限」显示为禁用的骨架控件,尽管 ` 现有 `/permission` 命令和 `permissions` 投影仍是当前会话的操作路径。浏览器插件现在向 `settings.general.item` 贡献「权限」行,从脱敏后的 Settings 描述符读取动态 enum,并只通过经过 revision 校验的 `settings.mutate` 写入 `defaultPreset`。该行通过 slot 的 `hooks` 格注入 observable,而不是绑定渲染器专用钩子;权限服务挂载时会遍历并固定所有已存活会话,因此 HMR(热模块替换)不会遗留未固定的会话。无归属的「通用」设置包不贡献任何占位行。 -ApiProxy 在可配置提供方 namespace 之外,将 `permission` 显式加入 Web Settings allowlist。这是局部的边界决策,而不是通用注册标志或 `local-client` 访问模型:注册其他 Settings namespace 仍不会将其暴露。权限变更会发出 `host/settings-changed`,但不会发出 `host/models-changed`。 +ApiProxy 在可配置提供方 namespace 之外,将 `permission` 显式加入 Web Settings allowlist。这是局部的边界决策,而不是通用注册标志或 `local-client` 访问模型:注册其他 Settings namespace 仍不会将其暴露。权限变更以 settings 失效通知形式到达客户端——当时是 `host/settings-changed`,现在是转发的 `settings/document-updated`([转发的 Remote 事件](../architecture/2026-08-10-remote-event-delivery.md))——但绝不会是 `host/models-changed`。 ## 后果 diff --git a/apps/web/tests/README.i18n.yaml b/apps/web/tests/README.i18n.yaml new file mode 100644 index 0000000000..a6005ade32 --- /dev/null +++ b/apps/web/tests/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 apps/web/tests/README.md +README.md: 68e5db5af5f816cc982bacb7989d996c859be204 +README.zh.md: f366c28024dab89d0243a60d93a706f220fa8fb8 diff --git a/apps/web/tests/README.md b/apps/web/tests/README.md new file mode 100644 index 0000000000..68e5db5af5 --- /dev/null +++ b/apps/web/tests/README.md @@ -0,0 +1,46 @@ +# apps/web browser e2e + +English | [中文](README.zh.md) + +These tests boot the real web composition in-process and drive it with a real +Chromium over real HTTP. The lane's mechanics — modes, fixtures, goldens, and +the deliberate composition divergences from `dsh web` — are documented in +[`scaffold.ts`](scaffold.ts) and the +[browser e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md). + +## These are Host-face tests + +They type-check in the root `tsconfig.host.json`, not in the Client aggregate, +because they read Host services directly: `ctx.apiProxy`, the Host +`SessionStore`, `ctx.sessionProjectionCache`. Driving a browser at runtime does +not make a file part of the Client program — the two faces merge cordis +`Context` under the same keys with different services, so one program cannot see +both. Moving these files into the Client aggregate makes every Host-service +access fail to compile. + +## Do not import `@deepseek-ai/dsh-client-*` here + +Importing a Client package — a value or a type — pulls its whole TypeScript +project, and every project it references, into the **Host build graph**. That has +bitten this lane once already: four Client consumer packages reference +`api/remotes`' Client face, which cannot compile until Host tsdown has generated +`@deepseek-ai/dsh-goal/remote`, so the Host build phase ended up waiting on an +artifact it produces itself. + +When a scenario needs a Client-owned constant or pure function, mirror it here +instead, next to the commented-out import that names the source module. A drift +then surfaces as a missed selector or an unsuppressed notice — a loud failure, +never a silent pass. `scaffold.ts` holds the mirrored welcome-notice values and +exports them for the scenarios that assert on them. + +Two kinds of Client import stand. `assembled-boot.ts` drives the shell itself, so +it imports `AppWebEntry` from `@deepseek-ai/dsh-client-web` and the boot-manifest +type from `@deepseek-ai/dsh-client-modules/client`: booting the real shell is what +that harness is for, and both packages are already in the Host graph. Separately, +the chat scenarios import `conversationContextKey` from +`@deepseek-ai/dsh-client-runtime/client` because `client/runtime` is reachable +through the unsplit `directory-picker` packages and pulls nothing further in. +That reachability is incidental, not a guarantee — if it ever leaves the graph, +mirror the helper like the rest. + +Nothing mechanically enforces this rule; keep it in review. diff --git a/apps/web/tests/README.zh.md b/apps/web/tests/README.zh.md new file mode 100644 index 0000000000..f366c28024 --- /dev/null +++ b/apps/web/tests/README.zh.md @@ -0,0 +1,37 @@ +# apps/web 浏览器 e2e + +[English](README.md) | 中文 + +这些测试在进程内启动真实的 web 组合,并用真实 Chromium 通过真实 HTTP 驱动它。该 lane +的运行机制——模式、fixture、golden,以及与 `dsh web` 之间刻意保留的组合差异——记录在 +[`scaffold.ts`](scaffold.ts) 和 +[浏览器 e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md)中。 + +## 这些是 Host 面的测试 + +它们在根 `tsconfig.host.json` 中做类型检查,而不在 Client aggregate 中,因为它们直接读取 +Host 服务:`ctx.apiProxy`、Host 侧 `SessionStore`、`ctx.sessionProjectionCache`。运行时驱动 +浏览器并不使一个文件成为 Client 程序的一部分——两个 face 在相同的键上以不同服务合并 cordis +`Context`,因此单个程序无法同时看见两者。把这些文件挪进 Client aggregate 会让每一处 +Host 服务访问都无法编译。 + +## 不要在此 import `@deepseek-ai/dsh-client-*` + +import 一个 Client 包——无论值还是类型——都会把它整个 TypeScript 工程、以及它引用的每个工程 +拉进 **Host 构建图**。这已经坑过本 lane 一次:四个 Client 消费方包引用了 `api/remotes` 的 +Client face,而该 face 必须等 Host tsdown 生成 `@deepseek-ai/dsh-goal/remote` 之后才能编译, +于是 Host 构建阶段变成在等一个由它自己产出的产物。 + +当某个场景需要 Client 持有的常量或纯函数时,改为在此处镜像一份,并紧挨着一条注释掉的 +import 点明源模块。这样漂移会表现为选择器未命中或提示未被抑制——是响亮的失败,绝不会是静默 +通过。`scaffold.ts` 持有镜像的 welcome-notice 取值,并导出给断言它们的场景使用。 + +有两类 Client import 是长期成立的。`assembled-boot.ts` 驱动 shell 本身,因此它从 +`@deepseek-ai/dsh-client-web` import `AppWebEntry`、从 +`@deepseek-ai/dsh-client-modules/client` import boot manifest 类型:启动真实 shell 正是该 +harness 的用途,且这两个包本来就在 Host 图中。另外,chat 场景从 +`@deepseek-ai/dsh-client-runtime/client` import `conversationContextKey`,因为 +`client/runtime` 经未拆分的 `directory-picker` 包可达,且不会再牵入别的东西。这种可达性是 +偶然而非保证——一旦它离开该图,就像其余情形那样镜像该 helper。 + +没有任何机制强制这条规则;靠 review 守住它。 diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 77eba279ba..5572e834ad 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -10,7 +10,7 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, - WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_ACK_FIELD, + WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_VERSION, WELCOME_NOTICE_COPY, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 9d8d8fba47..24d78bf62d 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: 7d6ca74df1311560b0d61444c92e74927639ca31 -development.zh.md: 1461717cf496b1863f2f0c47ad4b42990dc789e9 +development.md: d5bd18f83f6f2f8fd3d507ec133eea4d4e06abef +development.zh.md: 0725cfaad74ec2aa3e52f6523a1dbdbdd9da8a39 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 6c8cfbdee0..92815faa9d 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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/event-producer-consumer.md -event-producer-consumer.md: d47142bfc3e0ea1430dcbc87b6f01d614d8a4b12 -event-producer-consumer.zh.md: 929fe71dfb109f8360ffa6771bb9b267a7f0ab1c +event-producer-consumer.md: a4802914ca5080dfb1e4ac7ed41d62f6c95a3f00 +event-producer-consumer.zh.md: 966555779a96d7aa2ac4ad6db55ede6b33d42870 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d47142bfc3..a4802914ca 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,8 +21,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), `server` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | -| `commands/change` | `emit` | [`packages/interaction/commands/src/index.ts:134`](../packages/interaction/commands/src/index.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | -| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | +| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:33`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | +| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/types.ts:29`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | @@ -34,8 +34,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | -| `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | -| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | +| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | +| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:166`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | @@ -61,17 +61,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | -| `commands/changed` | `runtime` (`emit`) | `ui-command` | -| `connection/reset` | `runtime` (`emit`) | `runtime`, `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` | -| `credentials/changed` | `runtime` (`emit`) | `ui-models` | +| `connection/reset` | `runtime` (`emit`) | `ui-settings` | | `internal/dispatch` | - | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) | -| `internal/plugin` | - | `hmr`, `loader`, [`lsp-local`](../packages/lsp/lsp-local), `modules`, `webserver` | +| `internal/plugin` | - | `loader`, [`lsp-local`](../packages/lsp/lsp-local), `webserver` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | -| `models/changed` | `runtime` (`emit`) | `ui-models` | -| `session/preset-changed` | `runtime` (`emit`) | `ui-command` | -| `settings/changed` | `runtime` (`emit`) | `runtime`, `ui-models`, `ui-permission`, `ui-settings-general` | +| `models/changed` | `runtime` (`emit`) | - | +| `session/preset-changed` | `runtime` (`emit`) | - | | `slash/input-begin-command` | - | `ui-conversation` | | `slash/input-consume-token` | - | `ui-conversation` | | `slash/input-insert-reference` | - | `ui-conversation` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 929fe71dfb..966555779a 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,8 +23,8 @@ | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), `server` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | -| `commands/change` | `emit` | [`packages/interaction/commands/src/index.ts:134`](../packages/interaction/commands/src/index.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | -| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | +| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:33`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` | +| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/types.ts:29`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | @@ -36,13 +36,13 @@ | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | -| `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | -| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | +| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | +| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:165`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:145`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:156`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:166`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:146`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:157`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | @@ -51,7 +51,7 @@ | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:149`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:161`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:182`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | @@ -63,17 +63,14 @@ | Event string | Dispatchers | Listeners | | --- | --- | --- | -| `commands/changed` | `runtime` (`emit`) | `ui-command` | -| `connection/reset` | `runtime` (`emit`) | `runtime`, `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` | -| `credentials/changed` | `runtime` (`emit`) | `ui-models` | +| `connection/reset` | `runtime` (`emit`) | `ui-settings` | | `internal/dispatch` | - | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) | -| `internal/plugin` | - | `hmr`, `loader`, [`lsp-local`](../packages/lsp/lsp-local), `modules`, `webserver` | -| `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets)、`gateway` | +| `internal/plugin` | - | `loader`, [`lsp-local`](../packages/lsp/lsp-local), `webserver` | +| `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | -| `models/changed` | `runtime` (`emit`) | `ui-models` | -| `session/preset-changed` | `runtime` (`emit`) | `ui-command` | -| `settings/changed` | `runtime` (`emit`) | `runtime`, `ui-models`, `ui-permission`, `ui-settings-general` | +| `models/changed` | `runtime` (`emit`) | - | +| `session/preset-changed` | `runtime` (`emit`) | - | | `slash/input-begin-command` | - | `ui-conversation` | | `slash/input-consume-token` | - | `ui-conversation` | | `slash/input-insert-reference` | - | `ui-conversation` | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index cfcaa0803d..ce44c570d7 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: 719e8f4cc28f84086d1cfad5588f8cf78bcfcf0c -module-graph.zh.md: c17ad236b083ef08ab3927670a03c9e5df0c496d +module-graph.md: 69577adc46b93de85e997030fc12638511ec3b0c +module-graph.zh.md: e483b1e093989f81260d32a8588fae9e4f44dbd0 diff --git a/docs/module-graph.md b/docs/module-graph.md index 719e8f4cc2..69577adc46 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -340,9 +340,6 @@ flowchart TD pkg_client_hmr --> pkg_client_modules pkg_client_hmr --> pkg_host_webserver pkg_client_hmr --> pkg_invariants - pkg_client_runtime --> pkg_invariants - pkg_client_runtime --> pkg_type_meta - pkg_client_runtime --> pkg_typert_registry pkg_credentials --> pkg_brand pkg_credentials --> pkg_invariants pkg_subprocess_e2b --> pkg_e2b @@ -377,22 +374,6 @@ flowchart TD pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_invariants pkg_attachment_local --> pkg_paths - pkg_client_locale --> pkg_client_connection - pkg_client_locale --> pkg_client_runtime - pkg_client_locale --> pkg_client_ui_primitives - pkg_client_locale --> pkg_client_ui_slots - pkg_client_locale --> pkg_invariants - pkg_client_ui_models --> pkg_client_connection - pkg_client_ui_models --> pkg_client_runtime - pkg_client_ui_models --> pkg_client_schema_form - pkg_client_ui_models --> pkg_client_ui_primitives - pkg_client_ui_models --> pkg_client_ui_slots - pkg_client_ui_models --> pkg_client_web_react - pkg_client_ui_models --> pkg_invariants - pkg_client_ui_settings --> pkg_client_runtime - pkg_client_ui_settings --> pkg_client_ui_primitives - pkg_client_ui_settings --> pkg_client_ui_slots - pkg_client_ui_settings --> pkg_invariants pkg_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_environment @@ -428,38 +409,10 @@ flowchart TD pkg_skill --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm - pkg_client_ui_question --> pkg_client_locale - pkg_client_ui_question --> pkg_invariants - pkg_client_ui_settings_general --> pkg_client_connection - pkg_client_ui_settings_general --> pkg_client_locale - pkg_client_ui_settings_general --> pkg_client_runtime - pkg_client_ui_settings_general --> pkg_client_ui_primitives - pkg_client_ui_settings_general --> pkg_client_ui_settings - pkg_client_ui_settings_general --> pkg_client_ui_slots - pkg_client_ui_settings_general --> pkg_client_web_react - pkg_client_ui_settings_general --> pkg_invariants - pkg_client_ui_sidebar --> pkg_client_locale - pkg_client_ui_sidebar --> pkg_client_runtime - pkg_client_ui_sidebar --> pkg_client_ui_primitives - pkg_client_ui_sidebar --> pkg_client_ui_slots - pkg_client_ui_sidebar --> pkg_invariants - pkg_client_ui_slash --> pkg_client_locale - pkg_client_ui_slash --> pkg_client_runtime - pkg_client_ui_slash --> pkg_client_ui_primitives - pkg_client_ui_slash --> pkg_client_ui_slots - pkg_client_ui_slash --> pkg_invariants - pkg_client_ui_theme --> pkg_client_connection - pkg_client_ui_theme --> pkg_client_locale - pkg_client_ui_theme --> pkg_client_runtime - pkg_client_ui_theme --> pkg_client_ui_primitives - pkg_client_ui_theme --> pkg_client_ui_slots - pkg_client_ui_theme --> pkg_host_webserver - pkg_client_ui_theme --> pkg_invariants - pkg_client_ui_workspace --> pkg_client_locale - pkg_client_ui_workspace --> pkg_client_runtime - pkg_client_ui_workspace --> pkg_client_ui_primitives - pkg_client_ui_workspace --> pkg_client_ui_slots - pkg_client_ui_workspace --> pkg_invariants + pkg_client_runtime --> pkg_api_gateway + pkg_client_runtime --> pkg_invariants + pkg_client_runtime --> pkg_type_meta + pkg_client_runtime --> pkg_typert_registry pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm @@ -490,24 +443,10 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt - pkg_client_ui_layout --> pkg_client_runtime - pkg_client_ui_layout --> pkg_client_ui_slots - pkg_client_ui_layout --> pkg_client_ui_theme - pkg_client_ui_layout --> pkg_invariants pkg_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout - pkg_host_directory_picker_browse --> pkg_client_locale - pkg_host_directory_picker_browse --> pkg_client_runtime - pkg_host_directory_picker_browse --> pkg_client_ui_primitives - pkg_host_directory_picker_browse --> pkg_client_ui_slots - pkg_host_directory_picker_browse --> pkg_client_ui_workspace - pkg_host_directory_picker_browse --> pkg_invariants - pkg_host_directory_picker_native --> pkg_client_runtime - pkg_host_directory_picker_native --> pkg_client_ui_slots - pkg_host_directory_picker_native --> pkg_client_ui_workspace - pkg_host_directory_picker_native --> pkg_invariants pkg_persona --> pkg_invariants pkg_persona --> pkg_system_prompt pkg_sandbox --> pkg_invariants @@ -566,10 +505,6 @@ flowchart TD pkg_message_feedback --> pkg_session_persistence pkg_message_feedback --> pkg_storage_domain pkg_message_feedback --> pkg_type_meta - pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse - pkg_host_directory_picker_auto --> pkg_host_directory_picker_native - pkg_host_directory_picker_auto --> pkg_host_webserver - pkg_host_directory_picker_auto --> pkg_invariants pkg_commands --> pkg_agent pkg_commands --> pkg_brand pkg_commands --> pkg_invariants @@ -690,10 +625,14 @@ flowchart TD pkg_acp --> pkg_session pkg_acp --> pkg_user_approval pkg_api_remotes --> pkg_agent + pkg_api_remotes --> pkg_api_gateway + pkg_api_remotes --> pkg_commands + pkg_api_remotes --> pkg_credentials pkg_api_remotes --> pkg_goal pkg_api_remotes --> pkg_invariants pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_persistence + pkg_api_remotes --> pkg_settings pkg_api_remotes --> pkg_typert_registry pkg_headless --> pkg_agent pkg_headless --> pkg_agent_default_model @@ -894,13 +833,22 @@ flowchart TD pkg_client_test_runtime --> pkg_client_web_react pkg_client_test_runtime --> pkg_host_apiproxy pkg_client_test_runtime --> pkg_invariants - pkg_client_ui_trajectory --> pkg_agent - pkg_client_ui_trajectory --> pkg_client_locale - pkg_client_ui_trajectory --> pkg_client_runtime - pkg_client_ui_trajectory --> pkg_client_ui_primitives - pkg_client_ui_trajectory --> pkg_compact - pkg_client_ui_trajectory --> pkg_invariants - pkg_client_ui_trajectory --> pkg_tools + pkg_client_ui_models --> pkg_api_remotes + pkg_client_ui_models --> pkg_client_connection + pkg_client_ui_models --> pkg_client_runtime + pkg_client_ui_models --> pkg_client_schema_form + pkg_client_ui_models --> pkg_client_ui_primitives + pkg_client_ui_models --> pkg_client_ui_slots + pkg_client_ui_models --> pkg_client_web_react + pkg_client_ui_models --> pkg_invariants + pkg_client_ui_settings --> pkg_api_gateway + pkg_client_ui_settings --> pkg_api_remotes + pkg_client_ui_settings --> pkg_client_connection + pkg_client_ui_settings --> pkg_client_runtime + pkg_client_ui_settings --> pkg_client_schema_form + pkg_client_ui_settings --> pkg_client_ui_slots + pkg_client_ui_settings --> pkg_invariants + pkg_client_ui_settings --> pkg_settings pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -1063,21 +1011,13 @@ flowchart TD pkg_web_app --> pkg_bash_env pkg_web_app --> pkg_invariants pkg_web_app --> pkg_system_prompt - pkg_client_ui_conversation --> pkg_agent - pkg_client_ui_conversation --> pkg_attachment - pkg_client_ui_conversation --> pkg_brand - pkg_client_ui_conversation --> pkg_client_connection - pkg_client_ui_conversation --> pkg_client_locale - pkg_client_ui_conversation --> pkg_client_runtime - pkg_client_ui_conversation --> pkg_client_ui_primitives - pkg_client_ui_conversation --> pkg_client_ui_slash - pkg_client_ui_conversation --> pkg_client_ui_slots - pkg_client_ui_conversation --> pkg_commands - pkg_client_ui_conversation --> pkg_compact - pkg_client_ui_conversation --> pkg_invariants - pkg_client_ui_conversation --> pkg_llm_retry - pkg_client_ui_conversation --> pkg_token_meter - pkg_client_ui_conversation --> pkg_tools + pkg_client_locale --> pkg_api_remotes + pkg_client_locale --> pkg_client_connection + pkg_client_locale --> pkg_client_runtime + pkg_client_locale --> pkg_client_ui_primitives + pkg_client_locale --> pkg_client_ui_settings + pkg_client_locale --> pkg_client_ui_slots + pkg_client_locale --> pkg_invariants pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1120,6 +1060,132 @@ flowchart TD pkg_subagent_spawn --> pkg_invariants pkg_subagent_spawn --> pkg_subagent pkg_subagent_spawn --> pkg_subagent_inprocess + pkg_client_ui_question --> pkg_client_locale + pkg_client_ui_question --> pkg_invariants + pkg_client_ui_sidebar --> pkg_client_locale + pkg_client_ui_sidebar --> pkg_client_runtime + pkg_client_ui_sidebar --> pkg_client_ui_primitives + pkg_client_ui_sidebar --> pkg_client_ui_slots + pkg_client_ui_sidebar --> pkg_invariants + pkg_client_ui_slash --> pkg_client_locale + pkg_client_ui_slash --> pkg_client_runtime + pkg_client_ui_slash --> pkg_client_ui_primitives + pkg_client_ui_slash --> pkg_client_ui_slots + pkg_client_ui_slash --> pkg_invariants + pkg_client_ui_theme --> pkg_api_remotes + pkg_client_ui_theme --> pkg_client_connection + pkg_client_ui_theme --> pkg_client_locale + pkg_client_ui_theme --> pkg_client_runtime + pkg_client_ui_theme --> pkg_client_ui_primitives + pkg_client_ui_theme --> pkg_client_ui_settings + pkg_client_ui_theme --> pkg_client_ui_slots + pkg_client_ui_theme --> pkg_host_webserver + pkg_client_ui_theme --> pkg_invariants + pkg_client_ui_trajectory --> pkg_agent + pkg_client_ui_trajectory --> pkg_client_locale + pkg_client_ui_trajectory --> pkg_client_runtime + pkg_client_ui_trajectory --> pkg_client_ui_primitives + pkg_client_ui_trajectory --> pkg_compact + pkg_client_ui_trajectory --> pkg_invariants + pkg_client_ui_trajectory --> pkg_tools + pkg_client_ui_workspace --> pkg_client_locale + pkg_client_ui_workspace --> pkg_client_runtime + pkg_client_ui_workspace --> pkg_client_ui_primitives + pkg_client_ui_workspace --> pkg_client_ui_slots + pkg_client_ui_workspace --> pkg_invariants + pkg_agent_spine_demo --> pkg_agent + pkg_agent_spine_demo --> pkg_agent_loop + pkg_agent_spine_demo --> pkg_bash_env + pkg_agent_spine_demo --> pkg_goal + pkg_agent_spine_demo --> pkg_goal_session + pkg_agent_spine_demo --> pkg_invariants + pkg_agent_spine_demo --> pkg_llm + pkg_agent_spine_demo --> pkg_llm_retry + pkg_agent_spine_demo --> pkg_paths + pkg_agent_spine_demo --> pkg_scope + pkg_agent_spine_demo --> pkg_session + pkg_agent_spine_demo --> pkg_session_title + pkg_agent_spine_demo --> pkg_skill + pkg_agent_spine_demo --> pkg_skill_local + pkg_agent_spine_demo --> pkg_system_prompt + pkg_agent_spine_demo --> pkg_tasks_local + pkg_agent_spine_demo --> pkg_tool_bash + pkg_agent_spine_demo --> pkg_tool_goal + pkg_agent_spine_demo --> pkg_tool_skill + pkg_agent_spine_demo --> pkg_tool_tasks + pkg_agent_spine_demo --> pkg_tools + pkg_agent_spine_demo --> pkg_workspace_context + pkg_jsonrpc --> pkg_agent + pkg_jsonrpc --> pkg_invariants + pkg_jsonrpc --> pkg_llm + pkg_jsonrpc --> pkg_llm_deepseek + pkg_jsonrpc --> pkg_scope + pkg_jsonrpc --> pkg_sdk_protocol + pkg_jsonrpc --> pkg_session + pkg_jsonrpc --> pkg_subagent + pkg_sdk_client --> pkg_invariants + pkg_sdk_client --> pkg_llm + pkg_sdk_client --> pkg_sdk_protocol + pkg_sdk_client --> pkg_session + pkg_subagent_dsh_sdk --> pkg_agent + pkg_subagent_dsh_sdk --> pkg_invariants + pkg_subagent_dsh_sdk --> pkg_llm + pkg_subagent_dsh_sdk --> pkg_sdk_client + pkg_subagent_dsh_sdk --> pkg_session + pkg_subagent_dsh_sdk --> pkg_subagent + pkg_subagent_dsh_sdk --> pkg_subprocess + pkg_client_ui_conversation --> pkg_agent + pkg_client_ui_conversation --> pkg_api_remotes + pkg_client_ui_conversation --> pkg_attachment + pkg_client_ui_conversation --> pkg_brand + pkg_client_ui_conversation --> pkg_client_connection + pkg_client_ui_conversation --> pkg_client_locale + pkg_client_ui_conversation --> pkg_client_runtime + pkg_client_ui_conversation --> pkg_client_ui_primitives + pkg_client_ui_conversation --> pkg_client_ui_settings + pkg_client_ui_conversation --> pkg_client_ui_slash + pkg_client_ui_conversation --> pkg_client_ui_slots + pkg_client_ui_conversation --> pkg_commands + pkg_client_ui_conversation --> pkg_compact + pkg_client_ui_conversation --> pkg_invariants + pkg_client_ui_conversation --> pkg_llm_retry + pkg_client_ui_conversation --> pkg_token_meter + pkg_client_ui_conversation --> pkg_tools + pkg_client_ui_layout --> pkg_client_runtime + pkg_client_ui_layout --> pkg_client_ui_slots + pkg_client_ui_layout --> pkg_client_ui_theme + pkg_client_ui_layout --> pkg_invariants + pkg_client_ui_settings_general --> pkg_api_remotes + pkg_client_ui_settings_general --> pkg_client_connection + pkg_client_ui_settings_general --> pkg_client_locale + pkg_client_ui_settings_general --> pkg_client_runtime + pkg_client_ui_settings_general --> pkg_client_ui_primitives + pkg_client_ui_settings_general --> pkg_client_ui_settings + pkg_client_ui_settings_general --> pkg_client_ui_sidebar + pkg_client_ui_settings_general --> pkg_client_ui_slots + pkg_client_ui_settings_general --> pkg_client_web_react + pkg_client_ui_settings_general --> pkg_invariants + pkg_acp_demo --> pkg_acp + pkg_acp_demo --> pkg_agent_spine_demo + pkg_acp_demo --> pkg_app_boot + pkg_acp_demo --> pkg_invariants + pkg_acp_demo --> pkg_session_checkpoint_policy + pkg_acp_demo --> pkg_session_persistence_jsonl + pkg_acp_demo --> pkg_session_query + pkg_acp_demo --> pkg_session_query_sqlite + pkg_acp_demo --> pkg_tools + pkg_acp_demo --> pkg_workspace_context + pkg_host_directory_picker_browse --> pkg_client_locale + pkg_host_directory_picker_browse --> pkg_client_runtime + pkg_host_directory_picker_browse --> pkg_client_ui_primitives + pkg_host_directory_picker_browse --> pkg_client_ui_slots + pkg_host_directory_picker_browse --> pkg_client_ui_workspace + pkg_host_directory_picker_browse --> pkg_invariants + pkg_host_directory_picker_native --> pkg_client_runtime + pkg_host_directory_picker_native --> pkg_client_ui_slots + pkg_host_directory_picker_native --> pkg_client_ui_workspace + pkg_host_directory_picker_native --> pkg_invariants + pkg_client_ui_agent_preset --> pkg_api_remotes pkg_client_ui_agent_preset --> pkg_client_connection pkg_client_ui_agent_preset --> pkg_client_locale pkg_client_ui_agent_preset --> pkg_client_runtime @@ -1129,6 +1195,7 @@ flowchart TD pkg_client_ui_agent_preset --> pkg_client_ui_slots pkg_client_ui_agent_preset --> pkg_client_web_react pkg_client_ui_agent_preset --> pkg_invariants + pkg_client_ui_command --> pkg_api_remotes pkg_client_ui_command --> pkg_client_connection pkg_client_ui_command --> pkg_client_locale pkg_client_ui_command --> pkg_client_runtime @@ -1189,47 +1256,10 @@ flowchart TD pkg_client_ui_workflow_run --> pkg_session pkg_client_ui_workflow_run --> pkg_tool_workflow pkg_client_ui_workflow_run --> pkg_workflow - pkg_agent_spine_demo --> pkg_agent - pkg_agent_spine_demo --> pkg_agent_loop - pkg_agent_spine_demo --> pkg_bash_env - pkg_agent_spine_demo --> pkg_goal - pkg_agent_spine_demo --> pkg_goal_session - pkg_agent_spine_demo --> pkg_invariants - pkg_agent_spine_demo --> pkg_llm - pkg_agent_spine_demo --> pkg_llm_retry - pkg_agent_spine_demo --> pkg_paths - pkg_agent_spine_demo --> pkg_scope - pkg_agent_spine_demo --> pkg_session - pkg_agent_spine_demo --> pkg_session_title - pkg_agent_spine_demo --> pkg_skill - pkg_agent_spine_demo --> pkg_skill_local - pkg_agent_spine_demo --> pkg_system_prompt - pkg_agent_spine_demo --> pkg_tasks_local - pkg_agent_spine_demo --> pkg_tool_bash - pkg_agent_spine_demo --> pkg_tool_goal - pkg_agent_spine_demo --> pkg_tool_skill - pkg_agent_spine_demo --> pkg_tool_tasks - pkg_agent_spine_demo --> pkg_tools - pkg_agent_spine_demo --> pkg_workspace_context - pkg_jsonrpc --> pkg_agent - pkg_jsonrpc --> pkg_invariants - pkg_jsonrpc --> pkg_llm - pkg_jsonrpc --> pkg_llm_deepseek - pkg_jsonrpc --> pkg_scope - pkg_jsonrpc --> pkg_sdk_protocol - pkg_jsonrpc --> pkg_session - pkg_jsonrpc --> pkg_subagent - pkg_sdk_client --> pkg_invariants - pkg_sdk_client --> pkg_llm - pkg_sdk_client --> pkg_sdk_protocol - pkg_sdk_client --> pkg_session - pkg_subagent_dsh_sdk --> pkg_agent - pkg_subagent_dsh_sdk --> pkg_invariants - pkg_subagent_dsh_sdk --> pkg_llm - pkg_subagent_dsh_sdk --> pkg_sdk_client - pkg_subagent_dsh_sdk --> pkg_session - pkg_subagent_dsh_sdk --> pkg_subagent - pkg_subagent_dsh_sdk --> pkg_subprocess + pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse + pkg_host_directory_picker_auto --> pkg_host_directory_picker_native + pkg_host_directory_picker_auto --> pkg_host_webserver + pkg_host_directory_picker_auto --> pkg_invariants pkg_client_ui_model --> pkg_client_connection pkg_client_ui_model --> pkg_client_locale pkg_client_ui_model --> pkg_client_runtime @@ -1239,12 +1269,14 @@ flowchart TD pkg_client_ui_model --> pkg_client_ui_slash pkg_client_ui_model --> pkg_client_ui_slots pkg_client_ui_model --> pkg_invariants + pkg_client_ui_permission --> pkg_api_remotes pkg_client_ui_permission --> pkg_client_connection pkg_client_ui_permission --> pkg_client_locale pkg_client_ui_permission --> pkg_client_runtime pkg_client_ui_permission --> pkg_client_schema_form pkg_client_ui_permission --> pkg_client_ui_command pkg_client_ui_permission --> pkg_client_ui_primitives + pkg_client_ui_permission --> pkg_client_ui_settings pkg_client_ui_permission --> pkg_client_ui_slash pkg_client_ui_permission --> pkg_client_ui_slots pkg_client_ui_permission --> pkg_invariants @@ -1257,16 +1289,6 @@ flowchart TD pkg_client_ui_skill --> pkg_client_ui_slots pkg_client_ui_skill --> pkg_client_ui_tool pkg_client_ui_skill --> pkg_invariants - pkg_acp_demo --> pkg_acp - pkg_acp_demo --> pkg_agent_spine_demo - pkg_acp_demo --> pkg_app_boot - pkg_acp_demo --> pkg_invariants - pkg_acp_demo --> pkg_session_checkpoint_policy - pkg_acp_demo --> pkg_session_persistence_jsonl - pkg_acp_demo --> pkg_session_query - pkg_acp_demo --> pkg_session_query_sqlite - pkg_acp_demo --> pkg_tools - pkg_acp_demo --> pkg_workspace_context ``` | Package | Group | Depends on | @@ -1303,7 +1325,6 @@ flowchart TD | [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | -| [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | | [`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) | @@ -1317,9 +1338,6 @@ flowchart TD | [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | -| [`client-locale`](../packages/client/locale) | `client` | [`client-connection`](../packages/client/connection), [`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-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | -| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | @@ -1328,12 +1346,7 @@ flowchart TD | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`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-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) | -| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `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-runtime`](../packages/client/runtime) | `client` | [`api-gateway`](../packages/api/gateway), [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) | @@ -1343,10 +1356,7 @@ flowchart TD | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | @@ -1361,7 +1371,6 @@ flowchart TD | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`type-meta`](../packages/typert/type-meta) | -| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/interaction/user-interaction) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | @@ -1389,7 +1398,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), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`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) | @@ -1424,7 +1433,8 @@ flowchart TD | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | +| [`client-ui-models`](../packages/client/ui-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`settings`](../packages/settings/settings) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1452,7 +1462,7 @@ flowchart TD | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | +| [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`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) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | @@ -1460,8 +1470,24 @@ flowchart TD | [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | -| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`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-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-question`](../packages/client/ui-question) | `client` | [`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) | +| [`client-ui-theme`](../packages/client/ui-theme) | `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), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `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) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`jsonrpc`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`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-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`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-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | +| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `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-sidebar`](../packages/client/ui-sidebar), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`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-command`](../packages/client/ui-command) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | @@ -1469,11 +1495,7 @@ flowchart TD | [`client-ui-task`](../packages/client/ui-task) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`jsonrpc`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`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) | +| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/interaction/permission) | +| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/interaction/permission) | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/support/invariants) | -| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index c17ad236b0..e483b1e093 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -342,9 +342,6 @@ flowchart TD pkg_client_hmr --> pkg_client_modules pkg_client_hmr --> pkg_host_webserver pkg_client_hmr --> pkg_invariants - pkg_client_runtime --> pkg_invariants - pkg_client_runtime --> pkg_type_meta - pkg_client_runtime --> pkg_typert_registry pkg_credentials --> pkg_brand pkg_credentials --> pkg_invariants pkg_subprocess_e2b --> pkg_e2b @@ -379,22 +376,6 @@ flowchart TD pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_invariants pkg_attachment_local --> pkg_paths - pkg_client_locale --> pkg_client_connection - pkg_client_locale --> pkg_client_runtime - pkg_client_locale --> pkg_client_ui_primitives - pkg_client_locale --> pkg_client_ui_slots - pkg_client_locale --> pkg_invariants - pkg_client_ui_models --> pkg_client_connection - pkg_client_ui_models --> pkg_client_runtime - pkg_client_ui_models --> pkg_client_schema_form - pkg_client_ui_models --> pkg_client_ui_primitives - pkg_client_ui_models --> pkg_client_ui_slots - pkg_client_ui_models --> pkg_client_web_react - pkg_client_ui_models --> pkg_invariants - pkg_client_ui_settings --> pkg_client_runtime - pkg_client_ui_settings --> pkg_client_ui_primitives - pkg_client_ui_settings --> pkg_client_ui_slots - pkg_client_ui_settings --> pkg_invariants pkg_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_environment @@ -430,38 +411,10 @@ flowchart TD pkg_skill --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm - pkg_client_ui_question --> pkg_client_locale - pkg_client_ui_question --> pkg_invariants - pkg_client_ui_settings_general --> pkg_client_connection - pkg_client_ui_settings_general --> pkg_client_locale - pkg_client_ui_settings_general --> pkg_client_runtime - pkg_client_ui_settings_general --> pkg_client_ui_primitives - pkg_client_ui_settings_general --> pkg_client_ui_settings - pkg_client_ui_settings_general --> pkg_client_ui_slots - pkg_client_ui_settings_general --> pkg_client_web_react - pkg_client_ui_settings_general --> pkg_invariants - pkg_client_ui_sidebar --> pkg_client_locale - pkg_client_ui_sidebar --> pkg_client_runtime - pkg_client_ui_sidebar --> pkg_client_ui_primitives - pkg_client_ui_sidebar --> pkg_client_ui_slots - pkg_client_ui_sidebar --> pkg_invariants - pkg_client_ui_slash --> pkg_client_locale - pkg_client_ui_slash --> pkg_client_runtime - pkg_client_ui_slash --> pkg_client_ui_primitives - pkg_client_ui_slash --> pkg_client_ui_slots - pkg_client_ui_slash --> pkg_invariants - pkg_client_ui_theme --> pkg_client_connection - pkg_client_ui_theme --> pkg_client_locale - pkg_client_ui_theme --> pkg_client_runtime - pkg_client_ui_theme --> pkg_client_ui_primitives - pkg_client_ui_theme --> pkg_client_ui_slots - pkg_client_ui_theme --> pkg_host_webserver - pkg_client_ui_theme --> pkg_invariants - pkg_client_ui_workspace --> pkg_client_locale - pkg_client_ui_workspace --> pkg_client_runtime - pkg_client_ui_workspace --> pkg_client_ui_primitives - pkg_client_ui_workspace --> pkg_client_ui_slots - pkg_client_ui_workspace --> pkg_invariants + pkg_client_runtime --> pkg_api_gateway + pkg_client_runtime --> pkg_invariants + pkg_client_runtime --> pkg_type_meta + pkg_client_runtime --> pkg_typert_registry pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm @@ -492,24 +445,10 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt - pkg_client_ui_layout --> pkg_client_runtime - pkg_client_ui_layout --> pkg_client_ui_slots - pkg_client_ui_layout --> pkg_client_ui_theme - pkg_client_ui_layout --> pkg_invariants pkg_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout - pkg_host_directory_picker_browse --> pkg_client_locale - pkg_host_directory_picker_browse --> pkg_client_runtime - pkg_host_directory_picker_browse --> pkg_client_ui_primitives - pkg_host_directory_picker_browse --> pkg_client_ui_slots - pkg_host_directory_picker_browse --> pkg_client_ui_workspace - pkg_host_directory_picker_browse --> pkg_invariants - pkg_host_directory_picker_native --> pkg_client_runtime - pkg_host_directory_picker_native --> pkg_client_ui_slots - pkg_host_directory_picker_native --> pkg_client_ui_workspace - pkg_host_directory_picker_native --> pkg_invariants pkg_persona --> pkg_invariants pkg_persona --> pkg_system_prompt pkg_sandbox --> pkg_invariants @@ -568,10 +507,6 @@ flowchart TD pkg_message_feedback --> pkg_session_persistence pkg_message_feedback --> pkg_storage_domain pkg_message_feedback --> pkg_type_meta - pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse - pkg_host_directory_picker_auto --> pkg_host_directory_picker_native - pkg_host_directory_picker_auto --> pkg_host_webserver - pkg_host_directory_picker_auto --> pkg_invariants pkg_commands --> pkg_agent pkg_commands --> pkg_brand pkg_commands --> pkg_invariants @@ -692,10 +627,14 @@ flowchart TD pkg_acp --> pkg_session pkg_acp --> pkg_user_approval pkg_api_remotes --> pkg_agent + pkg_api_remotes --> pkg_api_gateway + pkg_api_remotes --> pkg_commands + pkg_api_remotes --> pkg_credentials pkg_api_remotes --> pkg_goal pkg_api_remotes --> pkg_invariants pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_persistence + pkg_api_remotes --> pkg_settings pkg_api_remotes --> pkg_typert_registry pkg_headless --> pkg_agent pkg_headless --> pkg_agent_default_model @@ -896,13 +835,22 @@ flowchart TD pkg_client_test_runtime --> pkg_client_web_react pkg_client_test_runtime --> pkg_host_apiproxy pkg_client_test_runtime --> pkg_invariants - pkg_client_ui_trajectory --> pkg_agent - pkg_client_ui_trajectory --> pkg_client_locale - pkg_client_ui_trajectory --> pkg_client_runtime - pkg_client_ui_trajectory --> pkg_client_ui_primitives - pkg_client_ui_trajectory --> pkg_compact - pkg_client_ui_trajectory --> pkg_invariants - pkg_client_ui_trajectory --> pkg_tools + pkg_client_ui_models --> pkg_api_remotes + pkg_client_ui_models --> pkg_client_connection + pkg_client_ui_models --> pkg_client_runtime + pkg_client_ui_models --> pkg_client_schema_form + pkg_client_ui_models --> pkg_client_ui_primitives + pkg_client_ui_models --> pkg_client_ui_slots + pkg_client_ui_models --> pkg_client_web_react + pkg_client_ui_models --> pkg_invariants + pkg_client_ui_settings --> pkg_api_gateway + pkg_client_ui_settings --> pkg_api_remotes + pkg_client_ui_settings --> pkg_client_connection + pkg_client_ui_settings --> pkg_client_runtime + pkg_client_ui_settings --> pkg_client_schema_form + pkg_client_ui_settings --> pkg_client_ui_slots + pkg_client_ui_settings --> pkg_invariants + pkg_client_ui_settings --> pkg_settings pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -1065,21 +1013,13 @@ flowchart TD pkg_web_app --> pkg_bash_env pkg_web_app --> pkg_invariants pkg_web_app --> pkg_system_prompt - pkg_client_ui_conversation --> pkg_agent - pkg_client_ui_conversation --> pkg_attachment - pkg_client_ui_conversation --> pkg_brand - pkg_client_ui_conversation --> pkg_client_connection - pkg_client_ui_conversation --> pkg_client_locale - pkg_client_ui_conversation --> pkg_client_runtime - pkg_client_ui_conversation --> pkg_client_ui_primitives - pkg_client_ui_conversation --> pkg_client_ui_slash - pkg_client_ui_conversation --> pkg_client_ui_slots - pkg_client_ui_conversation --> pkg_commands - pkg_client_ui_conversation --> pkg_compact - pkg_client_ui_conversation --> pkg_invariants - pkg_client_ui_conversation --> pkg_llm_retry - pkg_client_ui_conversation --> pkg_token_meter - pkg_client_ui_conversation --> pkg_tools + pkg_client_locale --> pkg_api_remotes + pkg_client_locale --> pkg_client_connection + pkg_client_locale --> pkg_client_runtime + pkg_client_locale --> pkg_client_ui_primitives + pkg_client_locale --> pkg_client_ui_settings + pkg_client_locale --> pkg_client_ui_slots + pkg_client_locale --> pkg_invariants pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1122,6 +1062,132 @@ flowchart TD pkg_subagent_spawn --> pkg_invariants pkg_subagent_spawn --> pkg_subagent pkg_subagent_spawn --> pkg_subagent_inprocess + pkg_client_ui_question --> pkg_client_locale + pkg_client_ui_question --> pkg_invariants + pkg_client_ui_sidebar --> pkg_client_locale + pkg_client_ui_sidebar --> pkg_client_runtime + pkg_client_ui_sidebar --> pkg_client_ui_primitives + pkg_client_ui_sidebar --> pkg_client_ui_slots + pkg_client_ui_sidebar --> pkg_invariants + pkg_client_ui_slash --> pkg_client_locale + pkg_client_ui_slash --> pkg_client_runtime + pkg_client_ui_slash --> pkg_client_ui_primitives + pkg_client_ui_slash --> pkg_client_ui_slots + pkg_client_ui_slash --> pkg_invariants + pkg_client_ui_theme --> pkg_api_remotes + pkg_client_ui_theme --> pkg_client_connection + pkg_client_ui_theme --> pkg_client_locale + pkg_client_ui_theme --> pkg_client_runtime + pkg_client_ui_theme --> pkg_client_ui_primitives + pkg_client_ui_theme --> pkg_client_ui_settings + pkg_client_ui_theme --> pkg_client_ui_slots + pkg_client_ui_theme --> pkg_host_webserver + pkg_client_ui_theme --> pkg_invariants + pkg_client_ui_trajectory --> pkg_agent + pkg_client_ui_trajectory --> pkg_client_locale + pkg_client_ui_trajectory --> pkg_client_runtime + pkg_client_ui_trajectory --> pkg_client_ui_primitives + pkg_client_ui_trajectory --> pkg_compact + pkg_client_ui_trajectory --> pkg_invariants + pkg_client_ui_trajectory --> pkg_tools + pkg_client_ui_workspace --> pkg_client_locale + pkg_client_ui_workspace --> pkg_client_runtime + pkg_client_ui_workspace --> pkg_client_ui_primitives + pkg_client_ui_workspace --> pkg_client_ui_slots + pkg_client_ui_workspace --> pkg_invariants + pkg_agent_spine_demo --> pkg_agent + pkg_agent_spine_demo --> pkg_agent_loop + pkg_agent_spine_demo --> pkg_bash_env + pkg_agent_spine_demo --> pkg_goal + pkg_agent_spine_demo --> pkg_goal_session + pkg_agent_spine_demo --> pkg_invariants + pkg_agent_spine_demo --> pkg_llm + pkg_agent_spine_demo --> pkg_llm_retry + pkg_agent_spine_demo --> pkg_paths + pkg_agent_spine_demo --> pkg_scope + pkg_agent_spine_demo --> pkg_session + pkg_agent_spine_demo --> pkg_session_title + pkg_agent_spine_demo --> pkg_skill + pkg_agent_spine_demo --> pkg_skill_local + pkg_agent_spine_demo --> pkg_system_prompt + pkg_agent_spine_demo --> pkg_tasks_local + pkg_agent_spine_demo --> pkg_tool_bash + pkg_agent_spine_demo --> pkg_tool_goal + pkg_agent_spine_demo --> pkg_tool_skill + pkg_agent_spine_demo --> pkg_tool_tasks + pkg_agent_spine_demo --> pkg_tools + pkg_agent_spine_demo --> pkg_workspace_context + pkg_jsonrpc --> pkg_agent + pkg_jsonrpc --> pkg_invariants + pkg_jsonrpc --> pkg_llm + pkg_jsonrpc --> pkg_llm_deepseek + pkg_jsonrpc --> pkg_scope + pkg_jsonrpc --> pkg_sdk_protocol + pkg_jsonrpc --> pkg_session + pkg_jsonrpc --> pkg_subagent + pkg_sdk_client --> pkg_invariants + pkg_sdk_client --> pkg_llm + pkg_sdk_client --> pkg_sdk_protocol + pkg_sdk_client --> pkg_session + pkg_subagent_dsh_sdk --> pkg_agent + pkg_subagent_dsh_sdk --> pkg_invariants + pkg_subagent_dsh_sdk --> pkg_llm + pkg_subagent_dsh_sdk --> pkg_sdk_client + pkg_subagent_dsh_sdk --> pkg_session + pkg_subagent_dsh_sdk --> pkg_subagent + pkg_subagent_dsh_sdk --> pkg_subprocess + pkg_client_ui_conversation --> pkg_agent + pkg_client_ui_conversation --> pkg_api_remotes + pkg_client_ui_conversation --> pkg_attachment + pkg_client_ui_conversation --> pkg_brand + pkg_client_ui_conversation --> pkg_client_connection + pkg_client_ui_conversation --> pkg_client_locale + pkg_client_ui_conversation --> pkg_client_runtime + pkg_client_ui_conversation --> pkg_client_ui_primitives + pkg_client_ui_conversation --> pkg_client_ui_settings + pkg_client_ui_conversation --> pkg_client_ui_slash + pkg_client_ui_conversation --> pkg_client_ui_slots + pkg_client_ui_conversation --> pkg_commands + pkg_client_ui_conversation --> pkg_compact + pkg_client_ui_conversation --> pkg_invariants + pkg_client_ui_conversation --> pkg_llm_retry + pkg_client_ui_conversation --> pkg_token_meter + pkg_client_ui_conversation --> pkg_tools + pkg_client_ui_layout --> pkg_client_runtime + pkg_client_ui_layout --> pkg_client_ui_slots + pkg_client_ui_layout --> pkg_client_ui_theme + pkg_client_ui_layout --> pkg_invariants + pkg_client_ui_settings_general --> pkg_api_remotes + pkg_client_ui_settings_general --> pkg_client_connection + pkg_client_ui_settings_general --> pkg_client_locale + pkg_client_ui_settings_general --> pkg_client_runtime + pkg_client_ui_settings_general --> pkg_client_ui_primitives + pkg_client_ui_settings_general --> pkg_client_ui_settings + pkg_client_ui_settings_general --> pkg_client_ui_sidebar + pkg_client_ui_settings_general --> pkg_client_ui_slots + pkg_client_ui_settings_general --> pkg_client_web_react + pkg_client_ui_settings_general --> pkg_invariants + pkg_acp_demo --> pkg_acp + pkg_acp_demo --> pkg_agent_spine_demo + pkg_acp_demo --> pkg_app_boot + pkg_acp_demo --> pkg_invariants + pkg_acp_demo --> pkg_session_checkpoint_policy + pkg_acp_demo --> pkg_session_persistence_jsonl + pkg_acp_demo --> pkg_session_query + pkg_acp_demo --> pkg_session_query_sqlite + pkg_acp_demo --> pkg_tools + pkg_acp_demo --> pkg_workspace_context + pkg_host_directory_picker_browse --> pkg_client_locale + pkg_host_directory_picker_browse --> pkg_client_runtime + pkg_host_directory_picker_browse --> pkg_client_ui_primitives + pkg_host_directory_picker_browse --> pkg_client_ui_slots + pkg_host_directory_picker_browse --> pkg_client_ui_workspace + pkg_host_directory_picker_browse --> pkg_invariants + pkg_host_directory_picker_native --> pkg_client_runtime + pkg_host_directory_picker_native --> pkg_client_ui_slots + pkg_host_directory_picker_native --> pkg_client_ui_workspace + pkg_host_directory_picker_native --> pkg_invariants + pkg_client_ui_agent_preset --> pkg_api_remotes pkg_client_ui_agent_preset --> pkg_client_connection pkg_client_ui_agent_preset --> pkg_client_locale pkg_client_ui_agent_preset --> pkg_client_runtime @@ -1131,6 +1197,7 @@ flowchart TD pkg_client_ui_agent_preset --> pkg_client_ui_slots pkg_client_ui_agent_preset --> pkg_client_web_react pkg_client_ui_agent_preset --> pkg_invariants + pkg_client_ui_command --> pkg_api_remotes pkg_client_ui_command --> pkg_client_connection pkg_client_ui_command --> pkg_client_locale pkg_client_ui_command --> pkg_client_runtime @@ -1191,47 +1258,10 @@ flowchart TD pkg_client_ui_workflow_run --> pkg_session pkg_client_ui_workflow_run --> pkg_tool_workflow pkg_client_ui_workflow_run --> pkg_workflow - pkg_agent_spine_demo --> pkg_agent - pkg_agent_spine_demo --> pkg_agent_loop - pkg_agent_spine_demo --> pkg_bash_env - pkg_agent_spine_demo --> pkg_goal - pkg_agent_spine_demo --> pkg_goal_session - pkg_agent_spine_demo --> pkg_invariants - pkg_agent_spine_demo --> pkg_llm - pkg_agent_spine_demo --> pkg_llm_retry - pkg_agent_spine_demo --> pkg_paths - pkg_agent_spine_demo --> pkg_scope - pkg_agent_spine_demo --> pkg_session - pkg_agent_spine_demo --> pkg_session_title - pkg_agent_spine_demo --> pkg_skill - pkg_agent_spine_demo --> pkg_skill_local - pkg_agent_spine_demo --> pkg_system_prompt - pkg_agent_spine_demo --> pkg_tasks_local - pkg_agent_spine_demo --> pkg_tool_bash - pkg_agent_spine_demo --> pkg_tool_goal - pkg_agent_spine_demo --> pkg_tool_skill - pkg_agent_spine_demo --> pkg_tool_tasks - pkg_agent_spine_demo --> pkg_tools - pkg_agent_spine_demo --> pkg_workspace_context - pkg_jsonrpc --> pkg_agent - pkg_jsonrpc --> pkg_invariants - pkg_jsonrpc --> pkg_llm - pkg_jsonrpc --> pkg_llm_deepseek - pkg_jsonrpc --> pkg_scope - pkg_jsonrpc --> pkg_sdk_protocol - pkg_jsonrpc --> pkg_session - pkg_jsonrpc --> pkg_subagent - pkg_sdk_client --> pkg_invariants - pkg_sdk_client --> pkg_llm - pkg_sdk_client --> pkg_sdk_protocol - pkg_sdk_client --> pkg_session - pkg_subagent_dsh_sdk --> pkg_agent - pkg_subagent_dsh_sdk --> pkg_invariants - pkg_subagent_dsh_sdk --> pkg_llm - pkg_subagent_dsh_sdk --> pkg_sdk_client - pkg_subagent_dsh_sdk --> pkg_session - pkg_subagent_dsh_sdk --> pkg_subagent - pkg_subagent_dsh_sdk --> pkg_subprocess + pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse + pkg_host_directory_picker_auto --> pkg_host_directory_picker_native + pkg_host_directory_picker_auto --> pkg_host_webserver + pkg_host_directory_picker_auto --> pkg_invariants pkg_client_ui_model --> pkg_client_connection pkg_client_ui_model --> pkg_client_locale pkg_client_ui_model --> pkg_client_runtime @@ -1241,12 +1271,14 @@ flowchart TD pkg_client_ui_model --> pkg_client_ui_slash pkg_client_ui_model --> pkg_client_ui_slots pkg_client_ui_model --> pkg_invariants + pkg_client_ui_permission --> pkg_api_remotes pkg_client_ui_permission --> pkg_client_connection pkg_client_ui_permission --> pkg_client_locale pkg_client_ui_permission --> pkg_client_runtime pkg_client_ui_permission --> pkg_client_schema_form pkg_client_ui_permission --> pkg_client_ui_command pkg_client_ui_permission --> pkg_client_ui_primitives + pkg_client_ui_permission --> pkg_client_ui_settings pkg_client_ui_permission --> pkg_client_ui_slash pkg_client_ui_permission --> pkg_client_ui_slots pkg_client_ui_permission --> pkg_invariants @@ -1259,16 +1291,6 @@ flowchart TD pkg_client_ui_skill --> pkg_client_ui_slots pkg_client_ui_skill --> pkg_client_ui_tool pkg_client_ui_skill --> pkg_invariants - pkg_acp_demo --> pkg_acp - pkg_acp_demo --> pkg_agent_spine_demo - pkg_acp_demo --> pkg_app_boot - pkg_acp_demo --> pkg_invariants - pkg_acp_demo --> pkg_session_checkpoint_policy - pkg_acp_demo --> pkg_session_persistence_jsonl - pkg_acp_demo --> pkg_session_query - pkg_acp_demo --> pkg_session_query_sqlite - pkg_acp_demo --> pkg_tools - pkg_acp_demo --> pkg_workspace_context ``` | Package | Group | Depends on | @@ -1305,7 +1327,6 @@ flowchart TD | [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | -| [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | | [`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) | @@ -1319,9 +1340,6 @@ flowchart TD | [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | -| [`client-locale`](../packages/client/locale) | `client` | [`client-connection`](../packages/client/connection), [`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-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | -| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | @@ -1330,12 +1348,7 @@ flowchart TD | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`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-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) | -| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `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-runtime`](../packages/client/runtime) | `client` | [`api-gateway`](../packages/api/gateway), [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) | @@ -1345,10 +1358,7 @@ flowchart TD | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | @@ -1363,7 +1373,6 @@ flowchart TD | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`type-meta`](../packages/typert/type-meta) | -| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/interaction/user-interaction) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | @@ -1391,7 +1400,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), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`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) | @@ -1426,7 +1435,8 @@ flowchart TD | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | +| [`client-ui-models`](../packages/client/ui-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`settings`](../packages/settings/settings) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | @@ -1454,7 +1464,7 @@ flowchart TD | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | +| [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`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) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | @@ -1462,8 +1472,24 @@ flowchart TD | [`subagent-codex`](../packages/subagent/subagent-codex) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | -| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`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-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-question`](../packages/client/ui-question) | `client` | [`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) | +| [`client-ui-theme`](../packages/client/ui-theme) | `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), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `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) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`jsonrpc`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`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-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`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-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | +| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `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-sidebar`](../packages/client/ui-sidebar), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`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-command`](../packages/client/ui-command) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | @@ -1471,11 +1497,7 @@ flowchart TD | [`client-ui-task`](../packages/client/ui-task) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`jsonrpc`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`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) | +| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/interaction/permission) | +| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/interaction/permission) | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/support/invariants) | -| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 88fc306381..3574545cd5 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-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/persistence-catalog.md -persistence-catalog.md: 3b6e5b742df6282ec0cde4b4581eb3909844b537 +persistence-catalog.md: 0ccb94ab8cb89c096b48a01b1232428dfc94f676 persistence-catalog.zh.md: 6b65038869b53536bcbb2bd15c04172a7b5531e5 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 3b6e5b742d..0ccb94ab8c 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -90,7 +90,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:338`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:367`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:399`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:336`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:343`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:372`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) ## Events @@ -203,7 +203,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:67`](../packages/inter Types: [StreamChunk](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:261`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:266`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -219,7 +219,7 @@ Source: [`packages/core/session/src/types.ts:261`](../packages/core/session/src/ Types: [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:273`](../packages/core/session/src/types.ts) ### `command/*` @@ -240,7 +240,7 @@ Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/ } ``` -Source: [`packages/interaction/commands/src/types.ts:41`](../packages/interaction/commands/src/types.ts) +Source: [`packages/interaction/commands/src/types.ts:56`](../packages/interaction/commands/src/types.ts) #### `command/run` — log-only @@ -258,7 +258,7 @@ Source: [`packages/interaction/commands/src/types.ts:41`](../packages/interactio 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } ``` -Source: [`packages/interaction/commands/src/types.ts:34`](../packages/interaction/commands/src/types.ts) +Source: [`packages/interaction/commands/src/types.ts:49`](../packages/interaction/commands/src/types.ts) ### `compact/*` @@ -499,7 +499,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:309`](../packages/core/session/src/types.ts) #### `request/header` — log-only @@ -511,7 +511,7 @@ Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -564,7 +564,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s 'session/end-seed': Record ``` -Source: [`packages/core/session/src/types.ts:327`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:332`](../packages/core/session/src/types.ts) #### `session/title` — log-only @@ -600,7 +600,7 @@ Source: [`packages/session/session-title-llm/src/index.ts:43`](../packages/sessi 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:256`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -609,7 +609,7 @@ Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:254`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -639,7 +639,7 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent Types: [TodoItem](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) ### `tool/*` @@ -656,7 +656,7 @@ Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/ Types: [CallId](subsystems/core.md) -Source: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -725,7 +725,7 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types } ``` -Source: [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:291`](../packages/core/session/src/types.ts) ### `tool-workflow/*` @@ -795,7 +795,7 @@ Source: [`packages/workflow/tool-workflow/src/types.ts:47`](../packages/workflow Types: [TurnEndReason](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:247`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -809,7 +809,7 @@ Source: [`packages/core/session/src/types.ts:247`](../packages/core/session/src/ 'turn/start': { turn: number } ``` -Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:243`](../packages/core/session/src/types.ts) ### `user/*` @@ -826,7 +826,7 @@ Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:259`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:264`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/docs/subsystems/commands.i18n.yaml b/docs/subsystems/commands.i18n.yaml index f04e332ad9..9a8c979de4 100644 --- a/docs/subsystems/commands.i18n.yaml +++ b/docs/subsystems/commands.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/commands.md -commands.md: 03cb068418ff3e157349989e421f33d76cd7afe6 -commands.zh.md: b900baab4b0fffb50ac10265f4ea3ac2d8f9d01b +commands.md: 843210ccfa967ed03cd704d2b4b93bc7a9323102 +commands.zh.md: 9e60b160733c99841a7a26c6aa1809450a9b5ff5 diff --git a/docs/subsystems/commands.md b/docs/subsystems/commands.md index 03cb068418..843210ccfa 100644 --- a/docs/subsystems/commands.md +++ b/docs/subsystems/commands.md @@ -161,7 +161,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise @@ -183,5 +183,5 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/interaction/commands/src/index.ts:134`](../../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/types.ts:33`](../../packages/interaction/commands/src/types.ts) diff --git a/docs/subsystems/commands.zh.md b/docs/subsystems/commands.zh.md index b900baab4b..9e60b16073 100644 --- a/docs/subsystems/commands.zh.md +++ b/docs/subsystems/commands.zh.md @@ -161,7 +161,7 @@ async execute( agent: Agent, line: string, signal: AbortSignal, ): Promise @@ -183,5 +183,5 @@ A command was registered or unregistered. This is an unfiltered registry notific 'commands/change'(): void ``` -Source: [`packages/interaction/commands/src/index.ts:134`](../../packages/interaction/commands/src/index.ts) +Source: [`packages/interaction/commands/src/types.ts:33`](../../packages/interaction/commands/src/types.ts) diff --git a/docs/subsystems/credentials.i18n.yaml b/docs/subsystems/credentials.i18n.yaml index a7c9218e1b..74e7f89cf7 100644 --- a/docs/subsystems/credentials.i18n.yaml +++ b/docs/subsystems/credentials.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/credentials.md -credentials.md: 5ac023231c7eac85edbeda1c72f871ca37c0a891 -credentials.zh.md: b3f6e19ca76fb680ab243715530cb03770b543dc +credentials.md: 9bfc0f46fbc13bef7f0132f17aa0de9007edf26d +credentials.zh.md: 649771f53a1e00fd2b4302d8c31f35235a2961a3 diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md index 5ac023231c..9bfc0f46fb 100644 --- a/docs/subsystems/credentials.md +++ b/docs/subsystems/credentials.md @@ -101,7 +101,7 @@ abstract set(ref: CredentialRef, value: string): Promise abstract unset(ref: CredentialRef): Promise ``` -Source: [`packages/credentials/credentials/src/index.ts:77`](../../packages/credentials/credentials/src/index.ts) +Source: [`packages/credentials/credentials/src/index.ts:60`](../../packages/credentials/credentials/src/index.ts) @@ -129,5 +129,5 @@ Committed change to a provider-managed credential source: a `set`, an `unset`, o 'credentials/updated'(ref: CredentialRef): void ``` -Source: [`packages/credentials/credentials/src/index.ts:67`](../../packages/credentials/credentials/src/index.ts) +Source: [`packages/credentials/credentials/src/types.ts:29`](../../packages/credentials/credentials/src/types.ts) diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md index b3f6e19ca7..649771f53a 100644 --- a/docs/subsystems/credentials.zh.md +++ b/docs/subsystems/credentials.zh.md @@ -101,7 +101,7 @@ abstract set(ref: CredentialRef, value: string): Promise abstract unset(ref: CredentialRef): Promise ``` -Source: [`packages/credentials/credentials/src/index.ts:77`](../../packages/credentials/credentials/src/index.ts) +Source: [`packages/credentials/credentials/src/index.ts:60`](../../packages/credentials/credentials/src/index.ts) @@ -129,5 +129,5 @@ Committed change to a provider-managed credential source: a `set`, an `unset`, o 'credentials/updated'(ref: CredentialRef): void ``` -Source: [`packages/credentials/credentials/src/index.ts:67`](../../packages/credentials/credentials/src/index.ts) +Source: [`packages/credentials/credentials/src/types.ts:29`](../../packages/credentials/credentials/src/types.ts) diff --git a/docs/subsystems/settings.i18n.yaml b/docs/subsystems/settings.i18n.yaml index 8d243e8eb2..78abf3bd5f 100644 --- a/docs/subsystems/settings.i18n.yaml +++ b/docs/subsystems/settings.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/settings.md -settings.md: 6499c8260ad90c6aa4576d76abe30f261fdb0192 -settings.zh.md: 2b9f89d54feebe4436a182ff9e42d31ac080101d +settings.md: 04360b2c332c37e6602bf792a28b77f6755a5b34 +settings.zh.md: 206c305059126c5cf95abfc1980214352b9ce840 diff --git a/docs/subsystems/settings.md b/docs/subsystems/settings.md index 6499c8260a..04360b2c33 100644 --- a/docs/subsystems/settings.md +++ b/docs/subsystems/settings.md @@ -252,7 +252,7 @@ async replace(ns: SettingsNamespace, section: object, expectedRevision?: number) async mutate(ns: SettingsNamespace, ops: readonly SettingsPathOp[], expectedRevision?: number): Promise ``` -Source: [`packages/settings/settings/src/index.ts:387`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/index.ts:350`](../../packages/settings/settings/src/index.ts) @@ -279,7 +279,7 @@ One registered namespace's RAW user section changed, whether or not the resolved 'settings/document-updated'(ns: SettingsNamespace, revision: number): void ``` -Source: [`packages/settings/settings/src/index.ts:170`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/types.ts:48`](../../packages/settings/settings/src/types.ts) @@ -306,5 +306,5 @@ Committed change to one registered namespace's resolved value. Emitted after the 'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void ``` -Source: [`packages/settings/settings/src/index.ts:157`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/types.ts:35`](../../packages/settings/settings/src/types.ts) diff --git a/docs/subsystems/settings.zh.md b/docs/subsystems/settings.zh.md index 2b9f89d54f..206c305059 100644 --- a/docs/subsystems/settings.zh.md +++ b/docs/subsystems/settings.zh.md @@ -252,7 +252,7 @@ async replace(ns: SettingsNamespace, section: object, expectedRevision?: number) async mutate(ns: SettingsNamespace, ops: readonly SettingsPathOp[], expectedRevision?: number): Promise ``` -Source: [`packages/settings/settings/src/index.ts:387`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/index.ts:350`](../../packages/settings/settings/src/index.ts) @@ -279,7 +279,7 @@ One registered namespace's RAW user section changed, whether or not the resolved 'settings/document-updated'(ns: SettingsNamespace, revision: number): void ``` -Source: [`packages/settings/settings/src/index.ts:170`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/types.ts:48`](../../packages/settings/settings/src/types.ts) @@ -306,5 +306,5 @@ Committed change to one registered namespace's resolved value. Emitted after the 'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void ``` -Source: [`packages/settings/settings/src/index.ts:157`](../../packages/settings/settings/src/index.ts) +Source: [`packages/settings/settings/src/types.ts:35`](../../packages/settings/settings/src/types.ts) diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index aa7ab3aafc..1cc0814d1d 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.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/typert.md -typert.md: 3c593952687a76089fcd6d66958c282294ae5abd -typert.zh.md: 5d9c987de8c6d54394ccd1165ad1d430d41a31e5 +typert.md: d279bcb1c0f20f75a82f89e8bfab731f82779729 +typert.zh.md: 4153cf8b6ebec65e0f9391911f9a0e9e0f869a63 diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index 3c59395268..d279bcb1c0 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -199,6 +199,27 @@ interface TypeRTClientRemote extends TypeRTRemoteNamespaceMap { * @returns disposer after namespace services and concrete methods are ready. */ $mount(contribution: TypeRTRemoteContribution): Promise + /** + * Subscribe to one forwarded Host event; delivery is one-way, in registration + * order, and isolates a throwing listener from the rest. + * @template Event - forwarded event name selected by the Host assembly. + * @param event - forwarded Host event name, unchanged on the wire. + * @param listener - receives the Host's argument list as declared by Cordis `Events`. + * @returns disposer owned by the calling fiber. + */ + $on(event: Event, listener: Events[Event]): () => void + /** + * Hand one decoded forwarded frame to the subscription table. The carrier + * owning the Host frame sink calls this; a consumer subscribes with + * {@link TypeRTClientRemote.$on} and never calls it. + * + * `event` is a plain string because this is the wire boundary: the name is + * whatever the Host assembly's allowlist selected, and one nobody subscribed + * to is dropped silently. + * @param event - forwarded Host event name, exactly as the Host emitted it. + * @param args - the Host argument list, already JSON-decoded. + */ + $dispatch(event: string, args: readonly unknown[]): void } ``` diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index 5d9c987de8..4153cf8b6e 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -199,6 +199,27 @@ interface TypeRTClientRemote extends TypeRTRemoteNamespaceMap { * @returns disposer after namespace services and concrete methods are ready. */ $mount(contribution: TypeRTRemoteContribution): Promise + /** + * Subscribe to one forwarded Host event; delivery is one-way, in registration + * order, and isolates a throwing listener from the rest. + * @template Event - forwarded event name selected by the Host assembly. + * @param event - forwarded Host event name, unchanged on the wire. + * @param listener - receives the Host's argument list as declared by Cordis `Events`. + * @returns disposer owned by the calling fiber. + */ + $on(event: Event, listener: Events[Event]): () => void + /** + * Hand one decoded forwarded frame to the subscription table. The carrier + * owning the Host frame sink calls this; a consumer subscribes with + * {@link TypeRTClientRemote.$on} and never calls it. + * + * `event` is a plain string because this is the wire boundary: the name is + * whatever the Host assembly's allowlist selected, and one nobody subscribed + * to is dropped silently. + * @param event - forwarded Host event name, exactly as the Host emitted it. + * @param args - the Host argument list, already JSON-decoded. + */ + $dispatch(event: string, args: readonly unknown[]): void } ``` diff --git a/knip.json b/knip.json index 11b25e3f80..1f3dcefea8 100644 --- a/knip.json +++ b/knip.json @@ -126,6 +126,9 @@ "project": [ "src/**/*.ts", "tests/**/*.ts" + ], + "ignoreDependencies": [ + "@deepseek-ai/dsh-api-gateway" ] }, "packages/client/ui-primitives": { @@ -653,18 +656,11 @@ }, "packages/client/ui-settings": { "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.spec.tsx" + "tests/**/*.spec.ts" ], "project": [ "src/**/*.ts", - "src/**/*.tsx", - "tests/**/*.ts", - "tests/**/*.tsx" - ], - "ignoreDependencies": [ - "@types/react-dom", - "react-dom" + "tests/**/*.ts" ] }, "apps/web": { diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 8e1e98774a..4782fc1aa8 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/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/gateway/README.md -README.md: 0e1a03d2016b8cfbe165dbf1b0a9802290b29502 -README.zh.md: f8c01b489f51fb5e78b608dd9c24a36c7bc64c3a +README.md: 96f55eead6aec50e2f39f5bcefe71b51cc853298 +README.zh.md: 8985fd1f57833bc05f045e72135bb69a5a198d50 diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index ffdec7ac4d..96f55eead6 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -20,7 +20,7 @@ A cancellation-aware Remote method declares `signal: AbortSignal` as its final H Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before calling Connection. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. -`ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. Delivery is one-way and follows registration order; a listener that throws is logged and isolated from the remaining listeners, which never affects the frame pump. The delivery port is not part of the Remote contract: the Client half owning the Host frame sink emits the internal `remote/host-event` Cordis event, the Remote service is its only subscriber, and an event name nobody subscribes to is dropped, since the wire carries whatever the Host selected. +`ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. Delivery is one-way and follows registration order; a listener that throws is logged and isolated from the remaining listeners, which never affects the frame pump. `ctx.remote.$dispatch()` is the other half of that surface, and it is the carrier's: the Client half owning the Host frame sink hands each decoded frame over, and an event name nobody subscribes to is dropped, since the wire carries whatever the Host selected. A consumer subscribes and never calls it. Generated declaration merges provide the TypeScript API through the shared `TypeRTClientRemote` contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 0137eb77db..8985fd1f57 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -20,7 +20,7 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle 每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 -`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属发起调用的 fiber,并随该 fiber 一起消失。投递是单向的,并按注册顺序进行;抛错的 listener 会被记录并与其余 listener 隔离,绝不影响帧泵。投递口不属于 Remote 约定:持有 Host 帧 sink 的 Client 半发射内部 Cordis 事件 `remote/host-event`,Remote 服务是它唯一的订阅方,收到无人订阅的事件名即丢弃,因为 wire 上出现什么取决于 Host 的转发选择。 +`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属发起调用的 fiber,并随该 fiber 一起消失。投递是单向的,并按注册顺序进行;抛错的 listener 会被记录并与其余 listener 隔离,绝不影响帧泵。`ctx.remote.$dispatch()` 是该面的另一半,且属于载体:持有 Host 帧 sink 的 Client 半把每个解码后的帧交进来,收到无人订阅的事件名即丢弃,因为 wire 上出现什么取决于 Host 的转发选择。消费方只订阅,绝不调用它。 生成的声明合并通过共享的 `TypeRTClientRemote` 约定提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index 00df34f9b4..c064a3eef9 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -75,16 +75,24 @@ export function apply(ctx: Context): void { /** One subscribed listener after `$on` erased its per-event argument list. */ type RemoteEventListener = (...args: never[]) => void +/** + * One subscription, identified by the registration rather than by its listener: + * two fibers may subscribe the same function object to the same event, and each + * disposer must retire only its own registration. + */ +interface RemoteEventSubscription { + readonly listener: RemoteEventListener +} + class ClientRemoteService extends Service implements TypeRTClientRemote { private readonly ownerCtx: Context private readonly namespaces = new Map() - private readonly subscriptions = new Map>() + private readonly subscriptions = new Map() private mutations = Promise.resolve() constructor(ctx: Context) { super(ctx, 'remote') this.ownerCtx = ctx - ctx.on('remote/host-event', (event, args) => { this.dispatch(event, args) }) ctx.effect(() => () => { this.subscriptions.clear() }, 'api-gateway.client.subscriptions') } @@ -105,37 +113,51 @@ class ClientRemoteService extends Service implements TypeRTClientRemote { // The table is keyed by the runtime event name, so the argument list this // signature pins per event cannot survive in it; `$deliver` restores it // from the frame the Host emitted for that same name. - const erased: RemoteEventListener = listener + const subscription: RemoteEventSubscription = { listener } const owned = this.ctx.effect(() => { const listeners = this.listeners(event) - listeners.add(erased) - return () => { listeners.delete(erased) } + listeners.push(subscription) + return () => { + const at = listeners.indexOf(subscription) + if (at >= 0) listeners.splice(at, 1) + } }, `api-gateway.client.$on(${JSON.stringify(event)})`) return () => { void owned() } } /** - * Deliver one forwarded event in registration order, isolating a throwing - * listener; an event name nobody subscribes to is dropped, since the wire - * carries whatever the Host forwarding allowlist selected. + * Deliver one forwarded event in registration order, isolating a listener + * that fails either synchronously or by rejecting a returned promise; see + * {@link TypeRTClientRemote.$dispatch} for the caller contract. */ - private dispatch(event: string, args: readonly unknown[]): void { + $dispatch(event: string, args: readonly unknown[]): void { const listeners = this.subscriptions.get(event) if (listeners === undefined) return - for (const listener of listeners) { - try { - listener(...args as never[]) - } catch (error) { + // Snapshot: a listener may subscribe or dispose during delivery, and this + // round's recipients are the ones registered when the frame arrived. + for (const { listener } of [...listeners]) { + const report = (error: unknown): void => { console.error(`client api: Remote event ${JSON.stringify(event)} listener threw:`, error) } + try { + /* oxlint-disable-next-line typescript/no-confusing-void-expression -- + * The declared return is void, so nobody awaits an async listener; the + * runtime value is still a promise, and reading it is the only way to + * keep its rejection inside this containment instead of surfacing as an + * unhandled one. */ + const settled: unknown = listener(...args as never[]) + if (settled instanceof Promise) settled.catch(report) + } catch (error) { + report(error) + } } } - /** Subscription set for one event name; empty sets are retained, bounded by the Host's selection. */ - private listeners(event: string): Set { + /** Subscriptions for one event name; empty arrays are retained, bounded by the Host's selection. */ + private listeners(event: string): RemoteEventSubscription[] { let listeners = this.subscriptions.get(event) if (listeners === undefined) { - listeners = new Set() + listeners = [] this.subscriptions.set(event, listeners) } return listeners diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index a2c0c980cf..3c9a1b5ff4 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -625,11 +625,11 @@ describe('Client TypeRT API', () => { )) await subscriber - ctx.emit('remote/host-event', 'fixture/changed', ['settings']) + ctx.remote.$dispatch('fixture/changed', ['settings']) expect(seen).toEqual(['settings']) await subscriber.dispose() - ctx.emit('remote/host-event', 'fixture/changed', ['after fiber disposal']) + ctx.remote.$dispatch('fixture/changed', ['after fiber disposal']) expect(seen).toEqual(['settings']) await client.dispose() @@ -645,7 +645,7 @@ describe('Client TypeRT API', () => { }) ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) try { - ctx.emit('remote/host-event', 'fixture/changed', ['credentials']) + ctx.remote.$dispatch('fixture/changed', ['credentials']) expect(seen).toEqual(['credentials']) expect(consoleError).toHaveBeenCalledWith( @@ -653,7 +653,7 @@ describe('Client TypeRT API', () => { expect.any(Error), ) disposeFirst() - ctx.emit('remote/host-event', 'fixture/changed', ['commands']) + ctx.remote.$dispatch('fixture/changed', ['commands']) expect(seen).toEqual(['credentials', 'commands']) expect(consoleError).toHaveBeenCalledTimes(1) } finally { @@ -661,9 +661,62 @@ describe('Client TypeRT API', () => { } }) - it('exposes subscription as the only forwarded-event verb', () => { + it('contains an async listener whose promise rejects', async () => { + const ctx = await bench(vi.fn()) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const seen: string[] = [] + // The declared return is void, so nobody awaits an async listener: the + // rejection has to be contained here or it escapes as an unhandled one. + /* oxlint-disable-next-line typescript/no-misused-promises -- + * Deliberately the shape the contract does not invite: `$on` declares a void + * listener, and this pins what the service does when a caller hands it an + * async one anyway. */ + ctx.remote.$on('fixture/changed', () => Promise.reject(new Error('fixture async failure'))) + ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) + try { + ctx.remote.$dispatch('fixture/changed', ['credentials']) + await Promise.resolve() + await Promise.resolve() + + expect(seen).toEqual(['credentials']) + expect(consoleError).toHaveBeenCalledWith( + 'client api: Remote event "fixture/changed" listener threw:', + expect.any(Error), + ) + } finally { + consoleError.mockRestore() + } + }) + + it('retires only its own registration when one listener subscribes twice', async () => { + const ctx = await bench(vi.fn()) + const seen: string[] = [] + // One function object, two registrations. A table keyed by listener identity + // stores it once, so the first frame would reach it once instead of twice + // and either disposer would silence both. + const listener = (namespace: string): void => { seen.push(namespace) } + const disposeFirst = ctx.remote.$on('fixture/changed', listener) + ctx.remote.$on('fixture/changed', listener) + + ctx.remote.$dispatch('fixture/changed', ['both']) + expect(seen).toEqual(['both', 'both']) + + // The surviving registration keeps receiving after its twin retires. + disposeFirst() + ctx.remote.$dispatch('fixture/changed', ['survivor']) + expect(seen).toEqual(['both', 'both', 'survivor']) + + // Disposing twice is inert: the record is already gone, so the second call + // must not splice the surviving twin out from under its own owner. + disposeFirst() + ctx.remote.$dispatch('fixture/changed', ['still here']) + expect(seen).toEqual(['both', 'both', 'survivor', 'still here']) + }) + + it('separates the consumer verb from the carrier handoff', () => { expectTypeOf().toHaveProperty('$on') - expectTypeOf().not.toHaveProperty('$dispatch') + // The carrier owning the frame sink calls this; a consumer subscribes instead. + expectTypeOf().toHaveProperty('$dispatch') }) it('drops a forwarded event nobody subscribes to', async () => { @@ -671,7 +724,7 @@ describe('Client TypeRT API', () => { const seen: string[] = [] ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) - ctx.emit('remote/host-event', 'fixture/idle', [1]) + ctx.remote.$dispatch('fixture/idle', [1]) expect(seen).toEqual([]) }) diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index dd8e3d4891..5492938176 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: 567ece0fb58d4b9c0b022dd2ce4d8ee87caccc57 -README.zh.md: 12add6f8efc5b6af3e9b74b26a1abb2bb3936e0a +README.md: 34c78caf985c93137e7369329302ffd5feb67595 +README.zh.md: 4fff19673f10b0d173c9e3f724ca205845a0b9ed diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 417937c2c3..78f83cb48d 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -16,10 +16,9 @@ export type {} from '@deepseek-ai/dsh-commands/types' export type {} from '@deepseek-ai/dsh-credentials/types' export type {} from '@deepseek-ai/dsh-settings/types' /** - * The Gateway Client face's own declaration merges, type-only: the internal - * `remote/host-event` delivery event a carrier owner emits and the Remote - * service subscribes to. Erased at emit, so this facade still carries no - * runtime edge to the Gateway implementation. + * The Gateway Client face's own declaration merges, type-only: `ctx.remote` and + * with it the `$on`/`$dispatch` surface. Erased at emit, so this facade still + * carries no runtime edge to the Gateway implementation. */ export type {} from '@deepseek-ai/dsh-api-gateway/client' diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index 1d592f49f9..705c8ee573 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -1,7 +1,7 @@ /** Host BFF entry and Loader shell for the Remote contribution assembly. */ import type { TypeRTForwardableEvent } from '@deepseek-ai/dsh-type-meta' -import { API_REMOTE_FORWARDED_EVENTS } from './types.ts' +import { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts' // The owner packages' client-safe `./types` exports carry the cordis `Events` // declarations for every allowlisted event. Pulling them into this face is what @@ -24,7 +24,7 @@ export type { ApiRemoteAgentResult, ApiRemoteLookupError, } from './agent-lookup.ts' -export { API_REMOTE_FORWARDED_EVENTS } from './types.ts' +export { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts' export type { ApiRemoteForwardedEvent } from './types.ts' // Shape gate over the allowlist, kept in the Host face because the Host's event diff --git a/packages/api/remotes/src/invariant.ts b/packages/api/remotes/src/invariant.ts index 1e0598a423..f93b63e98b 100644 --- a/packages/api/remotes/src/invariant.ts +++ b/packages/api/remotes/src/invariant.ts @@ -1,8 +1,8 @@ /** Package-owned invariant companion for `@deepseek-ai/dsh-api-remotes`. */ +/* jscpd:ignore-start */ import type { Context } from '@deepseek-ai/cordis' -import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' -import { API_REMOTE_FORWARDED_EVENTS } from './types.ts' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-api-remotes' @@ -11,38 +11,8 @@ export const name = 'api-remotes-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** The allowlist as a lookup over the live dispatch stream's plain event names. */ -const FORWARDED_EVENTS: ReadonlySet = new Set(API_REMOTE_FORWARDED_EVENTS) - -/** - * Judge one observed dispatch of an allowlisted event against what verbatim - * forwarding can carry. The Host face's `TypeRTForwardableEvent` assertion - * judges each name's DECLARED signature; only the dispatch stream shows how a - * producer actually emitted it, and neither deviation below is visible to the - * compiler. A Scope carrier would be silently dropped on the way to a consumer - * because `ctx.remote.$on` has no scoped form, and a waterfall or bail dispatch - * expects a return value that a one-way carrier can never deliver back. - * @param mode - dispatch mode reported by the event bus. - * @param event - dispatched event name. - * @param carrier - the dispatch `this`; `null` when the event is unscoped. - * @param fail - reporter bound to this package. - */ -function validateDispatch(mode: string, event: string, carrier: unknown, fail: InvariantFailure): void { - if (!FORWARDED_EVENTS.has(event)) return - if (carrier !== null) { - fail(`forwarded host event "${event}" was dispatched with a Scope carrier, which consumers can never receive`) - } - if (mode !== 'emit') { - fail(`forwarded host event "${event}" was dispatched as "${mode}", but forwarding to consumers is one-way`) - } -} - -/** Install the forwarded-event dispatch-shape check over the live event bus. */ -const install: InvariantInstaller = (ctx, fail) => { - ctx.on('internal/dispatch', (mode, event, _args, thisArg) => { - validateDispatch(mode, event, thisArg, fail) - }, { global: true }) -} +/** No runtime invariant: TypeRT and the Agent/Session registries own the observed relationships. */ +const install: InvariantInstaller = () => {} /** * Register this package's invariant companion. @@ -51,3 +21,4 @@ const install: InvariantInstaller = (ctx, fail) => { */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/api/remotes/src/remote-events.ts b/packages/api/remotes/src/remote-events.ts new file mode 100644 index 0000000000..f8137b72cf --- /dev/null +++ b/packages/api/remotes/src/remote-events.ts @@ -0,0 +1,21 @@ +/** + * The one home of this application's forwarded-Host-event allowlist. Both + * compiler faces list this file, so the Host forwarding loop and the consumer + * `ctx.remote.$on` key face read one declaration instead of two copies that + * could drift; `./types.ts` derives the type projection from it and stays + * type-only. + */ + +/** + * Host events this application forwards to consumers verbatim: no projection, + * no redaction, no renaming. The wire name is the Host cordis event name and + * the payload is its argument list, so this array is simultaneously the whole + * control point over what a consumer can receive and the legal key set of + * `ctx.remote.$on`. Forwarding one more event is an entry here and nothing + * else. + */ +export const API_REMOTE_FORWARDED_EVENTS = [ + 'commands/change', + 'credentials/updated', + 'settings/document-updated', +] as const diff --git a/packages/api/remotes/src/types.ts b/packages/api/remotes/src/types.ts index ec0aa8a01b..8d92903848 100644 --- a/packages/api/remotes/src/types.ts +++ b/packages/api/remotes/src/types.ts @@ -1,25 +1,15 @@ /** - * The one home of this application's forwarded-Host-event allowlist, listed in - * `tsconfig.host.json` AND `tsconfig.client.json` so the Host forwarding loop - * and the consumer `ctx.remote.$on` key face read the same declaration instead - * of two copies that could drift. + * Type face of the forwarded-Host-event allowlist: the consumer key projection + * and the selection seat it fills. The allowlist VALUE lives in + * `./remote-events.ts`, keeping this module type-only per the package + * convention; both compiler faces list both files, so the Host forwarding loop + * and the consumer `ctx.remote.$on` key face read one declaration instead of + * two copies that could drift. * * @module @deepseek-ai/dsh-api-remotes/types */ -/** - * Host events this application forwards to consumers verbatim: no projection, - * no redaction, no renaming. The wire name is the Host cordis event name and - * the payload is its argument list, so this array is simultaneously the whole - * control point over what a consumer can receive and the legal key set of - * `ctx.remote.$on`. Forwarding one more event is an entry here and nothing - * else. - */ -export const API_REMOTE_FORWARDED_EVENTS = [ - 'commands/change', - 'credentials/updated', - 'settings/document-updated', -] as const +import type { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts' /** Type projection of the allowlist; the consumer and the Host read this one. */ export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number] diff --git a/packages/api/remotes/tests/invariant.spec.ts b/packages/api/remotes/tests/invariant.spec.ts deleted file mode 100644 index 1037035a52..0000000000 --- a/packages/api/remotes/tests/invariant.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import { credentialRef } from '@deepseek-ai/dsh-credentials' -import InvariantService from '@deepseek-ai/dsh-invariants' -import { settingsNamespace } from '@deepseek-ai/dsh-settings' -import { API_REMOTE_FORWARDED_EVENTS } from '@deepseek-ai/dsh-api-remotes' -import type { ApiRemoteForwardedEvent } from '@deepseek-ai/dsh-api-remotes' -import * as ApiRemotesInvariant from '@deepseek-ai/dsh-api-remotes/invariant' - -async function setup(): Promise { - const ctx = new Context() - await ctx.plugin(InvariantService) - await ctx.plugin(ApiRemotesInvariant) - return ctx -} - -/** - * One legal emission per allowlisted event. `Events` types each emit by name, - * so the three arities (0, 2, 1) cannot share a single loop body; keying the - * table by {@link ApiRemoteForwardedEvent} makes the compiler reject it as soon - * as the allowlist grows, which keeps "every listed event is exercised" true - * without a single argument-list assertion. - */ -const legalEmission: Record void> = { - 'commands/change': ctx => { ctx.emit('commands/change') }, - 'credentials/updated': ctx => { ctx.emit('credentials/updated', credentialRef('DEMO_TOKEN')) }, - 'settings/document-updated': ctx => { - ctx.emit('settings/document-updated', settingsNamespace('demo'), 1) - }, -} - -describe('forwarded host event invariants', () => { - it('accepts an unscoped one-way dispatch of every allowlisted event', async () => { - const ctx = await setup() - for (const event of API_REMOTE_FORWARDED_EVENTS) { - expect(() => { legalEmission[event](ctx) }).not.toThrow() - } - }) - - it('ignores an owner package event the allowlist does not select', async () => { - const ctx = await setup() - // `settings/updated` is the resolved-value event, deliberately left out of - // the allowlist while its sibling `settings/document-updated` is in it, so - // this pins that the check discriminates by name rather than by owner. - expect(() => { - ctx.emit('settings/updated', settingsNamespace('demo'), { a: 1 }, { a: 2 }, 'update') - }).not.toThrow() - // The carrier that fails an allowlisted event must pass unremarked here. - expect(() => { - ctx.emit({}, 'settings/updated', settingsNamespace('demo'), { a: 1 }, { a: 2 }, 'update') - }).not.toThrow() - }) - - it('rejects an allowlisted event dispatched with a Scope carrier', async () => { - const ctx = await setup() - expect(() => { ctx.emit({}, 'commands/change') }) - .toThrow(/"commands\/change" was dispatched with a Scope carrier/) - }) - - it('rejects an allowlisted event dispatched as anything but one-way', async () => { - const ctx = await setup() - expect(() => { ctx.bail('commands/change') }) - .toThrow(/"commands\/change" was dispatched as "bail"/) - }) -}) diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index d537747e77..c02e98656a 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -7,6 +7,7 @@ }, "files": [ "src/client/index.ts", + "src/remote-events.ts", "src/types.ts" ], "references": [ diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index 0f7fd354d3..fbdfb6a94c 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -9,6 +9,7 @@ "src/agent-lookup.ts", "src/index.ts", "src/invariant.ts", + "src/remote-events.ts", "src/types.ts" ], "references": [ diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 6415c26961..0b8c0a6c7e 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -33,7 +33,9 @@ "client": { "inject": [ "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-client-runtime" + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-api-remotes" ], "platform": "web", "immediately": true @@ -41,17 +43,22 @@ }, "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "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/cordis": "^4.0.0-rc.7", + "@deepseek-ai/dsh-api-remotes": "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:^", "@types/react": "~18.3.1", diff --git a/packages/client/locale/src/client/LanguageRow.tsx b/packages/client/locale/src/client/LanguageRow.tsx index febf732792..12da219572 100644 --- a/packages/client/locale/src/client/LanguageRow.tsx +++ b/packages/client/locale/src/client/LanguageRow.tsx @@ -7,7 +7,7 @@ import { useState } from 'react' import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' -import type {} from './settings-contract.ts' +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type { createLanguageRowStore } from './settings-store.ts' import css from './LanguageRow.module.css' diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index 3eea9eede6..ede3ccc30b 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -13,9 +13,11 @@ import type { Context } from '@deepseek-ai/cordis' import { type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS, } from '@deepseek-ai/dsh-client-ui-slots' -import { - bindSettingsScope, type ClientContext, type SettingsScope, -} from '@deepseek-ai/dsh-client-runtime/client' +import type { ClientContext, SettingsScope } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: the ctx.settingsScope Context merge and the settings slot types. +// Cross-plugin collaboration goes through the service, never a value import +// (client bundle purity gate). +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import { LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, type LocaleId, type LocaleSettings, } from '../locale-settings.ts' @@ -29,7 +31,6 @@ import { createLanguageRowStore } from './settings-store.ts' export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx' export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts' -export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts' export type { CommonKey } from '../locales/index.ts' export type { LocaleId, LocaleSettings } from '../locale-settings.ts' @@ -343,7 +344,7 @@ function detectBrowserLocale(): LocaleId | undefined { } /** Required services: slot registration plus the settings transport. */ -export const inject = ['slots', 'connection'] +export const inject = ['slots', 'connection', 'remote', 'settingsScope'] /** * Client plugin body: provide the locale service with base dictionaries and @@ -352,7 +353,7 @@ export const inject = ['slots', 'connection'] * @param ctx - client cordis context. */ export function apply(ctx: ClientContext): void { - const host = bindSettingsScope(ctx, { namespace: LOCALE_SETTINGS_NAMESPACE }) + const host = ctx.settingsScope.bind({ namespace: LOCALE_SETTINGS_NAMESPACE }) const locale = new LocaleService(ctx, host) locale.register(COMMON_NS, { zh, en }) locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn }) diff --git a/packages/client/locale/src/client/settings-contract.ts b/packages/client/locale/src/client/settings-contract.ts deleted file mode 100644 index e032707645..0000000000 --- a/packages/client/locale/src/client/settings-contract.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * The `settings.general.item` slot type — one preference row inside the - * settings General section, contributed by the feature plugin that owns the - * preference (locale → Language, ui-theme → Appearance). Options: `id` (row - * key), `order` (row position). Rows draw their own internals (row layout, - * separators via CSS); the section column only stacks them. - * - * TYPE HOME RATIONALE: the slot is declared at runtime by - * ui-settings-general's General entry, but its type lives here — this - * package is the common dependency of every item registrant (any settings - * row carries copy, so every registrant already depends on locale), whereas - * the declarer's own contract is unreachable for locale/ui-theme without a - * reference cycle. - */ -declare module '@deepseek-ai/dsh-client-ui-slots' { - interface SlotMap { - /** One preference row inside the settings General section (see module JSDoc). */ - 'settings.general.item': { kind: 'list'; scope: 'root'; owner: SettingsGeneralItemOwnerProps } - } -} - -/** Owner share of a General preference row (the section supplies nothing). */ -export interface SettingsGeneralItemOwnerProps { - /** Marker field: item owner props are intentionally empty. */ - children?: never -} diff --git a/packages/client/locale/tests/apply.spec.ts b/packages/client/locale/tests/apply.spec.ts index c82e7c7cb3..92e567893b 100644 --- a/packages/client/locale/tests/apply.spec.ts +++ b/packages/client/locale/tests/apply.spec.ts @@ -4,6 +4,8 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { SettingsScopeService } from '@deepseek-ai/dsh-client-ui-settings/client' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject, SETTINGS_NS, } from '@deepseek-ai/dsh-client-locale/client' @@ -43,6 +45,9 @@ async function bench() { } }) ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback: true } as never) + // The settings transport and the forwarded-event port the plugin injects. + new TestRemote(ctx) + await ctx.plugin(SettingsScopeService).await() return { ctx, slots: ctx.get('slots') as SlotsService, describe, mutate, setHostPreference: (next: string | undefined) => { preference = next; revision += 1 }, @@ -79,7 +84,7 @@ describe('locale apply', () => { }) it('declares the slot service', () => { - expect(inject).toEqual(['slots', 'connection']) + expect(inject).toEqual(['slots', 'connection', 'remote', 'settingsScope']) }) it('provides the service with base + settings dictionaries and registers the row (declaration before or after apply)', async () => { @@ -134,10 +139,10 @@ describe('locale apply', () => { const locale = b.ctx.get('locale') as LocaleService await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') }) b.setHostPreference(undefined) - b.ctx.emit('settings/changed', LOCALE_SETTINGS_NAMESPACE) + b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') }) b.setHostPreference('en') - b.ctx.emit('settings/changed', LOCALE_SETTINGS_NAMESPACE) + b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') }) expect(b.describe).toHaveBeenCalledTimes(3) }) diff --git a/packages/client/locale/tests/invariant.spec.ts b/packages/client/locale/tests/invariant.spec.ts index c55efa9a12..7f1c51c008 100644 --- a/packages/client/locale/tests/invariant.spec.ts +++ b/packages/client/locale/tests/invariant.spec.ts @@ -6,6 +6,7 @@ import { apply as clientApply, COMMON_NS, LocaleService, inject } from '@deepsee import * as LocaleInvariant from '@deepseek-ai/dsh-client-locale/invariant' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import InvariantService from '@deepseek-ai/dsh-invariants' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' describe('invariant companion', () => { it('registers under the package name with an empty installer', async () => { @@ -20,10 +21,13 @@ describe('invariant companion', () => { it('client apply provides ctx.locale seeded with the zh/en common namespace', async () => { // The feature registers its own Language settings row, hence the slots edge. - expect(inject).toEqual(['slots', 'connection']) + expect(inject).toEqual(['slots', 'connection', 'remote', 'settingsScope']) const ctx = new Context() new SlotsService(ctx) ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + // The settings row's transport and the forwarded-event port. + ctx.provide('remote', { $on: () => () => {} } as never) + ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) await ctx.plugin({ inject, apply: clientApply }).await() const locale = ctx.get('locale') expect(locale).toBeInstanceOf(LocaleService) diff --git a/packages/client/locale/tsconfig.json b/packages/client/locale/tsconfig.json index 313c11f5bf..d0ecc2710e 100644 --- a/packages/client/locale/tsconfig.json +++ b/packages/client/locale/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../ui-settings" } ] } diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 86d51cd7a3..f57f9e4952 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -33,7 +33,8 @@ "client": { "inject": [ "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-typert-registry" + "@deepseek-ai/dsh-typert-registry", + "@deepseek-ai/dsh-api-gateway" ], "platform": "web", "immediately": true @@ -44,7 +45,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-schema-form": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", @@ -59,19 +59,20 @@ "zustand": "~4.4.7" }, "peerDependencies": { + "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^" + "@types/react": "~18.3.1" }, "files": [ "lib/index.js", diff --git a/packages/client/runtime/src/client/contract/settings-scope.ts b/packages/client/runtime/src/client/contract/settings-scope.ts new file mode 100644 index 0000000000..4e4f583d87 --- /dev/null +++ b/packages/client/runtime/src/client/contract/settings-scope.ts @@ -0,0 +1,63 @@ +/** + * The settings-namespace scope contract. The type lives here, in the common + * dependency of every feature that owns a preference, while the implementation + * and its Host transport live with the Settings surface + * (`dsh-client-ui-settings`): a feature service accepts a scope through + * `attachSettings` without depending on the surface that binds it, which would + * otherwise close a reference cycle. + */ + +/** Client-side sync state of one settings namespace. */ +export interface SettingsScopeSnapshot { + /** + * `loading` until the first accepted section, `ready` while one stands, and + * `unavailable` when the namespace is not exposed to this client or the + * connection keeps preferences process-local (memory mode). + */ + status: 'loading' | 'ready' | 'unavailable' + /** Last accepted schema-resolved section; undefined before the first acceptance. */ + value: T | undefined + /** Namespace revision fencing the next write; undefined before the first Host view. */ + revision: number | undefined + /** Whether the Host document accepts writes; memory mode never does. */ + writable: boolean + /** `host` syncs with the Host document; `memory` keeps a remote browser process-local. */ + mode: 'host' | 'memory' +} + +/** Domain-owned description of one settings namespace consumed by a browser plugin. */ +export interface SettingsScopeSpec { + /** Settings namespace registered by the owning Host plugin. */ + namespace: string + /** + * Narrow one wire section; undefined keeps the last accepted value. The + * default validates the section against the namespace's own serialized wire + * schema, so domains add a decoder only to narrow beyond that schema. + */ + decode?: (section: unknown) => T | undefined +} + +/** + * Reactive owner handle over one namespace's durable section — the browser + * mirror of the Host-side `SettingsScope` owner seam. Domain services read + * and observe the snapshot and route explicit user choices through `set`. + */ +export interface SettingsScope { + /** @returns the current sync snapshot (stable reference until the next change). */ + getSnapshot(): SettingsScopeSnapshot + /** + * Observe snapshot replacements. + * @param listener - invoked after each snapshot change. + * @returns the disposer removing this listener. + */ + subscribe(listener: () => void): () => void + /** + * Queue one field write. Rapid writes preserve mutation order, each carries + * the latest known namespace revision, and only the latest settlement may + * publish; a rejected or failed latest write reloads Host state instead. + * @param field - scalar field inside the namespace section. + * @param value - JSON-shaped value selected by the user. + * @returns settlement after the write and any latest-write recovery read. + */ + set(field: string, value: unknown): Promise +} diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 5bba2ff7fc..0d8671eb4f 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -1,6 +1,10 @@ /** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */ import type { Context } from '@deepseek-ai/cordis' import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' +// Type-only: the ctx.remote merge. Deliberately the gateway's Client half rather +// than api-remotes': that face imports a Host-tsdown-generated artifact, and this +// project sits in the Host build graph. +import type {} from '@deepseek-ai/dsh-api-gateway/client' import type { TypeRTContext } from '@deepseek-ai/dsh-type-meta' import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from './slots.ts' @@ -42,9 +46,12 @@ export type { SessionProvideChannelHost } from './sessions/provide.ts' export { createScope } from './agents/scope.ts' export type { AgentScopeHandle } from './agents/scope.ts' export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts' -export { bindSettingsScope, SettingsScopeController } from './settings-scope.ts' -export type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './settings-scope.ts' export { resolveWorkspacePath } from './workspaces/path.ts' +// Contract only: the scope implementation and its Host transport belong to +// dsh-client-ui-settings (see that package's settings-scope.ts). +export type { + SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec, +} from './contract/settings-scope.ts' export type { Session } from './sessions/session.ts' export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts' export type { AgentContext, ISessions } from './contract/sessions.ts' @@ -190,7 +197,7 @@ declare module '@deepseek-ai/cordis' { } /** Required services: the wire handle and Client TypeRT registry. */ -export const inject = ['connection', 'typert'] +export const inject = ['connection', 'typert', 'remote'] /** Mounts the browser runtime services and connection stream. * @param ctx - Client Cordis context. @@ -219,11 +226,11 @@ export function apply(ctx: Context): void { sessions.handleHostEnvelope(envelope) workspaces.handleHostEnvelope(envelope) // Forwarded-event bridge: the session layer ignores registry frames (no - // session routing). This plugin only carries the frame onto the internal - // `remote/host-event` plumbing event; the Remote service subscribes there - // and fans out to `ctx.remote.$on`, so no consumer reads a frame. + // session routing). This plugin owns the frame sink, so it hands the + // decoded frame straight to the Remote service, which fans it out to + // `ctx.remote.$on` subscribers; no consumer reads a frame. const frame = envelope.payload - if (frame.type === 'host/remote-event') ctx.emit('remote/host-event', frame.event, frame.args) + if (frame.type === 'host/remote-event') ctx.remote.$dispatch(frame.event, frame.args) else if (frame.type === 'host/session-preset-changed') { ctx.emit('session/preset-changed', frame.sessionId, frame.agentPreset) } diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index 4c2550384c..ca47db2a0b 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -1,7 +1,7 @@ /** - * Wire-to-typed-event bridge: a `host/remote-event` frame is republished - * verbatim on the internal `remote/host-event` plumbing event (the Remote - * service's fan-out to `ctx.remote.$on` is api-gateway's own coverage); + * Wire-to-typed-event bridge: a `host/remote-event` frame is handed verbatim to + * the Remote service's `$dispatch` (its fan-out to `ctx.remote.$on` is + * api-gateway's own coverage); * host/session-preset-changed → ctx 'session/preset-changed'; * `host/models-changed` still broadcasts the typed `models/changed`; each * established connection generation → ctx 'connection/reset' (the forced @@ -43,13 +43,20 @@ void forwardedEventContracts interface Bench { ctx: Context sinks: ConnectionSinks | undefined + /** Every `$dispatch` the runtime made, as `[event, ...args]`. */ + dispatched: unknown[][] } async function mount(): Promise { const ctx = new Context() await ctx.plugin(TypertRegistry) const api = new FakeApiClient() - const bench: Bench = { ctx, sinks: undefined } + const bench: Bench = { ctx, sinks: undefined, dispatched: [] } + // Stands in for api-gateway's Remote service: this spec owns the carrier's + // handoff, not the fan-out behind it. + ctx.reflect.provide('remote', { + $dispatch: (event: string, args: readonly unknown[]) => { bench.dispatched.push([event, ...args]) }, + }) const handle: ConnectionHandle = { api, isLoopback: true, @@ -69,8 +76,7 @@ async function mount(): Promise { describe('wire event bridge', () => { it('republishes a forwarded host event verbatim, and routes no other host frame there', async () => { const bench = await mount() - const seen: unknown[][] = [] - bench.ctx.on('remote/host-event', (event, args) => { seen.push([event, ...args]) }) + const seen = bench.dispatched bench.sinks?.onHostEnvelope?.({ rpcId: 'r1' as never, payload: { type: 'host/remote-event', event: 'commands/change', args: [] }, @@ -86,8 +92,7 @@ describe('wire event bridge', () => { it('carries each forwarded event name with its own argument list, unfiltered', async () => { const bench = await mount() - const seen: unknown[][] = [] - bench.ctx.on('remote/host-event', (event, args) => { seen.push([event, ...args]) }) + const seen = bench.dispatched bench.sinks?.onHostEnvelope?.({ rpcId: 'r3' as never, diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index 7ccf95dd73..6641467cc2 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -23,9 +23,6 @@ { "path": "../connection" }, - { - "path": "../schema-form" - }, { "path": "../../host/apiproxy" }, @@ -61,6 +58,9 @@ }, { "path": "../../typert/registry" + }, + { + "path": "../../api/gateway" } ], "exclude": [ diff --git a/packages/client/test-runtime/src/remote.ts b/packages/client/test-runtime/src/remote.ts index 143352cb1c..fab7f9b1fd 100644 --- a/packages/client/test-runtime/src/remote.ts +++ b/packages/client/test-runtime/src/remote.ts @@ -7,29 +7,40 @@ import type { Context } from '@deepseek-ai/cordis' * host events to reach those subscribers, but not the generated namespaces or * the wire — so this double implements subscription and dispatch only. * - * Dispatch is driven the same way production drives it: by the internal - * `remote/host-event` event the connection sink emits. A spec therefore - * exercises its refresh chains with `ctx.emit('remote/host-event', name, args)`, - * the exact signal `client/runtime` republishes from a `host/remote-event` - * frame, rather than reaching into this double. + * Dispatch is driven the same way production drives it: `client/runtime` owns the + * host frame sink and hands each decoded `host/remote-event` frame to + * `$dispatch`. A spec therefore exercises its refresh chains by calling + * `$dispatch(name, args)` on this double. * * `$mount` rejects: a spec that reaches a generated namespace through this * double has outgrown it and needs the real Client Remote service. + * + * One deliberate asymmetry with production: a throwing listener propagates out + * of the emit instead of being contained and logged, so a spec cannot lean on + * this double for the containment guarantee `$on` documents — assert that + * against the real service. */ export class TestRemote { private readonly subscriptions = new Map void>>() /** - * Register the double as `ctx.remote` and bind its dispatch to the plumbing event. + * Register the double as `ctx.remote`. * @param ctx - the spec's root Context. */ constructor(ctx: Context) { ctx.provide('remote', this) - ctx.on('remote/host-event', (event, args) => { - const listeners = this.subscriptions.get(event) - if (listeners === undefined) return - for (const listener of [...listeners]) listener(...args as never[]) - }) + } + + /** + * Deliver one forwarded host event to its subscribers, standing in for the + * carrier that owns the frame sink. + * @param event - forwarded host event name. + * @param args - the Host argument list, verbatim. + */ + $dispatch(event: string, args: readonly unknown[]): void { + const listeners = this.subscriptions.get(event) + if (listeners === undefined) return + for (const listener of [...listeners]) listener(...args as never[]) } /** diff --git a/packages/client/test-runtime/tests/remote.spec.ts b/packages/client/test-runtime/tests/remote.spec.ts new file mode 100644 index 0000000000..3f11c82323 --- /dev/null +++ b/packages/client/test-runtime/tests/remote.spec.ts @@ -0,0 +1,43 @@ +/** + * TestRemote's own contract: subscription and disposal, dispatch driven by the + * internal plumbing event, the silent drop for an unsubscribed name, and the + * `$mount` refusal that sends a spec to the real Client Remote service. + */ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import { TestRemote } from '../src/remote.ts' + +describe('TestRemote', () => { + it('delivers a forwarded event to its subscribers and stops after disposal', async () => { + const ctx = new Context() + const remote = new TestRemote(ctx) + const seen: string[] = [] + const off = remote.$on('settings/document-updated', (ns: string) => { + seen.push(ns) + }) + + ctx.remote.$dispatch('settings/document-updated', ['ui-theme', 1]) + expect(seen).toEqual(['ui-theme']) + + off() + ctx.remote.$dispatch('settings/document-updated', ['ui-theme', 2]) + expect(seen).toEqual(['ui-theme']) + await ctx.fiber.dispose() + }) + + it('drops a forwarded event nobody subscribed to', async () => { + const ctx = new Context() + new TestRemote(ctx) + // No subscriber for this name: the emit must be inert rather than throwing, + // because the wire carries whatever the Host allowlist selected. + expect(() => { ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY']) }).not.toThrow() + await ctx.fiber.dispose() + }) + + it('refuses $mount, which needs the real Client Remote service', async () => { + const ctx = new Context() + const remote = new TestRemote(ctx) + await expect(remote.$mount()).rejects.toThrow('needs the real Client Remote service') + await ctx.fiber.dispose() + }) +}) diff --git a/packages/client/ui-agent-preset/src/client/section-store.ts b/packages/client/ui-agent-preset/src/client/section-store.ts index df430f6db4..a6f3cb4d62 100644 --- a/packages/client/ui-agent-preset/src/client/section-store.ts +++ b/packages/client/ui-agent-preset/src/client/section-store.ts @@ -137,9 +137,10 @@ export class AgentPresetSectionController { /** * Called after this page changes the roster DIRECTORY, so the other * surfaces reading the same roster re-read it. A settings field moving is - * already announced by the host through `settings/changed`; a directory - * copied or deleted here is not, and the new-session chip has no other - * way to learn a preset it should offer now exists. + * already announced by the host through the forwarded + * `settings/document-updated`; a directory copied or deleted here is not, + * and the new-session chip has no other way to learn a preset it should + * offer now exists. */ private readonly rosterChanged: () => void = () => {}, ) {} diff --git a/packages/client/ui-agent-preset/tests/apply.spec.ts b/packages/client/ui-agent-preset/tests/apply.spec.ts index cf87c541c8..f719f9f371 100644 --- a/packages/client/ui-agent-preset/tests/apply.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.spec.ts @@ -79,7 +79,7 @@ async function bench() { const locale = new LocaleService(ctx) ctx.provide('locale', locale) // The plugins inject `remote`; forwarded events reach them through the - // same `remote/host-event` signal the connection sink republishes. + // same `$dispatch` handoff the connection sink makes. new TestRemote(ctx) const calls: string[] = [] ctx.provide('connection', { @@ -253,11 +253,11 @@ describe('ui-agent-preset apply', () => { await section.load() const before = calls.length - ctx.emit('remote/host-event', 'settings/document-updated', ['agent-presets', 1]) + ctx.remote.$dispatch('settings/document-updated', ['agent-presets', 1]) await vi.waitFor(() => { expect(calls.length).toBe(before + 2) }) const afterRelevant = calls.length - ctx.emit('remote/host-event', 'settings/document-updated', ['llm-deepseek', 1]) + ctx.remote.$dispatch('settings/document-updated', ['llm-deepseek', 1]) await Promise.resolve() // Both surfaces re-read on their own namespace; an unrelated one moves @@ -285,7 +285,7 @@ describe('ui-agent-preset apply', () => { await ctx.plugin({ inject: [...inject], apply }).await() const before = calls.length - ctx.emit('remote/host-event', 'settings/document-updated', ['agent-presets', 1]) + ctx.remote.$dispatch('settings/document-updated', ['agent-presets', 1]) await vi.waitFor(() => { expect(calls.length).toBeGreaterThan(before) }) // Only the General row reloads: a section nobody opened has nothing to @@ -336,11 +336,11 @@ describe('ui-agent-preset apply', () => { // An unrelated namespace moves nothing: the chip re-reads on its own // setting, not on every settings write in the process. moveDefault() - ctx.emit('remote/host-event', 'settings/document-updated', ['llm-deepseek', 1]) + ctx.remote.$dispatch('settings/document-updated', ['llm-deepseek', 1]) await Promise.resolve() expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard') - ctx.emit('remote/host-event', 'settings/document-updated', ['agent-presets', 1]) + ctx.remote.$dispatch('settings/document-updated', ['agent-presets', 1]) await vi.waitFor(() => { expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('minimal') }) diff --git a/packages/client/ui-command/tests/service.spec.ts b/packages/client/ui-command/tests/service.spec.ts index 320b1bf600..839934eb53 100644 --- a/packages/client/ui-command/tests/service.spec.ts +++ b/packages/client/ui-command/tests/service.spec.ts @@ -80,7 +80,7 @@ async function bench(opts: BenchOptions = {}) { }) ctx.provide('connection', { api }) // CommandService injects `remote`; the directory invalidation arrives on the - // same `remote/host-event` signal the connection sink republishes. + // same `$dispatch` handoff the connection sink makes. new TestRemote(ctx) /** Notices the fake conversation face collected (runDetached routing). */ const notices: Array<{ scope: SessionId | undefined; level: 'info' | 'error'; text: string }> = [] @@ -602,7 +602,7 @@ describe('popupFor', () => { }) describe('directory invalidation events', () => { - it('commands/changed repulls in the background while the old snapshot serves', async () => { + it('commands/change repulls in the background while the old snapshot serves', async () => { let round = 0 const { ctx, source, warm } = await bench({ commands: () => { @@ -615,7 +615,7 @@ describe('directory invalidation events', () => { }, }) await warm(proj('s1')) - ctx.emit('remote/host-event', 'commands/change', []) + ctx.remote.$dispatch('commands/change', []) await new Promise(resolve => setTimeout(resolve, 0)) expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined() expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined() diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index e80432f79f..e11cec8289 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -35,6 +35,8 @@ "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-ui-layout" ], "platform": "web" @@ -51,47 +53,52 @@ "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-connection": "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-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/cordis": "^4.0.0-rc.7", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-compact": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-goal": "workspace:^", - "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-compact": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", + "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 5449295055..4298328b33 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -2,8 +2,11 @@ import type { Context } from '@deepseek-ai/cordis' import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import { - bindSettingsScope, resolveWorkspacePath, type ISessions, type SessionId, + resolveWorkspacePath, type ISessions, type SessionId, } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: the ctx.settingsScope Context merge. Cross-plugin collaboration +// goes through the service, never a value import (client bundle purity gate). +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -46,7 +49,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Services required by the conversation plugin. */ export const inject = [ - 'slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection', + 'slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection', 'remote', 'settingsScope', 'conversationEvents', 'conversationViews', ] @@ -128,7 +131,7 @@ export function apply(ctx: Context): void { // Apply-time construction keeps store identity bound to this fiber. const chatStore = createChatStore() const submissionPolicy = new ComposerSubmissionPolicy( - bindSettingsScope(ctx, { namespace: CONVERSATION_SETTINGS_NAMESPACE }), + ctx.settingsScope.bind({ namespace: CONVERSATION_SETTINGS_NAMESPACE }), ) ctx.slots.inject('settings.general.item', () => ctx.slots.register({ diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 706db3eeb1..2017d84498 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -15,7 +15,7 @@ // chat-toolview-slot.spec.tsx. import { describe, expect, it, vi } from 'vitest' -import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client' @@ -48,6 +48,9 @@ function sessionFakeFor() { async function bench() { const runtime = await SlotTestRuntime.create() runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + // The plugin injects both; these specs exercise no settings path. + runtime.provide('remote', { $on: () => () => {} }) + runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const sessionFake = sessionFakeFor() await runtime.sessions.add({ id: ROOT, diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx index 9e52d2a0d5..9d4004f8c0 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx @@ -6,7 +6,7 @@ import { useState } from 'react' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' usePinnedBrowserLanguages('zh-CN') @@ -51,6 +51,9 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) { async function bench(opts?: { blank?: boolean }) { const runtime = await SlotTestRuntime.create() runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + // The plugin injects both; these specs exercise no settings path. + runtime.provide('remote', { $on: () => () => {} }) + runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) @@ -76,6 +79,9 @@ describe('resident composer', () => { it('renders the locked view state while no session exists at all', async () => { const runtime = await SlotTestRuntime.create() runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + // The plugin injects both; these specs exercise no settings path. + runtime.provide('remote', { $on: () => () => {} }) + runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) @@ -103,6 +109,9 @@ describe('resident composer', () => { it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => { const runtime = await SlotTestRuntime.create() runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + // The plugin injects both; these specs exercise no settings path. + runtime.provide('remote', { $on: () => () => {} }) + runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) @@ -169,6 +178,9 @@ describe('prompt rejection through the assembled composer', () => { it('renders the promptError alert strip and keeps the draft in the machine', async () => { const runtime = await SlotTestRuntime.create() runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + // The plugin injects both; these specs exercise no settings path. + runtime.provide('remote', { $on: () => () => {} }) + runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 7963e4b4f4..0ebb710311 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -6,7 +6,7 @@ // entries. Tool composition belongs to ui-tool and its machinery spec. import { describe, expect, it, vi } from 'vitest' -import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' @@ -22,6 +22,9 @@ const CHILD = 'child-1' as SessionId async function bench() { const runtime = await SlotTestRuntime.create() runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + // The plugin injects both; these specs exercise no settings path. + runtime.provide('remote', { $on: () => () => {} }) + runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) await runtime.sessions.add({ id: ROOT, summary: { title: 'R', displayTitle: 'R' } }, { current: false }) await runtime.sessions.add( { id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false }) diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 49763e5fb8..b6190dc3ae 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -76,6 +76,9 @@ }, { "path": "../../interaction/permission" + }, + { + "path": "../ui-settings" } ], "exclude": [ diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index 31289b38de..78d3317d75 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -18,7 +18,7 @@ import type { } from '@deepseek-ai/dsh-client-runtime/client' import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { ProducedFiles } from '../src/client/ProducedFiles.tsx' import { basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles, @@ -341,6 +341,9 @@ describe('plugin registration', () => { children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } }, } as never, () => null) ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + // ui-theme's Appearance row binds a durable scope through these two. + ctx.provide('remote', { $on: () => () => {} } as never) + ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() const fiber = ctx.plugin({ inject: [...inject], apply }) diff --git a/packages/client/ui-layout/tests/apply.spec.ts b/packages/client/ui-layout/tests/apply.spec.ts index f1a9115059..9c0b42035e 100644 --- a/packages/client/ui-layout/tests/apply.spec.ts +++ b/packages/client/ui-layout/tests/apply.spec.ts @@ -7,6 +7,7 @@ // coverage gate still requires exercised. import { Context } from '@deepseek-ai/cordis' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { beforeEach, describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' @@ -26,6 +27,9 @@ async function bench() { // seam for persistence; model this bench as a remote, memory-only browser. ctx.provide('locale', new LocaleService(ctx)) ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + // ui-theme's Appearance row binds a durable scope through these two. + ctx.provide('remote', { $on: () => () => {} } as never) + ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) await ctx.plugin({ inject: themeInject, apply: themeApply }).await() await slotsFiber.await() return { ctx, slots: ctx.get('slots') as SlotsService } diff --git a/packages/client/ui-models/tests/apply.spec.ts b/packages/client/ui-models/tests/apply.spec.ts index e077441fe0..a5aa39e281 100644 --- a/packages/client/ui-models/tests/apply.spec.ts +++ b/packages/client/ui-models/tests/apply.spec.ts @@ -19,7 +19,7 @@ async function bench() { const locale = new LocaleService(ctx) ctx.provide('locale', locale) // The plugins inject `remote`; forwarded events reach them through the - // same `remote/host-event` signal the connection sink republishes. + // same `$dispatch` handoff the connection sink makes. new TestRemote(ctx) // The apply path only captures the wire face; no call leaves this fake // until a section actually loads. @@ -138,8 +138,8 @@ describe('pushed invalidations', () => { declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() // The fake wire face has no methods: a fetch attempt would throw. - b.ctx.emit('remote/host-event', 'settings/document-updated', ['llm-pi-ai', 1]) - b.ctx.emit('remote/host-event', 'credentials/updated', ['OPENAI_API_KEY']) + b.ctx.remote.$dispatch('settings/document-updated', ['llm-pi-ai', 1]) + b.ctx.remote.$dispatch('credentials/updated', ['OPENAI_API_KEY']) b.ctx.emit('models/changed') b.ctx.emit('connection/reset') }) @@ -170,7 +170,7 @@ describe('pushed invalidations', () => { )() injected.controller.store.update((state) => { state.status = 'ready' }) const load = vi.spyOn(injected.controller, 'load').mockResolvedValue() - b.ctx.emit('remote/host-event', 'credentials/updated', ['DEEPSEEK_API_KEY']) + b.ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY']) expect(load).toHaveBeenCalledTimes(1) }) }) diff --git a/packages/client/ui-permission/package.json b/packages/client/ui-permission/package.json index f7391e2c3f..01e25b0d0a 100644 --- a/packages/client/ui-permission/package.json +++ b/packages/client/ui-permission/package.json @@ -36,7 +36,8 @@ "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-command", - "@deepseek-ai/dsh-api-remotes" + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-ui-settings" ], "platform": "web" } @@ -55,6 +56,7 @@ "@deepseek-ai/dsh-client-schema-form": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -62,6 +64,7 @@ "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/cordis": "^4.0.0-rc.7", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -70,6 +73,7 @@ "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", diff --git a/packages/client/ui-permission/src/client/index.ts b/packages/client/ui-permission/src/client/index.ts index d055b10abe..1e9a9486c0 100644 --- a/packages/client/ui-permission/src/client/index.ts +++ b/packages/client/ui-permission/src/client/index.ts @@ -16,6 +16,8 @@ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: the settings slot types (this package registers a General row). +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' // Type-only: pulls the ctx.remote merge and the forwarded-event key face // (the settings invalidation rides the allowlist) into this program. import type {} from '@deepseek-ai/dsh-api-remotes/client' diff --git a/packages/client/ui-permission/tests/browser-plugin.spec.ts b/packages/client/ui-permission/tests/browser-plugin.spec.ts index 7b6f5e2a19..63bc9e2133 100644 --- a/packages/client/ui-permission/tests/browser-plugin.spec.ts +++ b/packages/client/ui-permission/tests/browser-plugin.spec.ts @@ -39,7 +39,7 @@ async function bench() { locale.setLocale('en') ctx.provide('locale', locale) // The plugin injects `remote`; forwarded events reach it through the same - // `remote/host-event` signal the connection sink republishes. + // `$dispatch` handoff the connection sink makes. new TestRemote(ctx) ctx.slots.register({ name: 'root', @@ -162,8 +162,8 @@ describe('ui-permission browser plugin', () => { it('disposal removes the decoration (HMR safety)', async () => { const b = await bench() expect(b.decoration()).toBeDefined() - b.ctx.emit('remote/host-event', 'settings/document-updated', ['another', 1]) - b.ctx.emit('remote/host-event', 'settings/document-updated', ['permission', 1]) + b.ctx.remote.$dispatch('settings/document-updated', ['another', 1]) + b.ctx.remote.$dispatch('settings/document-updated', ['permission', 1]) b.ctx.emit('connection/reset') await b.fiber.dispose() expect(b.decoration()).toBeUndefined() diff --git a/packages/client/ui-permission/tsconfig.json b/packages/client/ui-permission/tsconfig.json index 987c3e429f..18cb5ff3da 100644 --- a/packages/client/ui-permission/tsconfig.json +++ b/packages/client/ui-permission/tsconfig.json @@ -46,6 +46,9 @@ }, { "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../ui-settings" } ] } diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 8d7a5f1d4b..87ff4b0946 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/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-settings-general/README.md -README.md: ab27e073dc76335efc619f56365d1705007f7ef2 -README.zh.md: 16ff5604bee5425569b783e27a29699344f630e3 +README.md: 93bb2b04982a0e7bc8a248c1127428aec509773f +README.zh.md: 5cf5da2f92a0b5a1f53f3306665c3c84381a0e85 diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 8dc142fd91..89e5fcfa0d 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -36,7 +36,8 @@ "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-api-remotes" + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-ui-sidebar" ], "platform": "web" } @@ -48,19 +49,21 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "clsx": "^2.0.0" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "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-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { @@ -71,11 +74,12 @@ "@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-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", + "@types/react": "~18.3.1", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css similarity index 100% rename from packages/client/ui-settings/src/client/SettingsRoot.module.css rename to packages/client/ui-settings-general/src/client/SettingsRoot.module.css diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings-general/src/client/SettingsRoot.tsx similarity index 99% rename from packages/client/ui-settings/src/client/SettingsRoot.tsx rename to packages/client/ui-settings-general/src/client/SettingsRoot.tsx index de00fa372e..795b9f1308 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.tsx @@ -16,7 +16,7 @@ import clsx from 'clsx' import { IconAgentPresetOutline16, IconCloseOutline16, IconDataOutline16, IconSettingsOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts' +import type { SettingsRootComponentProps, SettingsSectionRow } from './shell-contract.ts' import css from './SettingsRoot.module.css' /** Nav glyph by section id; unknown ids fall back to the settings gear. */ diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index d4c1aa0ca5..947e278f99 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -7,14 +7,21 @@ */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -// Type-only: pulls the shell's SlotMap merges (trigger/header/section/item). +// Type-only: the settings slot declarations plus the ctx.settingsScope Context +// merge. Cross-plugin collaboration goes through the service, never a value +// import (client bundle purity gate). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' -// Type-only: pulls ctx.locale and the 'settings.general.item' SlotMap merge. +// Type-only: pulls ctx.locale into this program. import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: pulls the ctx.remote merge and the forwarded-event key face // (the settings invalidation rides the allowlist) into this program. import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type { + SettingsOnboardingStep, SettingsRootInjected, SettingsSectionRow, +} from './shell-contract.ts' +import { SettingsRoot } from './SettingsRoot.tsx' import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx' import { GeneralSection } from './GeneralSection.tsx' import { SettingsDocumentAction } from './SettingsDocumentAction.tsx' @@ -99,6 +106,77 @@ export function apply(ctx: ClientContext): void { ] return () => { for (const dispose of disposers) dispose() } }, 'ui-settings-general: metadata invalidations') + // The settings shell: this package occupies the sidebar-owned hole and + // declares the settings slots. Ledger → nav-row projection as an observable + // source (uSES contract: getSnapshot returns the cached rows until the + // ledger version moves). Labels may be locale-following thunks, so the cache + // key includes the locale revision and subscribers ride both sources. + let rowsVersion = -1 + let rowsRevision = -1 + let rows: readonly SettingsSectionRow[] = [] + let onboardingVersion = -1 + let onboardingSteps: readonly SettingsOnboardingStep[] = [] + const shellInjected = (): SettingsRootInjected => ({ + hooks: { + sections: { + getSnapshot: () => { + const version = ctx.slots.getVersion('settings.section') + const revision = ctx.locale.getSnapshot().revision + if (version !== rowsVersion || revision !== rowsRevision) { + rowsVersion = version + rowsRevision = revision + rows = ctx.slots.entries('settings.section') + .map(e => ({ + /* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */ + id: e.options.id ?? '', + order: e.options.order ?? 0, + label: resolveSlotLabel(e.options.label) ?? '', + })) + .sort((a, b) => a.order - b.order) + } + return rows + }, + subscribe: (listener) => { + const offLedger = ctx.slots.subscribe('settings.section', listener) + const offLocale = ctx.locale.subscribe(listener) + return () => { + offLedger() + offLocale() + } + }, + }, + onboardingSteps: { + getSnapshot: () => { + const version = ctx.slots.getVersion('settings.onboarding') + if (version !== onboardingVersion) { + onboardingVersion = version + onboardingSteps = ctx.slots.entries('settings.onboarding') + .map(e => ({ + /* v8 ignore next -- list-slot registration requires id */ + id: e.options.id ?? '', + order: e.options.order ?? 0, + })) + .sort((a, b) => a.order - b.order) + } + return onboardingSteps + }, + subscribe: listener => ctx.slots.subscribe('settings.onboarding', listener), + }, + }, + }) + ctx.slots.inject('sidebar.settings', () => ctx.slots.register({ + name: 'sidebar.settings', + children: { + 'settings.trigger': { kind: 'single', scope: 'root' }, + 'settings.header': { kind: 'single', scope: 'root' }, + 'settings.action': { kind: 'list', scope: 'root' }, + 'settings.close': { kind: 'single', scope: 'root' }, + 'settings.section': { kind: 'list', scope: 'root' }, + 'settings.onboarding': { kind: 'list', scope: 'root' }, + }, + inject: shellInjected, + }, SettingsRoot)) + ctx.slots.inject('settings.trigger', () => ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent)) ctx.slots.inject('settings.header', () => diff --git a/packages/client/ui-settings-general/src/client/shell-contract.ts b/packages/client/ui-settings-general/src/client/shell-contract.ts new file mode 100644 index 0000000000..ad61b2111a --- /dev/null +++ b/packages/client/ui-settings-general/src/client/shell-contract.ts @@ -0,0 +1,59 @@ +/** + * Settings shell contract — the types of the `sidebar.settings` occupant this + * package renders. They live here rather than in ui-settings because they + * reference the sidebar's own slot type: ui-settings is the settings domain's + * base layer and must not depend on any `ui-*` presentation package, or the + * reference graph closes a cycle through ui-sidebar → ui-layout → ui-theme. + * The settings SLOT types (what registrants contribute) stay in ui-settings. + */ +import type { HostObservable, InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +// Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry) +// into every program that sees this contract. +import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' +// Type-only: pulls the settings slot declarations the shell renders into. +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' + +/** One nav row projected from a settings.section registration's options. */ +export interface SettingsSectionRow { + id: string + order: number + label: string +} + +/** One ordered onboarding step projected from a slot registration. */ +export interface SettingsOnboardingStep { + id: string + order: number +} + +/** + * Registrant-private injected share of the settings shell (assembled in + * apply): the ledger's nav-row projection as a hooks-compartment source — + * the shell reads no locale state and subscribes through the bound hook. + */ +export type SettingsRootInjected = { + hooks: { + /** settings.section ledger projected into ordered nav rows. */ + sections: HostObservable + /** settings.onboarding ledger projected into coordinator order. */ + onboardingSteps: HostObservable + } +} + +/** + * Full component props of the settings shell root: the sidebar owner share + * (wide/rail state) plus the declared render shares and the injected face + * (hooks compartment bound to useSections). No store is registered — modal + * open state and active section id are component-local viewing state. + */ +export type SettingsRootComponentProps = + PropsRuntime<'sidebar.settings'> + & PropsRenderSlots< + | 'settings.trigger' + | 'settings.header' + | 'settings.action' + | 'settings.close' + | 'settings.section' + | 'settings.onboarding' + > + & InjectFace diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts index 98db5699df..3a2c333cfc 100644 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -34,7 +34,7 @@ async function bench(isLoopback = true) { const locale = new LocaleService(ctx) ctx.provide('locale', locale) // The plugins inject `remote`; forwarded events reach them through the - // same `remote/host-event` signal the connection sink republishes. + // same `$dispatch` handoff the connection sink makes. new TestRemote(ctx) const settingsDescribe = vi.fn(() => Promise.resolve({ rpcId: 'settings-general' as never, @@ -170,9 +170,9 @@ describe('ui-settings-general apply', () => { const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)() await controller.load() expect(b.settingsDescribe).toHaveBeenCalledOnce() - b.ctx.emit('remote/host-event', 'settings/document-updated', ['unrelated', 1]) + b.ctx.remote.$dispatch('settings/document-updated', ['unrelated', 1]) expect(b.settingsDescribe).toHaveBeenCalledOnce() - b.ctx.emit('remote/host-event', 'settings/document-updated', [WELCOME_NOTICE_SETTINGS_NAMESPACE, 1]) + b.ctx.remote.$dispatch('settings/document-updated', [WELCOME_NOTICE_SETTINGS_NAMESPACE, 1]) await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) }) b.ctx.emit('connection/reset') await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) }) diff --git a/packages/client/ui-settings/tests/settings-root.spec.tsx b/packages/client/ui-settings-general/tests/settings-root.spec.tsx similarity index 99% rename from packages/client/ui-settings/tests/settings-root.spec.tsx rename to packages/client/ui-settings-general/tests/settings-root.spec.tsx index 1f34a47cf5..a95e8e6533 100644 --- a/packages/client/ui-settings/tests/settings-root.spec.tsx +++ b/packages/client/ui-settings-general/tests/settings-root.spec.tsx @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { useEffect, useState } from 'react' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import type { SettingsRootComponentProps } from '../src/client/contract/slots.ts' +import type { SettingsRootComponentProps } from '../src/client/shell-contract.ts' import { SettingsRoot } from '../src/client/SettingsRoot.tsx' afterEach(cleanup) diff --git a/packages/client/ui-settings/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/shell.spec.ts similarity index 83% rename from packages/client/ui-settings/tests/apply.spec.ts rename to packages/client/ui-settings-general/tests/shell.spec.ts index 2e8243fa26..8515d79423 100644 --- a/packages/client/ui-settings/tests/apply.spec.ts +++ b/packages/client/ui-settings-general/tests/shell.spec.ts @@ -2,13 +2,26 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' -import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings/client' -import type { SettingsRootInjected } from '@deepseek-ai/dsh-client-ui-settings/client' +import { apply, inject } from '../src/client/index.ts' +import type { SettingsRootInjected } from '../src/client/shell-contract.ts' import { SettingsRoot } from '../src/client/SettingsRoot.tsx' async function bench() { const ctx = new Context() await ctx.plugin(SlotsService).await() + // Copy machinery the shell only reads a revision from; the real locale + // plugin would drag its own settings-row dependencies into this bench. + ctx.provide('locale', { + register: () => () => {}, + bind: () => (key: string) => key, + getSnapshot: () => ({ active: 'zh', locales: [], revision: 0 }), + subscribe: () => () => {}, + } as never) + ctx.provide('connection', { + api: { settings: { describe: async () => ({ result: { ok: false } }) } }, + isLoopback: false, + } as never) + ctx.provide('remote', { $on: () => () => {} } as never) return { ctx, slots: ctx.get('slots') as SlotsService } } @@ -36,7 +49,7 @@ const CHILD_SPECS = { describe('ui-settings apply', () => { it('declares only the slot registry (a pure composition face, no locale)', () => { - expect(inject).toEqual(['slots']) + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote']) }) it('registers the shell and declares every child slot, before or after the declaration', async () => { @@ -63,13 +76,16 @@ describe('ui-settings apply', () => { declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const { sections } = injectedOf(b.slots).hooks - // The shell ships no sections of its own — registrants fill the ledger. - expect(sections.getSnapshot()).toEqual([]) + // This package registers the General section itself; every other section + // arrives from a feature registrant. + const GENERAL = { id: 'general', order: 0, label: 'general.nav' } + expect(sections.getSnapshot()).toEqual([GENERAL]) b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null) // No order and no label: both projection defaults apply. b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null) const rows = sections.getSnapshot() expect(rows).toEqual([ + GENERAL, { id: 'a', order: 0, label: '' }, { id: 'z', order: 20, label: 'Z' }, ]) @@ -94,6 +110,8 @@ describe('ui-settings apply', () => { b.slots.register({ name: 'settings.onboarding', id: 'default-order' } as never, () => null) const steps = onboardingSteps.getSnapshot() expect(steps).toEqual([ + // This package's own onboarding page, registered by the same apply. + { id: 'welcome-notice', order: -100 }, { id: 'welcome', order: -100 }, { id: 'credential', order: 0 }, { id: 'default-order', order: 0 }, diff --git a/packages/client/ui-settings-general/tsconfig.json b/packages/client/ui-settings-general/tsconfig.json index 07f5269510..a4535df785 100644 --- a/packages/client/ui-settings-general/tsconfig.json +++ b/packages/client/ui-settings-general/tsconfig.json @@ -40,6 +40,9 @@ }, { "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../ui-sidebar" } ] } diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 3447573398..d281c7d724 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-settings", - "description": "Settings shell plugin: sidebar trigger, modal panel, feature sections, and an ordered full-page onboarding stage", + "description": "Settings domain base plugin: the settings-namespace scope service and the canonical settings slot-type contract", "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" @@ -32,8 +32,9 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-sidebar" + "@deepseek-ai/dsh-api-remotes" ], "platform": "web" } @@ -43,30 +44,32 @@ "watch": "tsdown --watch" }, "license": "BSD-3-Clause", - "dependencies": { - "clsx": "^2.0.0" - }, "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-schema-form": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "@deepseek-ai/dsh-settings": "workspace:^", + "react": "^18.2.0" }, "devDependencies": { - "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", + "@deepseek-ai/dsh-client-schema-form": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@types/react-dom": "~18.3.0", + "@deepseek-ai/dsh-settings": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings/src/client/contract/slots.ts b/packages/client/ui-settings/src/client/contract/slots.ts index 4158ee6c66..903e1b0225 100644 --- a/packages/client/ui-settings/src/client/contract/slots.ts +++ b/packages/client/ui-settings/src/client/contract/slots.ts @@ -7,10 +7,7 @@ * setting never means editing the shell; copy that belongs to no single * feature (chrome, the General section) is owned by ui-settings-general. */ -import type { HostObservable, InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -// Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry) -// into every program that sees this contract. -import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' + declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { @@ -66,9 +63,26 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * would render without mask or stage). */ 'settings.onboarding': { kind: 'list'; scope: 'root'; owner: SettingsOnboardingOwnerProps } + /** + * One preference row inside the General section, contributed by the + * feature plugin that owns the preference (locale → Language, ui-theme → + * Appearance, ui-conversation → Composer Enter). Options: `id` (row key), + * `order` (row position). Rows draw their own internals; the section + * column only stacks them. Declared at runtime by ui-settings-general's + * General entry — the type lives here with every other settings slot type, + * because this package is the settings domain's base layer and every + * registrant already depends on it for `ctx.settingsScope`. + */ + 'settings.general.item': { kind: 'list'; scope: 'root'; owner: SettingsGeneralItemOwnerProps } } } +/** Owner share of a General preference row (the section supplies nothing). */ +export interface SettingsGeneralItemOwnerProps { + /** Marker field: item owner props are intentionally empty. */ + children?: never +} + /** Owner share of the trigger content seat: the sidebar column state. */ export interface SettingsTriggerOwnerProps { /** Whether the sidebar renders wide content (false = 56px rail, icon only). */ @@ -103,47 +117,3 @@ export interface SettingsOnboardingOwnerProps { openSection: (id: string) => void } -/** One nav row projected from a settings.section registration's options. */ -export interface SettingsSectionRow { - id: string - order: number - label: string -} - -/** One ordered onboarding step projected from a slot registration. */ -export interface SettingsOnboardingStep { - id: string - order: number -} - -/** - * Registrant-private injected share of the settings shell (assembled in - * apply): the ledger's nav-row projection as a hooks-compartment source — - * the shell reads no locale state and subscribes through the bound hook. - */ -export type SettingsRootInjected = { - hooks: { - /** settings.section ledger projected into ordered nav rows. */ - sections: HostObservable - /** settings.onboarding ledger projected into coordinator order. */ - onboardingSteps: HostObservable - } -} - -/** - * Full component props of the settings shell root: the sidebar owner share - * (wide/rail state) plus the declared render shares and the injected face - * (hooks compartment bound to useSections). No store is registered — modal - * open state and active section id are component-local viewing state. - */ -export type SettingsRootComponentProps = - PropsRuntime<'sidebar.settings'> - & PropsRenderSlots< - | 'settings.trigger' - | 'settings.header' - | 'settings.action' - | 'settings.close' - | 'settings.section' - | 'settings.onboarding' - > - & InjectFace diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 01c175954e..30f0c41af1 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -1,111 +1,32 @@ /** - * Settings shell plugin, browser half. A pure composition face: occupies the - * sidebar-owned `sidebar.settings` hole with the trigger chrome + modal - * panel, declares its chrome, section, and onboarding slots, and projects the - * section ledger into panel navigation. The shell ships no copy; it reads the - * optional locale revision only to resolve registrant-owned nav-label thunks. - * ui-settings-general owns the chrome and General content; features own their - * rows, sections, and onboarding pages. Export discipline: packages/client/AGENTS.md. + * Settings domain base plugin, browser half. Provides `ctx.settingsScope`, the + * settings-namespace Host transport every preference row binds its durable + * section through, and owns the canonical slot-type contract for the settings + * surface. It depends on no `ui-*` presentation package, so any feature that + * owns a preference can reach it: the settings SHELL — the `sidebar.settings` + * occupant, its navigation, and the chrome — lives in ui-settings-general, + * because a shell dependency on ui-sidebar would close a reference cycle + * through ui-layout and ui-theme. Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -// Type-only: the ctx.locale Context merge for the optional ctx.get('locale') -// read (nav labels may be locale-following thunks; the shell still ships no -// copy of its own and takes no hard locale dependency). -import type {} from '@deepseek-ai/dsh-client-locale/client' -import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -import type { - SettingsOnboardingStep, SettingsRootInjected, SettingsSectionRow, -} from './contract/slots.ts' -import { SettingsRoot } from './SettingsRoot.tsx' +import { SettingsScopeService } from './settings-scope.ts' export type { - SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected, - SettingsOnboardingOwnerProps, SettingsOnboardingStep, SettingsSectionOwnerProps, - SettingsSectionRow, SettingsTriggerOwnerProps, + SettingsGeneralItemOwnerProps, SettingsHeaderOwnerProps, SettingsOnboardingOwnerProps, + SettingsSectionOwnerProps, SettingsTriggerOwnerProps, } from './contract/slots.ts' +export { SettingsScopeController, SettingsScopeService } from './settings-scope.ts' /** - * Required services (cordis fiber inject). The target slot is declared by - * ui-sidebar's apply, whose activation order relative to this one is NOT - * constrained (dsh.client.inject edges are informational); registration - * depends on the slot through `slots.inject()`. + * Required services: none. The transport is resolved per caller through + * `this.ctx` at `bind` time, so this plugin waits for nothing. */ -export const inject = ['slots'] +export const inject = [] /** - * Register the settings shell into `sidebar.settings` once the declaration is - * on the ledger. + * Provide the settings-namespace scope service. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - // Ledger → nav-row projection as an observable source (uSES contract: - // getSnapshot returns the cached rows until the ledger version moves). - // Labels may be locale-following thunks, so the cache key includes the - // locale revision and subscribers ride both sources. - let rowsVersion = -1 - let rowsRevision = -1 - let rows: readonly SettingsSectionRow[] = [] - let onboardingVersion = -1 - let onboardingSteps: readonly SettingsOnboardingStep[] = [] - const localeRevision = (): number => ctx.get('locale')?.getSnapshot().revision ?? 0 - const injected = (): SettingsRootInjected => ({ - hooks: { - sections: { - getSnapshot: () => { - const version = ctx.slots.getVersion('settings.section') - const revision = localeRevision() - if (version !== rowsVersion || revision !== rowsRevision) { - rowsVersion = version - rowsRevision = revision - rows = ctx.slots.entries('settings.section') - .map(e => ({ - /* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */ - id: e.options.id ?? '', - order: e.options.order ?? 0, - label: resolveSlotLabel(e.options.label) ?? '', - })) - .sort((a, b) => a.order - b.order) - } - return rows - }, - subscribe: (listener) => { - const offLedger = ctx.slots.subscribe('settings.section', listener) - const offLocale = ctx.get('locale')?.subscribe(listener) - return () => { - offLedger() - offLocale?.() - } - }, - }, - onboardingSteps: { - getSnapshot: () => { - const version = ctx.slots.getVersion('settings.onboarding') - if (version !== onboardingVersion) { - onboardingVersion = version - onboardingSteps = ctx.slots.entries('settings.onboarding') - .map(e => ({ - /* v8 ignore next -- list-slot registration requires id */ - id: e.options.id ?? '', - order: e.options.order ?? 0, - })) - .sort((a, b) => a.order - b.order) - } - return onboardingSteps - }, - subscribe: listener => ctx.slots.subscribe('settings.onboarding', listener), - }, - }, - }) - ctx.slots.inject('sidebar.settings', () => ctx.slots.register({ - name: 'sidebar.settings', - children: { - 'settings.trigger': { kind: 'single', scope: 'root' }, - 'settings.header': { kind: 'single', scope: 'root' }, - 'settings.action': { kind: 'list', scope: 'root' }, - 'settings.close': { kind: 'single', scope: 'root' }, - 'settings.section': { kind: 'list', scope: 'root' }, - 'settings.onboarding': { kind: 'list', scope: 'root' }, - }, - inject: injected, - }, SettingsRoot)) + ctx.provide('settingsScope', new SettingsScopeService(ctx)) } diff --git a/packages/client/runtime/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts similarity index 62% rename from packages/client/runtime/src/client/settings-scope.ts rename to packages/client/ui-settings/src/client/settings-scope.ts index cb7e933406..7af1c1eb97 100644 --- a/packages/client/runtime/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -1,66 +1,36 @@ -/** Host-backed settings-namespace synchronization for browser plugins. */ +/** + * Host transport for the settings-namespace scope contract. The contract types + * live in `dsh-client-runtime` (the common dependency of every feature that + * owns a preference); this file owns the wire behavior and the invalidation + * subscription, both of which are Settings-surface concerns. + */ +import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' import type { ConnectionHandle, IApiClient, SettingsNamespaceView, } from '@deepseek-ai/dsh-client-connection/client' import { rehydrateSchema, validateDraft } from '@deepseek-ai/dsh-client-schema-form' -import { createSnapshotStore, type SnapshotStore } from './contract/store.ts' - -/** Client-side sync state of one settings namespace. */ -export interface SettingsScopeSnapshot { - /** - * `loading` until the first accepted section, `ready` while one stands, and - * `unavailable` when the namespace is not exposed to this client or the - * connection keeps preferences process-local (memory mode). - */ - status: 'loading' | 'ready' | 'unavailable' - /** Last accepted schema-resolved section; undefined before the first acceptance. */ - value: T | undefined - /** Namespace revision fencing the next write; undefined before the first Host view. */ - revision: number | undefined - /** Whether the Host document accepts writes; memory mode never does. */ - writable: boolean - /** `host` syncs with the Host document; `memory` keeps a remote browser process-local. */ - mode: 'host' | 'memory' -} - -/** Domain-owned description of one settings namespace consumed by a browser plugin. */ -export interface SettingsScopeSpec { - /** Settings namespace registered by the owning Host plugin. */ - namespace: string - /** - * Narrow one wire section; undefined keeps the last accepted value. The - * default validates the section against the namespace's own serialized wire - * schema, so domains add a decoder only to narrow beyond that schema. - */ - decode?: (section: unknown) => T | undefined -} - -/** - * Reactive owner handle over one namespace's durable section — the browser - * mirror of the Host-side `SettingsScope` owner seam. Domain services read - * and observe the snapshot and route explicit user choices through `set`. - */ -export interface SettingsScope { - /** @returns the current sync snapshot (stable reference until the next change). */ - getSnapshot(): SettingsScopeSnapshot - /** - * Observe snapshot replacements. - * @param listener - invoked after each snapshot change. - * @returns the disposer removing this listener. - */ - subscribe(listener: () => void): () => void - /** - * Queue one field write. Rapid writes preserve mutation order, each carries - * the latest known namespace revision, and only the latest settlement may - * publish; a rejected or failed latest write reloads Host state instead. - * @param field - scalar field inside the namespace section. - * @param value - JSON-shaped value selected by the user. - * @returns settlement after the write and any latest-write recovery read. - */ - set(field: string, value: unknown): Promise -} +import { + createSnapshotStore, type SettingsScope, type SettingsScopeSnapshot, + type SettingsScopeSpec, type SnapshotStore, +} from '@deepseek-ai/dsh-client-runtime/client' +// Type-only, and deliberately NOT `@deepseek-ai/dsh-api-remotes/client`: this +// package is reachable from the Host build graph through its feature-package +// callers, and api-remotes' Client face imports a Host-tsdown-generated +// `/remote` artifact, which would deadlock the Host tsc phase. The gateway's +// Client half declares `ctx.remote` with no generated import, and the +// allowlist's `types` subpath is a pure-type source file, so the pair supplies +// `$on` and its key face without dragging a build artifact in. The runtime +// `remote` injection belongs to whoever calls bindSettingsScope: the +// subscription is registered on the caller's own context. +import type {} from '@deepseek-ai/dsh-api-gateway/client' +import type {} from '@deepseek-ai/dsh-api-remotes/types' +// The forwarded event's own declaration: `$on`'s key face is +// `Extract`, so the allowlist alone resolves to +// never — the owning package's client-safe, type-only subpath supplies the +// cordis `Events` entry (and with it the branded `SettingsNamespace`). +import type {} from '@deepseek-ai/dsh-settings/types' type SettingsFace = Pick @@ -224,38 +194,60 @@ export class SettingsScopeController implements SettingsScope { } } -/** - * Bind one namespace scope to settings and connection invalidations on the - * caller's plugin lifecycle. Listeners exist before the initial background - * read starts, so activation never blocks on the settings transport. - * @param ctx - owning browser plugin context. - * @param spec - domain-owned namespace contract. - * @returns the bound scope consumed by the domain's services and rows. - */ -export function bindSettingsScope( - ctx: Context, - spec: SettingsScopeSpec, -): SettingsScope { - const connection = ctx.get('connection') as ConnectionHandle - const controller = new SettingsScopeController( - connection.api, - spec, - connection.isLoopback ? 'host' : 'memory', - ) - ctx.effect(() => { - const refresh = (namespace?: string): void => { - if (namespace !== undefined && namespace !== spec.namespace) return - void controller.load() - } - const disposers = [ - ctx.on('settings/changed', refresh), - ctx.on('connection/reset', () => { refresh() }), - ] - void controller.load() - return async () => { - for (const dispose of disposers) dispose() - await controller.dispose() - } - }, `runtime: ${spec.namespace} settings scope`) - return controller +declare module '@deepseek-ai/cordis' { + interface Context { + settingsScope: SettingsScopeService + } +} + +/** + * The settings domain's base service. Features that own a preference reach the + * settings transport through this service rather than a shared function: the + * client bundle purity gate forbids cross-plugin value imports and directs + * cross-plugin collaboration through cordis services + * (`packages/client/tsdown.client.ts`). + */ +export class SettingsScopeService extends Service { + /** + * @param ctx - the providing plugin's context. + */ + constructor(ctx: Context) { + super(ctx, 'settingsScope') + } + + /** + * Bind one namespace scope to settings and connection invalidations on the + * CALLER's plugin lifecycle — the service proxy binds `this.ctx` to the + * caller at call time, so the scope's disposer belongs to the calling fiber. + * Listeners exist before the initial background read starts, so activation + * never blocks on the settings transport. The caller injects `connection` + * for the transport and `remote` for the forwarded settings invalidation. + * @param spec - domain-owned namespace contract. + * @returns the bound scope consumed by the domain's services and rows. + */ + bind(spec: SettingsScopeSpec): SettingsScope { + const ctx = this.ctx + const connection = ctx.get('connection') as ConnectionHandle + const controller = new SettingsScopeController( + connection.api, + spec, + connection.isLoopback ? 'host' : 'memory', + ) + ctx.effect(() => { + const refresh = (namespace?: string): void => { + if (namespace !== undefined && namespace !== spec.namespace) return + void controller.load() + } + const disposers = [ + ctx.remote.$on('settings/document-updated', refresh), + ctx.on('connection/reset', () => { refresh() }), + ] + void controller.load() + return async () => { + for (const dispose of disposers) dispose() + await controller.dispose() + } + }, `ui-settings: ${spec.namespace} settings scope`) + return controller + } } diff --git a/packages/client/runtime/tests/settings-scope.spec.ts b/packages/client/ui-settings/tests/settings-scope.spec.ts similarity index 93% rename from packages/client/runtime/tests/settings-scope.spec.ts rename to packages/client/ui-settings/tests/settings-scope.spec.ts index ae3c1db73c..e6b165e9f3 100644 --- a/packages/client/runtime/tests/settings-scope.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.spec.ts @@ -2,9 +2,9 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { describe, expect, it, vi } from 'vitest' import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' -import { - bindSettingsScope, SettingsScopeController, type SettingsScope, -} from '../src/client/settings-scope.ts' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import type { SettingsScope } from '@deepseek-ai/dsh-client-runtime/client' +import { SettingsScopeController, SettingsScopeService } from '../src/client/settings-scope.ts' interface UiTestSettings { preference: 'light' | 'dark' | 'system' @@ -295,7 +295,7 @@ describe('SettingsScopeController', () => { }) }) -describe('bindSettingsScope', () => { +describe('SettingsScopeService.bind', () => { it('subscribes before the initial read and converges to the latest queued invalidation', async () => { const initial = deferred>() const describeCall = vi.fn() @@ -308,16 +308,18 @@ describe('bindSettingsScope', () => { isLoopback: true, } as never) let scope!: SettingsScope + new TestRemote(ctx) + await ctx.plugin(SettingsScopeService).await() const fiber = ctx.plugin({ - inject: ['connection'], + inject: ['connection', 'remote', 'settingsScope'], apply: (plugin: Context) => { - scope = bindSettingsScope(plugin, { namespace: 'ui-test' }) + scope = plugin.settingsScope.bind({ namespace: 'ui-test' }) }, }) await fiber.await() await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledOnce() }) - ctx.emit('settings/changed', 'unrelated') - ctx.emit('settings/changed', 'ui-test') + ctx.remote.$dispatch('settings/document-updated', ['unrelated', 0]) + ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0]) ctx.emit('connection/reset') initial.resolve(described({ preference: 'dark' }, 1)) await vi.waitFor(() => { expect(describeCall).toHaveBeenCalledTimes(3) }) @@ -325,7 +327,7 @@ describe('bindSettingsScope', () => { expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 3 }) }) await fiber.dispose() - ctx.emit('settings/changed', 'ui-test') + ctx.remote.$dispatch('settings/document-updated', ['ui-test', 0]) await Promise.resolve() expect(describeCall).toHaveBeenCalledTimes(3) }) @@ -338,10 +340,12 @@ describe('bindSettingsScope', () => { isLoopback: false, } as never) let scope!: SettingsScope + new TestRemote(ctx) + await ctx.plugin(SettingsScopeService).await() const fiber = ctx.plugin({ - inject: ['connection'], + inject: ['connection', 'remote', 'settingsScope'], apply: (plugin: Context) => { - scope = bindSettingsScope(plugin, { namespace: 'ui-test' }) + scope = plugin.settingsScope.bind({ namespace: 'ui-test' }) }, }) await fiber.await() @@ -350,3 +354,4 @@ describe('bindSettingsScope', () => { expect(describeCall).not.toHaveBeenCalled() }) }) + diff --git a/packages/client/ui-settings/tsconfig.json b/packages/client/ui-settings/tsconfig.json index c5ed0161c8..193107b1d2 100644 --- a/packages/client/ui-settings/tsconfig.json +++ b/packages/client/ui-settings/tsconfig.json @@ -11,20 +11,23 @@ { "path": "../../../vendor/cordis" }, - { - "path": "../locale" - }, { "path": "../ui-slots" }, - { - "path": "../ui-primitives" - }, { "path": "../runtime" }, { - "path": "../ui-sidebar" + "path": "../connection" + }, + { + "path": "../schema-form" + }, + { + "path": "../../api/gateway" + }, + { + "path": "../../settings/settings" }, { "path": "../../support/invariants" diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.spec.ts index 426dadaa60..1636349ca5 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.spec.ts @@ -12,6 +12,7 @@ * source's own contract. */ import { Context } from '@deepseek-ai/cordis' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { describe, expect, it } from 'vitest' import { SlotsService, type ConversationSnapshot, type SessionId, type SessionListState, @@ -87,6 +88,9 @@ async function fullBench(sessions: SessionSummary[]) { ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } }) ctx.provide('sessions', face) ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + // ui-theme's Appearance row binds a durable scope through these two. + ctx.provide('remote', { $on: () => () => {} } as never) + ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) await provideSlotFaces(ctx) await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() await ctx.plugin({ inject: [...inject], apply }).await() @@ -123,6 +127,9 @@ describe('apply', () => { await ctx.plugin(SlashService).await() ctx.provide('sessions', sessionsWith(FAMILY)) ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + // ui-theme's Appearance row binds a durable scope through these two. + ctx.provide('remote', { $on: () => () => {} } as never) + ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) await provideSlotFaces(ctx) await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() const fiber = ctx.plugin({ inject: [...inject], apply }) diff --git a/packages/client/ui-task/tests/browser-plugin.spec.ts b/packages/client/ui-task/tests/browser-plugin.spec.ts index 142668387f..c03a5125bf 100644 --- a/packages/client/ui-task/tests/browser-plugin.spec.ts +++ b/packages/client/ui-task/tests/browser-plugin.spec.ts @@ -8,6 +8,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import InvariantService from '@deepseek-ai/dsh-invariants' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '../src/client/index.ts' import { apply as applyNode } from '../src/index.ts' @@ -32,8 +33,11 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType null) ctx.provide('sessions', {}) - // The locale plugin binds a settings scope, which reads the connection handle. + // The locale plugin binds a settings scope, which reads the connection handle + // and the forwarded-event port. ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + ctx.provide('remote', { $on: () => () => {} } as never) + ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 1d2e1e6b19..7a5e6b37cf 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -35,7 +35,9 @@ "inject": [ "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-locale" + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-api-remotes" ], "platform": "web", "immediately": true @@ -43,21 +45,26 @@ }, "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "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-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@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-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/ui-theme/src/client/AppearanceRow.tsx b/packages/client/ui-theme/src/client/AppearanceRow.tsx index e482f5ed2e..b1db97d009 100644 --- a/packages/client/ui-theme/src/client/AppearanceRow.tsx +++ b/packages/client/ui-theme/src/client/AppearanceRow.tsx @@ -12,7 +12,7 @@ import { import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { ThemePreference } from '../theme-settings.ts' import type { ThemeKey } from './locales.ts' -import type {} from './settings-contract.ts' +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type { createAppearanceRowStore } from './settings-store.ts' import css from './AppearanceRow.module.css' diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index aaa37ecca8..ed1e9dc4a5 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -9,9 +9,10 @@ */ import type { Context } from '@deepseek-ai/cordis' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' -import { - bindSettingsScope, type ClientContext, type SettingsScope, -} from '@deepseek-ai/dsh-client-runtime/client' +import type { ClientContext, SettingsScope } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: the ctx.settingsScope Context merge. Cross-plugin collaboration +// goes through the service, never a value import (client bundle purity gate). +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' import type { AppearanceRowInjected } from './AppearanceRow.tsx' @@ -212,8 +213,12 @@ export class ThemeService { } } -/** Required services: settings transport plus slots/locale for the Appearance row. */ -export const inject = ['slots', 'locale', 'connection'] +/** + * Required services: settings transport plus slots/locale for the Appearance + * row. `remote` carries the forwarded settings invalidation that + * `bindSettingsScope` subscribes to on this context. + */ +export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope'] /** * Client plugin body: provide the theme service and register the @@ -222,7 +227,7 @@ export const inject = ['slots', 'locale', 'connection'] * @param ctx - client cordis context. */ export function apply(ctx: ClientContext): void { - const host = bindSettingsScope(ctx, { namespace: THEME_SETTINGS_NAMESPACE }) + const host = ctx.settingsScope.bind({ namespace: THEME_SETTINGS_NAMESPACE }) const theme = new ThemeService(ctx, host) ctx.provide('theme', theme) diff --git a/packages/client/ui-theme/src/client/settings-contract.ts b/packages/client/ui-theme/src/client/settings-contract.ts deleted file mode 100644 index f4fc5fe8da..0000000000 --- a/packages/client/ui-theme/src/client/settings-contract.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Re-export outlet for the `settings.general.item` slot type consumed by this - * package's Appearance row. The canonical home is the locale package (the - * common dependency of every item registrant); this file exists so row - * modules import the type from within their own package. - */ -export type { SettingsGeneralItemOwnerProps } from '@deepseek-ai/dsh-client-locale/client' -// Side-effect type import: pulls the SlotMap merge into this program. -import type {} from '@deepseek-ai/dsh-client-locale/client' diff --git a/packages/client/ui-theme/tests/apply.spec.ts b/packages/client/ui-theme/tests/apply.spec.ts index 006d64d621..65009edc26 100644 --- a/packages/client/ui-theme/tests/apply.spec.ts +++ b/packages/client/ui-theme/tests/apply.spec.ts @@ -5,7 +5,8 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' -import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { SettingsScopeService } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client' import type { AppearanceRowInjected, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-settings.ts' @@ -53,6 +54,9 @@ async function bench(isLoopback = true) { }) }) ctx.provide('connection', { api: { settings: { describe, mutate } }, isLoopback } as never) + // The settings transport and the forwarded-event port the plugin injects. + new TestRemote(ctx) + await ctx.plugin(SettingsScopeService).await() return { ctx, slots: ctx.get('slots') as SlotsService, locale, describe, mutate, setHostPreference: (next: string) => { preference = next }, @@ -79,7 +83,7 @@ function faceOf(slots: SlotsService) { describe('ui-theme apply', () => { it('declares the slot and locale services', () => { - expect(inject).toEqual(['slots', 'locale', 'connection']) + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope']) }) it('provides the service, registers localized copy, and registers the row (declaration before or after apply)', async () => { @@ -128,10 +132,10 @@ describe('ui-theme apply', () => { await b.ctx.plugin({ inject: [...inject], apply }).await() const theme = b.ctx.get('theme') as ThemeService await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('dark') }) - b.ctx.emit('settings/changed', 'unrelated') + b.ctx.remote.$dispatch('settings/document-updated', ['unrelated', 0]) expect(b.describe).toHaveBeenCalledOnce() b.setHostPreference('light') - b.ctx.emit('settings/changed', THEME_SETTINGS_NAMESPACE) + b.ctx.remote.$dispatch('settings/document-updated', [THEME_SETTINGS_NAMESPACE, 0]) await vi.waitFor(() => { expect(theme.getTheme().preference).toBe('light') }) b.setHostPreference('dark') b.ctx.emit('connection/reset') diff --git a/packages/client/ui-theme/tests/invariant.spec.ts b/packages/client/ui-theme/tests/invariant.spec.ts index c559d14e0d..514b8e2f60 100644 --- a/packages/client/ui-theme/tests/invariant.spec.ts +++ b/packages/client/ui-theme/tests/invariant.spec.ts @@ -7,6 +7,7 @@ import * as ThemeInvariant from '@deepseek-ai/dsh-client-ui-theme/invariant' import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import InvariantService from '@deepseek-ai/dsh-invariants' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' describe('invariant companion', () => { it('registers under the package name with an empty installer', async () => { @@ -23,7 +24,7 @@ describe('invariant companion', () => { it('client apply provides ctx.theme over the slots/locale edges', async () => { // The feature registers its own Appearance settings row with localized // copy, hence the slots + locale edges. - expect(inject).toEqual(['slots', 'locale', 'connection']) + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope']) const ctx = new Context() new SlotsService(ctx) ctx.provide('connection', { @@ -33,6 +34,9 @@ describe('invariant companion', () => { }) } }, isLoopback: true, } as never) + // The settings row's transport and the forwarded-event port. + ctx.provide('remote', { $on: () => () => {} } as never) + ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) await ctx.plugin({ inject: localeInject, apply: localeApply }).await() await ctx.plugin({ inject, apply: clientApply }).await() expect(ctx.get('theme')).toBeInstanceOf(ThemeService) diff --git a/packages/client/ui-theme/tsconfig.json b/packages/client/ui-theme/tsconfig.json index 2cc728b31c..8da8ac5e6e 100644 --- a/packages/client/ui-theme/tsconfig.json +++ b/packages/client/ui-theme/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../ui-settings" } ] } diff --git a/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx b/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx index e006b106a8..80ee97d157 100644 --- a/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-tool/tests/assembly-surfaces.spec.tsx @@ -5,7 +5,7 @@ import { cleanup, fireEvent, waitFor } from '@testing-library/react' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts' import { toolChatSnapshot } from './tool-details-render.tsx' @@ -69,6 +69,9 @@ const LAYOUT_CHILDREN = { async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + // ui-theme's Appearance row binds a durable scope through these two. + runtime.provide('remote', { $on: () => () => {} }) + runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx index 5939655118..fbdfc4b75e 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx @@ -9,6 +9,7 @@ // (runningCalls) nest their so-far dispatches the same way. import { Context } from '@deepseek-ai/cordis' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' import { @@ -158,6 +159,9 @@ async function bench(snapshot: ConversationSnapshot) { ctx.provide('workspaces', workspaces) ctx.provide('layout', layout) ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + // ui-theme's Appearance row binds a durable scope through these two. + ctx.provide('remote', { $on: () => () => {} } as never) + ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = new LocaleService(ctx) ctx.provide('locale', locale) slots.installLocale(locale) diff --git a/packages/client/ui-tool/tests/toolview-slot.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.spec.tsx index cab91c6457..2ab4ba9b12 100644 --- a/packages/client/ui-tool/tests/toolview-slot.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.spec.tsx @@ -13,7 +13,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent } from '@testing-library/react' import type { ISession, SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyTool, inject as injectTool } from '@deepseek-ai/dsh-client-ui-tool/client' @@ -65,6 +65,9 @@ const LAYOUT_CHILDREN = { async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + // ui-theme's Appearance row binds a durable scope through these two. + runtime.provide('remote', { $on: () => () => {} }) + runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } runtime.provide('layout', layout) const locale = new LocaleService(runtime.ctx) @@ -198,6 +201,9 @@ describe('registrant declaration injection', () => { it('runs a registrant before ui-tool and waits on the actual toolview declaration', async () => { const runtime = await SlotTestRuntime.create() runtime.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + // ui-theme's Appearance row binds a durable scope through these two. + runtime.provide('remote', { $on: () => () => {} }) + runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) runtime.provide('locale', locale) diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 49b100620b..725dc11d5f 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -64,6 +64,7 @@ "@deepseek-ai/dsh-agent": "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-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index ceacfdaf09..9a4e6b3ca8 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -30,6 +30,7 @@ import { import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots' import { zh, type TrajectoryKey } from '../src/client/locales.ts' import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client' @@ -202,8 +203,11 @@ async function bench(snapshot = historySnapshot(NODES)) { slots.register( { name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never) // The locale plugin backs the locale-aware view tab label ('locale' in - // inject); its settings scope needs a connection handle. + // inject); its settings scope needs a connection handle and the + // forwarded-event port. ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + ctx.provide('remote', { $on: () => () => {} } as never) + ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) ctx.plugin({ inject: [...localeInject], apply: localeApply }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() diff --git a/packages/credentials/credentials/README.i18n.yaml b/packages/credentials/credentials/README.i18n.yaml index 756ba1ba04..703bca4432 100644 --- a/packages/credentials/credentials/README.i18n.yaml +++ b/packages/credentials/credentials/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/credentials/credentials/README.md -README.md: 5cfeb8e6656fabd638ea2126e56bc66391f0ca01 -README.zh.md: 28a029f0e4909fb14a1208458dcd1d22afe4abb9 +README.md: 62f5c6318ec04e9820a3d18ffa159d1561215760 +README.zh.md: 10a98217b3229a7d7fb372eb9ec7f418d9d4d764 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index e020c6f93f..61ffd7dcfd 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: 2101c785a613477c04ecbfec6a39a0f403af40ef -README.zh.md: 3ba37967ff88ca89911017945aeed857e4b4ff19 +README.md: 426aa32c6b56b8be597969125206b4e1e7025622 +README.zh.md: 75383798555e3b775685b926630eedd97794005a diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 0d3b68c588..426aa32c6b 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -72,7 +72,7 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Forwarded Remote events are parasitic on this legacy frame union** — `host/remote-event` lives in `HostFrame` so the delivery path could reuse the existing host stream instead of opening a third downlink, which makes it read as if this package owned the Remote event contract. It does not: the allowlist is `dsh-api-remotes`' and the consumer verb is `ctx.remote.$on`. When the host stream moves off this package, the frame moves with it and the consumer contract is unaffected ([rationale](../../../.agents/notes/proposed/architecture/2026-08-10-remote-event-delivery.md)). +- **Forwarded Remote events are parasitic on this legacy frame union** — `host/remote-event` lives in `HostFrame` so the delivery path could reuse the existing host stream instead of opening a third downlink, which makes it read as if this package owned the Remote event contract. It does not: the allowlist is `dsh-api-remotes`' and the consumer verb is `ctx.remote.$on`. When the host stream moves off this package, the frame moves with it and the consumer contract is unaffected ([rationale](../../../.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md)). - **Pending-interaction state is host-side** — the wire uses POST `/api/respond` plus `RpcReceipt`; the table in `src/api-proxy.ts` handles questions only and has no approval entries. - **Reserved seams stay out of `RpcMethodMap`** — `prompt.mode: 'inject'`, `task.list`, and a describe `hostInstanceId` are documented reservations; model discovery uses `llm.models`. An unknown method fails loud at envelope parse rather than getting a not-implemented code. - **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index e9bed071e0..7538379855 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -72,7 +72,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr ## 已知限制与暂缓事项 -- **转发的 Remote 事件寄居在这套 legacy 帧联合里**:`host/remote-event` 住在 `HostFrame` 中,是为了让投递路径复用现有宿主流、不必新开第三条下行通道,因此读起来像是本包拥有 Remote 事件契约。并非如此:名单归 `dsh-api-remotes`,消费端动词是 `ctx.remote.$on`。将来宿主流整体搬离本包时,该帧随之搬走,消费端契约不受影响([原委](../../../.agents/notes/proposed/architecture/2026-08-10-remote-event-delivery.zh.md))。 +- **转发的 Remote 事件寄居在这套 legacy 帧联合里**:`host/remote-event` 住在 `HostFrame` 中,是为了让投递路径复用现有宿主流、不必新开第三条下行通道,因此读起来像是本包拥有 Remote 事件契约。并非如此:名单归 `dsh-api-remotes`,消费端动词是 `ctx.remote.$on`。将来宿主流整体搬离本包时,该帧随之搬走,消费端契约不受影响([原委](../../../.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md))。 - **待处理交互状态位于宿主侧**:wire 使用 POST `/api/respond` 加 `RpcReceipt`;`src/api-proxy.ts` 中的表只处理问题,不包含审批条目。 - **预留 seam 不进入 `RpcMethodMap`**:`prompt.mode: 'inject'`、`task.list` 和描述字段 `hostInstanceId` 都是已记录的预留项;模型发现使用 `llm.models`。未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。 - **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 147dc9ed7d..16390a3331 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -6,7 +6,7 @@ import { randomUUID } from 'node:crypto' import { mkdir, stat } from 'node:fs/promises' import { dirname } from 'node:path' -import type { Context, Events } from '@deepseek-ai/cordis' +import type { Context } from '@deepseek-ai/cordis' import { installModelSelection } from '@deepseek-ai/dsh-agent' import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent' import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model' @@ -418,9 +418,11 @@ function frame(payload: F): RpcRequest { /** * Narrow one allowlisted host event's argument list to the JSON values the * wrapper frame carries. A rejected argument is an allowlist mistake (the - * forwarded path applies no projection), not hostile input, so it fails loud - * here rather than degrading to a dropped or lossy frame. Exported for the - * test that owns this decision: every currently allowlisted event has a + * forwarded path applies no projection), not hostile input, so it throws rather + * than degrading to a lossy frame. The throw surfaces where the forwarding + * listener runs, so the emitter's own listener containment logs it and drops + * that frame — loud in the Host log, not at load or at the emit. Exported for + * the test that owns this decision: every currently allowlisted event has a * statically JSON-safe payload, so a type-legal `ctx.emit` cannot reach the * rejection branch. * @param event - forwarded host event name, named in the failure. @@ -3471,18 +3473,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // its `host/models-changed`), which is the order a client sees. ...API_REMOTE_FORWARDED_EVENTS.map(name => ctx.on( name, - // cordis keys `on` by literal event name, so subscribing from a - // runtime list erases the handler type once. The erasure is safe - // because the allowlist's shape assertion already proves each name - // is a real, non-scoped, void-returning event, and assertJsonArgs - // proves the payload is JSON-safe before it reaches the queue. + // The allowlist's shape assertion proves each name is a real, + // non-scoped, void-returning event, so the rest-parameter handler + // satisfies every member of the union `on` accepts here; + // assertJsonArgs proves the payload is JSON-safe before it queues. ((...args: unknown[]) => { queue.push(frame({ type: 'host/remote-event', event: name, args: assertJsonArgs(name, args), })) - }) as Events[typeof name], + }), )), // The recompose itself registers nothing (it re-parents the agent's // scope onto a standing mount that may already exist), so the diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 2115d1491a..4f5d40191f 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -229,7 +229,10 @@ function forwardedSettings(ns: string): HostFrame { return { type: 'host/remote-event', event: 'settings/document-updated', - args: [ns, expect.any(Number) as unknown as number], + /* oxlint-disable-next-line typescript/no-unsafe-assignment -- + * expect.any is typed `any`; the frame's args are JsonValue[]. The + * revision is the Host's own counter, so the matcher is the assertion. */ + args: [ns, expect.any(Number)], } } diff --git a/packages/settings/settings/README.i18n.yaml b/packages/settings/settings/README.i18n.yaml index fba3913f75..b6e05fac88 100644 --- a/packages/settings/settings/README.i18n.yaml +++ b/packages/settings/settings/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/settings/settings/README.md -README.md: 7917f38017bfb23dc4718ee533c1f9a92b519d41 -README.zh.md: f46cf433b4d2207b17b0f40cc3b9cf70794b512c +README.md: b1c32c7f4cd05b05802400b33dfdd40f7ed20e61 +README.zh.md: 47fa75d13ad607554de1aa51de03557a769e2366 diff --git a/packages/typert/type-meta/README.i18n.yaml b/packages/typert/type-meta/README.i18n.yaml index 99482aeefc..764bd5c5ac 100644 --- a/packages/typert/type-meta/README.i18n.yaml +++ b/packages/typert/type-meta/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/typert/type-meta/README.md -README.md: 5bf35dabf80e035ec8eeeef1598873bb4032fa98 -README.zh.md: 68b690d2143b4420f2e10efceaca0dfc1149d0ff +README.md: 658baa8bae68c33acf3fe22dba5bf463356a551b +README.zh.md: 7c0fb603e8a235aa9786bcd2721a7d7626703d46 diff --git a/packages/typert/type-meta/README.md b/packages/typert/type-meta/README.md index 33524355f0..658baa8bae 100644 --- a/packages/typert/type-meta/README.md +++ b/packages/typert/type-meta/README.md @@ -20,7 +20,7 @@ Decorator initializers retain markers in a module-private `WeakMap` keyed by the Business packages extend `TypeRTLookupMap` and `TypeRTContextMap` to associate Host objects or scoped Contexts with their wire identities. Generated artifacts extend `TypeRTRemoteMap`, `TypeRTRemoteScopeMap`, and `TypeRTRemoteNamespaceMap` so Client imports expose only selected Remote methods. `InvocationDescriptor` is the shared runtime form consumed by the registry, Gateway, and Client Remote. -The Host assembly extends `TypeRTRemoteEventSelection` with the Host events it forwards to consumers, which narrows the `ctx.remote.$on` key face; `TypeRTForwardableEvent` states the shapes a one-way delivery can carry at all, excluding Scope-bound and answered events. The `remote/host-event` Cordis event is declared here because both compilation faces share this package, but only the consumer side participates: the Client half owning the host frame sink emits it and the Client Remote service is its only subscriber. +The Host assembly extends `TypeRTRemoteEventSelection` with the Host events it forwards to consumers, which narrows the `ctx.remote.$on` key face; `TypeRTForwardableEvent` states the shapes a one-way delivery can carry at all, excluding Scope-bound and answered events. `TypeRTClientRemote` carries both roles of that surface: consumers subscribe through `$on`, and the Client half owning the host frame sink hands frames over through `$dispatch`. Lookup and Context packages own both sides of their contract: declaration merging supplies the static association, while runtime providers register identity resolution with `ctx.typert`. A lookup or Host Context provider supplies the stable declaration and default resolver, while Host composition may separately configure a synchronous or asynchronous resolver; policy rejections may use `TypeRTLookupFailure` to carry a failure value owned by the boundary adapter. Strict codecs carry generated schemas; `src-json` codecs identify the weaker source-launch path. diff --git a/packages/typert/type-meta/README.zh.md b/packages/typert/type-meta/README.zh.md index 1e7f434a72..7c0fb603e8 100644 --- a/packages/typert/type-meta/README.zh.md +++ b/packages/typert/type-meta/README.zh.md @@ -20,7 +20,7 @@ Host 方法通过将 `signal: AbortSignal` 声明为最后一个参数来启用 业务包扩展 `TypeRTLookupMap` 和 `TypeRTContextMap`,以关联 Host 对象或作用域 Context 与其协议身份。生成的产物扩展 `TypeRTRemoteMap`、`TypeRTRemoteScopeMap` 和 `TypeRTRemoteNamespaceMap`,使 Client 导入后仅暴露选定的 Remote 方法。`InvocationDescriptor` 是供注册表、Gateway 和 Client Remote 使用的共享运行时形式。 -Host 装配扩展 `TypeRTRemoteEventSelection` 来声明转发给消费端的 Host 事件,从而收窄 `ctx.remote.$on` 的键面;`TypeRTForwardableEvent` 陈述单向投递根本能承载哪些形状,把 Scope 化事件与有返回值的事件排除在外。`remote/host-event` 这条 Cordis 事件声明在此包,是因为两个编译面共用它,但只有消费端参与:持有 Host 帧 sink 的 Client 半发射它,Client Remote 服务是唯一的订阅方。 +Host 装配扩展 `TypeRTRemoteEventSelection` 来声明转发给消费端的 Host 事件,从而收窄 `ctx.remote.$on` 的键面;`TypeRTForwardableEvent` 陈述单向投递根本能承载哪些形状,把 Scope 化事件与有返回值的事件排除在外。`TypeRTClientRemote` 承载该面的两种角色:消费方经 `$on` 订阅,持有 Host 帧 sink 的 Client 半经 `$dispatch` 交出帧。 查找包与 Context 包同时负责其约定的两侧:声明合并提供静态关联,运行时提供方则向 `ctx.typert` 注册身份解析。lookup 或 Host Context provider 提供稳定声明与默认 resolver,Host 组合可以另行配置同步或异步 resolver;策略拒绝可用 `TypeRTLookupFailure` 携带由边界适配器拥有的失败值。严格编解码器携带生成的 schema;`src-json` 编解码器标识约束更弱的源码启动路径。 diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index a4899a7249..c08fb209ee 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -211,6 +211,18 @@ export interface TypeRTClientRemote extends TypeRTRemoteNamespaceMap { * @returns disposer owned by the calling fiber. */ $on(event: Event, listener: Events[Event]): () => void + /** + * Hand one decoded forwarded frame to the subscription table. The carrier + * owning the Host frame sink calls this; a consumer subscribes with + * {@link TypeRTClientRemote.$on} and never calls it. + * + * `event` is a plain string because this is the wire boundary: the name is + * whatever the Host assembly's allowlist selected, and one nobody subscribed + * to is dropped silently. + * @param event - forwarded Host event name, exactly as the Host emitted it. + * @param args - the Host argument list, already JSON-decoded. + */ + $dispatch(event: string, args: readonly unknown[]): void } /** @@ -452,18 +464,4 @@ declare module '@deepseek-ai/cordis' { interface Context { typert: TypeRTService } - - interface Events { - /** - * The carrier received one allowlisted host event forwarded over the wire. - * Declared here because both compilation faces share this package; only the - * consumer side participates, where the Client half owning the host frame - * sink emits it and the Remote service is its only subscriber, turning it - * into `$on` callbacks. The Host neither emits nor observes it. - * @mode emit - * @param event - forwarded host event name, exactly as the Host emitted it. - * @param args - the Host argument list, already JSON-decoded. - */ - 'remote/host-event'(event: string, args: readonly unknown[]): void - } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index adb6aa6c5c..c5de1e08c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,9 +150,6 @@ importers: '@deepseek-ai/dsh-base': specifier: workspace:^ version: link:../../packages/bundle/base - '@deepseek-ai/dsh-client-ui-agent-preset': - specifier: workspace:^ - version: link:../../packages/client/ui-agent-preset '@deepseek-ai/dsh-cmdline': specifier: workspace:^ version: link:../../packages/boot/cmdline @@ -825,6 +822,15 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:^ + version: link:../gateway + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../interaction/commands + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../../credentials/credentials '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal @@ -837,6 +843,9 @@ importers: '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../../session/session-persistence + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../../typert/registry @@ -1735,12 +1744,18 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../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 @@ -1780,9 +1795,6 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection - '@deepseek-ai/dsh-client-schema-form': - specifier: workspace:^ - version: link:../schema-form '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -1823,6 +1835,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:^ + version: link:../../api/gateway '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -1835,9 +1850,6 @@ importers: '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../../typert/registry - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -1903,6 +1915,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -1949,6 +1964,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -2001,6 +2019,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment @@ -2025,6 +2046,9 @@ importers: '@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-slash': specifier: workspace:^ version: link:../ui-slash @@ -2224,6 +2248,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -2266,6 +2293,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -2278,12 +2308,18 @@ importers: '@deepseek-ai/dsh-client-schema-form': specifier: workspace:^ version: link:../schema-form + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime '@deepseek-ai/dsh-client-ui-command': specifier: workspace:^ version: link:../ui-command '@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-slash': specifier: workspace:^ version: link:../ui-slash @@ -2474,44 +2510,43 @@ importers: version: 18.3.31 packages/client/ui-settings: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-locale': + '@deepseek-ai/dsh-api-gateway': specifier: workspace:^ - version: link:../locale + version: link:../../api/gateway + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime - '@deepseek-ai/dsh-client-ui-primitives': + '@deepseek-ai/dsh-client-schema-form': specifier: workspace:^ - version: link:../ui-primitives - '@deepseek-ai/dsh-client-ui-sidebar': + version: link:../schema-form + '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ - version: link:../ui-sidebar + version: link:../test-runtime '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@types/react': specifier: ~18.3.1 version: 18.3.31 - '@types/react-dom': - specifier: ~18.3.0 - version: 18.3.7(@types/react@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-settings-general: dependencies: @@ -2521,10 +2556,16 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + clsx: + specifier: ^2.0.0 + version: 2.1.1 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-connection': specifier: workspace:^ version: link:../connection @@ -2543,6 +2584,9 @@ importers: '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-sidebar': + specifier: workspace:^ + version: link:../ui-sidebar '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2782,6 +2826,9 @@ importers: '@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 @@ -2794,6 +2841,9 @@ importers: '@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 @@ -2880,6 +2930,9 @@ importers: '@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-conversation': specifier: workspace:^ version: link:../ui-conversation diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index e1c65bc8cd..b94eaa80b7 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -122,6 +122,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { apiProxy: 'interface-typed (ApiProxy) with the class in api-proxy.ts, not index.ts — packages/host/apiproxy/README.md owns the API', appShell: 'client-side interface-typed browser service — packages/client/web/README.md owns the API', connection: 'client-side interface-typed browser service — packages/client/connection/README.md owns the API', + settingsScope: 'client-side settings-namespace transport service — packages/client/ui-settings/README.md owns the API', chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-conversation/README.md owns the API', command: 'client-side interface-typed browser service — packages/client/ui-command/README.md owns the API', conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the API', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 4685e045ba..84e7ca6912 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1483,7 +1483,7 @@ { "doc": "docs/subsystems/settings.md", "symbol": "SettingsNamespace", - "source": "packages/settings/settings/src/index.ts" + "source": "packages/settings/settings/src/types.ts" }, { "doc": "docs/subsystems/settings.md", @@ -1508,7 +1508,7 @@ { "doc": "docs/subsystems/settings.md", "symbol": "SettingsUpdateSource", - "source": "packages/settings/settings/src/index.ts" + "source": "packages/settings/settings/src/types.ts" }, { "doc": "docs/subsystems/credentials.md", @@ -1713,7 +1713,7 @@ { "doc": "docs/subsystems/credentials.md", "symbol": "CredentialRef", - "source": "packages/credentials/credentials/src/index.ts" + "source": "packages/credentials/credentials/src/types.ts" }, { "doc": "docs/subsystems/permission.md", From e874e8001276333babd07ac922edd812fc4db18a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:31:13 +0800 Subject: [PATCH 19/31] fix: test --- packages/api/gateway/src/client/index.ts | 1 + .../client/ui-settings/src/client/index.ts | 6 +++- .../client/ui-settings/tests/plugin.spec.ts | 29 +++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 packages/client/ui-settings/tests/plugin.spec.ts diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index c064a3eef9..745984a609 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -119,6 +119,7 @@ class ClientRemoteService extends Service implements TypeRTClientRemote { listeners.push(subscription) return () => { const at = listeners.indexOf(subscription) + /* v8 ignore next -- listener */ if (at >= 0) listeners.splice(at, 1) } }, `api-gateway.client.$on(${JSON.stringify(event)})`) diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 30f0c41af1..d671654d37 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -25,8 +25,12 @@ export const inject = [] /** * Provide the settings-namespace scope service. + * + * `Service` registers itself under its own name from its constructor, so the + * class mounts as a plugin; a second `ctx.provide` for the same name would + * throw. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - ctx.provide('settingsScope', new SettingsScopeService(ctx)) + ctx.plugin(SettingsScopeService) } diff --git a/packages/client/ui-settings/tests/plugin.spec.ts b/packages/client/ui-settings/tests/plugin.spec.ts new file mode 100644 index 0000000000..3720d9f0a9 --- /dev/null +++ b/packages/client/ui-settings/tests/plugin.spec.ts @@ -0,0 +1,29 @@ +/** + * The settings domain base plugin's own mounting behavior: it stands up + * `ctx.settingsScope` for every feature that owns a preference row, and the + * service retires with its fiber. + */ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import { apply, inject, SettingsScopeService } from '../src/client/index.ts' + +/** Boot the browser half over a bare root context; it injects nothing. */ +function bench() { + const ctx = new Context() + return { ctx, fiber: ctx.plugin({ inject: [...inject], apply }) } +} + +describe('settings domain base plugin', () => { + it('mounts the scope service under settingsScope', async () => { + const { ctx, fiber } = bench() + await fiber.await() + expect(ctx.get('settingsScope')).toBeInstanceOf(SettingsScopeService) + }) + + it('fiber disposal retires the service', async () => { + const { ctx, fiber } = bench() + await fiber.await() + await fiber.dispose() + expect(ctx.get('settingsScope')).toBeUndefined() + }) +}) From 364f7c70766a6112bdcedc7ffc29b0f83fadf24f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:53:19 +0800 Subject: [PATCH 20/31] docs: compile the note's type-contract and allowlist examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The six `ignore-check` fences this note added pushed doc-typecheck's repo-wide opt-out ratio past its 50% ceiling. Two of them were whole compilable units all along: the type-meta declarations only lacked the cordis `Events` import, and the allowlist example compiles as one module once the value and its derived type face sit in the same block. The four that remain are genuine fragments — two interface member signatures, a union arm, and an expression whose operands are declared elsewhere. --- .../architecture/2026-08-10-remote-event-delivery.i18n.yaml | 4 ++-- .../architecture/2026-08-10-remote-event-delivery.md | 6 ++++-- .../architecture/2026-08-10-remote-event-delivery.zh.md | 6 ++++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml index 2cc3f97d27..43be087974 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md -2026-08-10-remote-event-delivery.md: 58af845e66e24f49a484ae2cb216e995417a5df5 -2026-08-10-remote-event-delivery.zh.md: dd96e6082d0eedd541e128b059abaa195a6035a2 +2026-08-10-remote-event-delivery.md: 81a6805e0f4c46e558784eeebfa51bbcd4b7ad5d +2026-08-10-remote-event-delivery.zh.md: 0d901d65b0a9383fa7304925103c075edce4620d diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md index 58af845e66..81a6805e0f 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md @@ -32,7 +32,9 @@ The three **pure passthrough** events ride this path, and their `HostFrame` vari type-meta gains one **shape predicate**, one **selection seat**, and **one** member on `TypeRTClientRemote`. No runtime code: -```ts ignore-check +```ts +import type { Events } from '@deepseek-ai/cordis' + /** Cordis events shaped for one-way remote delivery: no Scope binding, void return. */ export type TypeRTForwardableEvent = { [Event in keyof Events]: unknown extends ThisParameterType @@ -68,7 +70,7 @@ Delivery shares no implementation with the cordis event system: one-way only, no `packages/api/remotes/src/remote-events.ts` is listed in the `files` of both `tsconfig.host.json` and `tsconfig.client.json`, and is the allowlist's single home; `src/types.ts` derives its type face: -```ts ignore-check +```ts // remote-events.ts — the value export const API_REMOTE_FORWARDED_EVENTS = [ 'commands/change', diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md index dd96e6082d..0d901d65b0 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md @@ -32,7 +32,9 @@ Host 上一族「注册表变了,重新拉一次」的纯失效事件(`comma type-meta 加一个**形状谓词**、一个**选择座位**和 `TypeRTClientRemote` 的**一个**成员;零运行时代码: -```ts ignore-check +```ts +import type { Events } from '@deepseek-ai/cordis' + /** Cordis events shaped for one-way remote delivery: no Scope binding, void return. */ export type TypeRTForwardableEvent = { [Event in keyof Events]: unknown extends ThisParameterType @@ -68,7 +70,7 @@ $dispatch(event: string, args: readonly unknown[]): void `packages/api/remotes/src/remote-events.ts` 同时列进 `tsconfig.host.json` 与 `tsconfig.client.json` 的 `files`,是名单的**唯一家**;`src/types.ts` 由它派生类型面: -```ts ignore-check +```ts // remote-events.ts — the value export const API_REMOTE_FORWARDED_EVENTS = [ 'commands/change', From 32378d27b4f2f4745ca54efb4f3ef67eca9f51d4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:53:34 +0800 Subject: [PATCH 21/31] docs(ui-settings): describe the settings domain base and its shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SettingsRoot` and the `sidebar.settings` occupancy moved to ui-settings-general, but both READMEs still read as if ui-settings were the shell. ui-settings now documents what it actually is — the `ctx.settingsScope` transport plus the slot type declarations, with the bind ownership, refresh triggers, write revision handling, and decode refusal a preference row must know — and records the loopback-only and single-field write gaps in place of two limitations that no longer exist. ui-settings-general documents the shell it now renders. The stale module JSDoc on both halves follows. --- .../client/ui-settings-general/README.i18n.yaml | 4 ++-- packages/client/ui-settings-general/README.md | 4 +++- packages/client/ui-settings-general/README.zh.md | 4 +++- .../ui-settings-general/src/client/index.ts | 10 ++++++---- packages/client/ui-settings/README.i18n.yaml | 4 ++-- packages/client/ui-settings/README.md | 9 +++++---- packages/client/ui-settings/README.zh.md | 9 +++++---- .../ui-settings/src/client/contract/slots.ts | 15 ++++++++------- packages/client/ui-settings/src/index.ts | 2 +- 9 files changed, 35 insertions(+), 26 deletions(-) diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 87ff4b0946..9259a6c652 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/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-settings-general/README.md -README.md: 93bb2b04982a0e7bc8a248c1127428aec509773f -README.zh.md: 5cf5da2f92a0b5a1f53f3306665c3c84381a0e85 +README.md: 0cdffdfaad20784535a7ed010ad4b71d63a0a2c1 +README.zh.md: 0b72b3db96b335f0c288758b3f7a8b56441ee88c diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 93bb2b0498..0cdffdfaad 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -2,7 +2,9 @@ English | [中文](README.zh.md) -Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages. +Settings shell, ownerless-copy, and product-onboarding plugin. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, projects the `settings.section` ledger into the navigation and the `settings.onboarding` ledger into one mounted page at a time, and registers everything on the Settings pages that belongs to no single feature — the trigger/header/close chrome content, the local configuration-file action, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. The slot types it renders into belong to ui-settings, the settings domain base; only the shell's own contract types live here, because they reference ui-sidebar's slot type and the base layer must depend on no `ui-*` package. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages. + +The shell ships no copy of its own — all text arrives from registrants. Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency). The onboarding ledger projects in ascending order and mounts exactly one page at a time; the takeover chrome (body-level stage, mask, app-root `inert`) belongs to the step itself through ui-primitives' `OnboardingSurface`, so a mounted step still resolving its private facts renders null and neither paints nor blocks anything — the shell shows no empty stage while a step decides. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, mutations, and the surface wrap, so independently registered flows cannot stack and the shell does not become a second configuration fact source. A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 5cf5da2f92..0b72b3db96 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -2,7 +2,9 @@ [English](README.md) | 中文 -设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容、本地配置文件操作,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。 +设置外壳、无特定功能归属文案与产品引导插件。它以触发控件和模态设置面板占用 `sidebar.settings`,把 `settings.section` 账本投影成导航、把 `settings.onboarding` 账本投影成每次只挂载一页的引导流程,并在设置页面上注册所有不属于单一功能的内容:触发器、标题栏与关闭控件内容、本地配置文件操作,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。它渲染进的那些 slot 类型归 ui-settings——设置领域底座——所有;只有外壳自身的契约类型放在这里,因为它们引用 ui-sidebar 的 slot 类型,而底座不得依赖任何 `ui-*` 包。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。 + +外壳不自带文案:所有文本都来自注册方。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。首次使用引导记录按升序投影,每次只挂载一个页面;接管界面框架(body 层级的展示层、遮罩、应用根节点 `inert`)经 ui-primitives 的 `OnboardingSurface` 由步骤自身持有,因此已挂载但仍在判定私有事实的步骤渲染 null 时不绘制也不阻塞任何内容——步骤判定期间外壳不会露出空白展示层。当前注册方会收到该条目的 id、`complete()` 和 `openSection(id)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案、变更操作以及展示层包装均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。 回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权设置读取。 diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 947e278f99..1c207efc76 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -1,8 +1,10 @@ /** - * Settings ownerless-copy plugin, browser half: registers everything on the - * Settings surface that belongs to no single feature — the trigger/header - * chrome content, local-document action, General section, and `settings` - * dictionaries. Feature-owned rows and sections stay with their features. + * Settings shell and ownerless-copy plugin, browser half: renders the + * `sidebar.settings` occupant — panel chrome, section navigation, and the + * onboarding stage — and registers everything on the Settings pages that + * belongs to no single feature: the trigger/header chrome content, + * local-document action, General section, and `settings` dictionaries. + * Feature-owned rows and sections stay with their features. * Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index 5954e72809..392f2b52a1 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/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-settings/README.md -README.md: 785f0417f00ec8eb1f8c9273b4d81f8ca5ca1810 -README.zh.md: 239987e379aaabd2edbfc9e09cd2fc8cef7a685e +README.md: 8bf085fd02e3c76148674065bb4f5708e9a6e8d8 +README.zh.md: f7b6f18809c64be6830ea23c3968e9af70b70c41 diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index 785f0417f0..8bf085fd02 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.action` (ordered content-header actions), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned pages in a full-viewport stage). The shell ships no copy of its own — all text arrives from registrants (ui-settings-general owns chrome, General, and the product notice; features own their actions, sections, rows, and conditional onboarding pages). Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency). +The settings domain's base layer, with two roles and no presentation of its own. It provides `ctx.settingsScope`, the Host transport every preference row binds its durable namespace section through, and it declares the settings slot types registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.action` (ordered content-header actions), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned pages). It depends on no `ui-*` presentation package, so any feature that owns a preference can reach it; the settings SHELL — the `sidebar.settings` occupant, its navigation, and the chrome — lives in ui-settings-general, because a shell dependency on ui-sidebar would close a reference graph cycle through ui-layout and ui-theme. The shell's own contract types live beside the shell for the same reason. -The shell projects the onboarding ledger into ascending order and mounts exactly one page at a time; the takeover chrome (body-level stage, mask, app-root `inert`) belongs to the step itself through ui-primitives' `OnboardingSurface`, so a mounted step still resolving its private facts renders null and neither paints nor blocks anything — the shell shows no empty stage while a step decides. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, mutations, and the surface wrap, so independently registered flows cannot stack and the shell does not become a second configuration fact source. +The plugin injects nothing and waits for nothing: `ctx.settingsScope.bind(spec)` resolves the wire face through the CALLER's context at call time, so the bound scope's disposer belongs to the calling fiber, and the caller injects `connection` for the transport and `remote` for the invalidation. Listeners exist before the first background read starts, so a row's activation never blocks on the settings transport. A bound scope reloads on the forwarded `settings/document-updated` event for its own namespace and on `connection/reset`. Writes carry one field path and the last known namespace revision as `expectedRevision`; a rejected or failed write re-reads unless a newer write already superseded it, and a stale read never publishes over a newer one. Without a `decode` in the spec, a section that is not a plain object, fails its rehydrated schema, or carries a schema envelope this client cannot rehydrate publishes no value at all, so a row renders its own absent state instead of a half-decoded one. ## Model Experience -None, as the settings shell serves browser UI composition; nothing here reaches a model request. +None, as the settings domain base serves browser preference storage and slot declarations; nothing here reaches a model request. #### KV Cache effect @@ -16,4 +16,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Panel is browser-preference scope only** — host-side settings surfaces (permission mode, tool-call mode) have no RPC backing yet; their skeletons live in ui-settings-general. +- **Remote browsers get no durable settings** — the settings RPCs are loopback-only, so a scope bound in a non-loopback browser starts `unavailable` and never crosses the wire; every row it backs is inert there. +- **One field per write** — `set` sends a single `set` op, so a row that must move two fields together has no transaction and publishes two revisions. diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index 239987e379..f7b6f18809 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.action`(内容标题栏中的有序操作)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、显示在全视口展示层中的有序页面)。外壳不自带文案:所有文本都来自注册方(ui-settings-general 拥有界面框架、「通用」分区和产品声明;各功能拥有各自的操作、分区、行和条件式首次使用引导页面)。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。 +设置领域的底座,承担两项职责,本身不含任何呈现内容。它提供 `ctx.settingsScope`——每个偏好设置行绑定自己那份持久化命名空间分区所用的宿主传输层;并声明由注册方填充的设置 slot 类型:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.action`(内容标题栏中的有序操作)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有的有序页面)。它不依赖任何 `ui-*` 呈现包,因此任何持有偏好设置的功能都能够到它;设置**外壳**——`sidebar.settings` 占位方、它的导航与界面框架——位于 ui-settings-general,因为外壳一旦依赖 ui-sidebar,就会经 ui-layout 与 ui-theme 闭合出一条引用图环路。外壳自身的契约类型出于同一原因与外壳放在一起。 -外壳将首次使用引导记录按升序投影,每次只挂载一个页面;接管界面框架(body 层级的展示层、遮罩、应用根节点 `inert`)经 ui-primitives 的 `OnboardingSurface` 由步骤自身持有,因此已挂载但仍在判定私有事实的步骤渲染 null 时不绘制也不阻塞任何内容——步骤判定期间外壳不会露出空白展示层。当前注册方会收到该条目的 id、`complete()` 和 `openSection(id)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案、变更操作以及展示层包装均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。 +该插件不注入任何服务、也不等待任何服务:`ctx.settingsScope.bind(spec)` 在调用时经**调用方**的 context 解析线路面,因此绑定所得 scope 的 disposer 归调用方 fiber 所有,而由调用方注入 `connection` 取得传输层、注入 `remote` 取得失效通知。监听器在首次后台读取启动之前就已存在,因此某一行的激活绝不会阻塞在设置传输层上。已绑定的 scope 会在收到属于自己命名空间的转发 `settings/document-updated` 事件时、以及在 `connection/reset` 时重新读取。写入携带单一字段路径以及最近已知的命名空间 revision 作为 `expectedRevision`;被拒绝或失败的写入会重新读取,除非已有更新的写入取代了它,而过期的读取绝不会覆盖发布更新的结果。若 spec 未提供 `decode`,则分区不是普通对象、未通过其重建后的 schema 校验、或携带本客户端无法重建的 schema 信封时,一律不发布任何值,于是行渲染自己的缺失状态,而不是一份半解码的值。 ## 模型体验 -无。设置外壳为浏览器 UI 提供组合能力;这里没有任何内容进入模型请求。 +无。设置领域底座为浏览器提供偏好设置存储与 slot 声明;这里没有任何内容进入模型请求。 #### KV Cache 影响 @@ -16,4 +16,5 @@ ## 已知限制与暂缓事项 -- **面板仅涵盖浏览器偏好设置**:宿主侧设置表层(权限模式、工具调用模式)尚无 RPC 支撑;其骨架位于 ui-settings-general。 +- **远程浏览器没有持久化设置**:设置 RPC 仅限 loopback,因此在非 loopback 浏览器中绑定的 scope 以 `unavailable` 起步且从不跨线路,它支撑的每一行在那里都是无效的。 +- **每次写入仅一个字段**:`set` 只发送单个 `set` op,因此需要同时改动两个字段的行没有事务可用,会发布两个 revision。 diff --git a/packages/client/ui-settings/src/client/contract/slots.ts b/packages/client/ui-settings/src/client/contract/slots.ts index 903e1b0225..a754570859 100644 --- a/packages/client/ui-settings/src/client/contract/slots.ts +++ b/packages/client/ui-settings/src/client/contract/slots.ts @@ -1,11 +1,12 @@ /** - * Settings shell slot contract — the canonical home of every settings slot - * type. The shell is a pure composition face with zero copy of its own: it - * occupies the sidebar-owned `sidebar.settings` hole and declares the slots - * below; ALL text (trigger label, panel title, header actions, close aria, - * section content) arrives from registrants. A feature owns its settings surface — adding a - * setting never means editing the shell; copy that belongs to no single - * feature (chrome, the General section) is owned by ui-settings-general. + * Settings slot contract — the canonical home of every settings slot type, + * owned by the settings domain base rather than by the shell that renders + * them (ui-settings-general, which occupies `sidebar.settings`). The shell has + * zero copy of its own: ALL text (trigger label, panel title, header actions, + * close aria, section content) arrives from registrants. A feature owns its + * own settings pages — adding a setting never means editing the shell; copy + * that belongs to no single feature (chrome, the General section) is owned by + * ui-settings-general too. */ diff --git a/packages/client/ui-settings/src/index.ts b/packages/client/ui-settings/src/index.ts index 52fe4c43e6..80dd81b7b2 100644 --- a/packages/client/ui-settings/src/index.ts +++ b/packages/client/ui-settings/src/index.ts @@ -1,4 +1,4 @@ /** Host loader entry for the browser implementation exported from `./client`. */ -/** Host plugin body — no host-side behavior for the settings shell plugin. */ +/** Host plugin body — no host-side behavior for the settings domain base plugin. */ export function apply(): void {} From b7c99b696c66ec6b833f1dccb08ecc6c2318b1d8 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:07:08 +0800 Subject: [PATCH 22/31] test(web): mount the settings domain base in the assembled boot table `assembled-boot.ts` hand-maintains the boot entries for the built-bundle jsdom graph, and this branch made the locale and ui-theme rows inject `settingsScope`. Nothing in that table provided it, so both stayed pending, ui-layout never activated behind them, and all four assembled snapshots timed out waiting for the first rendered row. Adds the ui-settings entry ahead of them (it injects nothing itself) and records `api-gateway` on the runtime entry, which now needs `remote`. --- apps/web/tests/assembled-boot.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index eb22d8319c..dfda1040a6 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -20,9 +20,13 @@ const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [ { id: '@deepseek-ai/dsh-client-connection', bundlePath: 'packages/client/connection/lib/client.js', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-api-gateway', bundlePath: 'packages/api/gateway/lib/client.js', url: '/plugins/api-gateway.js', rev: 'fx', inject: ['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection'], immediately: true }, { id: '@deepseek-ai/dsh-api-remotes', bundlePath: 'packages/api/remotes/lib/client.js', url: '/plugins/api-remotes.js', rev: 'fx', inject: ['@deepseek-ai/dsh-api-gateway'], immediately: true }, - { id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-typert-registry'], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-theme', bundlePath: 'packages/client/ui-theme/lib/client.js', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-locale', bundlePath: 'packages/client/locale/lib/client.js', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true }, + // The settings domain base: the only provider of ctx.settingsScope, which the + // locale and ui-theme rows below inject for their preference rows. Without it + // both stay pending and ui-layout never activates, so nothing renders. + { id: '@deepseek-ai/dsh-client-ui-settings', bundlePath: 'packages/client/ui-settings/lib/client.js', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-api-remotes'], immediately: true }, + { id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-api-gateway'], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-theme', bundlePath: 'packages/client/ui-theme/lib/client.js', url: '/plugins/ui-theme.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-api-remotes'], immediately: true }, + { id: '@deepseek-ai/dsh-client-locale', bundlePath: 'packages/client/locale/lib/client.js', url: '/plugins/locale.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-api-remotes'], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-layout', bundlePath: 'packages/client/ui-layout/lib/client.js', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, { id: '@deepseek-ai/dsh-client-ui-sidebar', bundlePath: 'packages/client/ui-sidebar/lib/client.js', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-conversation', bundlePath: 'packages/client/ui-conversation/lib/client.js', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, From 80f1a064f542152719db8532e96914675a695795 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:11:46 +0800 Subject: [PATCH 23/31] fix: dep --- packages/client/locale/package.json | 3 +-- packages/client/ui-conversation/package.json | 3 +-- packages/client/ui-permission/package.json | 3 +-- packages/client/ui-settings/package.json | 3 +-- packages/client/ui-theme/package.json | 3 +-- 5 files changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 0b8c0a6c7e..cc527dee85 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -54,7 +54,7 @@ "react": "^18.2.0" }, "devDependencies": { - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", @@ -62,7 +62,6 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "dependencies": { diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index e11cec8289..710cfa81bb 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -74,7 +74,7 @@ "react": "^18.2.0" }, "devDependencies": { - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", @@ -100,7 +100,6 @@ "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-permission/package.json b/packages/client/ui-permission/package.json index 01e25b0d0a..5e5c390cc2 100644 --- a/packages/client/ui-permission/package.json +++ b/packages/client/ui-permission/package.json @@ -64,7 +64,7 @@ "react": "^18.2.0" }, "devDependencies": { - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -80,7 +80,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index d281c7d724..56009fca92 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -57,7 +57,7 @@ "react": "^18.2.0" }, "devDependencies": { - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", @@ -68,7 +68,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 7a5e6b37cf..f2b8a683be 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -58,7 +58,7 @@ "react": "^18.2.0" }, "devDependencies": { - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", @@ -69,7 +69,6 @@ "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ From e0bbe42242f4f46111f802e20b7d82f5da99831f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:50:03 +0800 Subject: [PATCH 24/31] refactor(web): consume owner remote events directly --- .../2026-07-30-config-plane-boundaries.md | 2 +- .../2026-07-30-config-plane-boundaries.zh.md | 2 +- .../2026-07-30-web-config-plane.md | 2 +- .../2026-07-30-web-config-plane.zh.md | 2 +- .../2026-08-10-remote-event-delivery.md | 27 +++--- .../2026-08-10-remote-event-delivery.zh.md | 17 ++-- ...-10-slash-catalog-follows-preset-switch.md | 14 +-- ...-slash-catalog-follows-preset-switch.zh.md | 14 +-- ...-31-permission-default-for-new-sessions.md | 2 +- ...-permission-default-for-new-sessions.zh.md | 2 +- docs/event-producer-consumer.md | 2 - docs/event-producer-consumer.zh.md | 2 - packages/api/remotes/README.md | 8 +- packages/api/remotes/README.zh.md | 8 +- packages/api/remotes/package.json | 4 + packages/api/remotes/src/client/index.ts | 2 + packages/api/remotes/src/index.ts | 2 + packages/api/remotes/src/remote-events.ts | 2 + packages/api/remotes/tsconfig.client.json | 6 ++ packages/api/remotes/tsconfig.host.json | 6 ++ packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- packages/client/runtime/src/client/index.ts | 22 ----- .../runtime/src/client/sessions/manager.ts | 8 -- .../runtime/tests/sessions-service.spec.ts | 17 ---- .../client/runtime/tests/wire-events.spec.ts | 30 ++---- packages/client/ui-agent-preset/README.md | 2 +- packages/client/ui-agent-preset/README.zh.md | 2 +- .../ui-agent-preset/src/client/index.ts | 6 ++ .../ui-agent-preset/tests/apply.spec.ts | 24 +++++ packages/client/ui-command/README.md | 2 +- packages/client/ui-command/README.zh.md | 2 +- .../client/ui-command/src/client/service.ts | 2 +- .../client/ui-command/tests/service.spec.ts | 4 +- packages/client/ui-model/README.md | 2 + packages/client/ui-model/README.zh.md | 2 + packages/client/ui-model/package.json | 6 +- packages/client/ui-model/src/client/index.ts | 4 +- .../client/ui-model/src/client/service.ts | 13 +-- .../ui-model/tests/browser-plugin.spec.ts | 6 +- packages/client/ui-model/tsconfig.json | 3 + packages/client/ui-models/README.md | 2 +- packages/client/ui-models/README.zh.md | 2 +- packages/client/ui-models/src/client/index.ts | 2 +- packages/client/ui-models/tests/apply.spec.ts | 2 +- packages/client/ui-skill/README.md | 2 +- packages/client/ui-skill/README.zh.md | 2 +- packages/client/ui-skill/package.json | 5 +- packages/client/ui-skill/src/client/index.ts | 6 +- .../ui-skill/tests/browser-plugin.spec.ts | 9 +- packages/client/ui-skill/tsconfig.json | 3 + packages/host/apiproxy/README.md | 4 +- packages/host/apiproxy/README.zh.md | 4 +- packages/host/apiproxy/src/api-proxy.ts | 38 +------- .../host/apiproxy/src/api/events.schema.ts | 2 - packages/host/apiproxy/src/api/events.ts | 19 ---- packages/host/apiproxy/src/api/llm.ts | 3 +- .../tests/api-proxy-agent-preset.spec.ts | 11 ++- .../apiproxy/tests/api-proxy-config.spec.ts | 29 +++--- .../host/apiproxy/tests/rpc-schemas.spec.ts | 3 +- packages/llm/llm/src/index.ts | 10 -- packages/llm/llm/src/types.ts | 15 +++ packages/preset/agent-presets/README.md | 2 +- packages/preset/agent-presets/README.zh.md | 2 +- packages/preset/agent-presets/package.json | 5 + .../preset/agent-presets/src/authoring.ts | 2 +- .../preset/agent-presets/src/discovery.ts | 2 +- packages/preset/agent-presets/src/index.ts | 14 ++- packages/preset/agent-presets/src/mount.ts | 2 +- packages/preset/agent-presets/src/preset.ts | 88 +++++++++++++++++ packages/preset/agent-presets/src/types.ts | 97 +++---------------- pnpm-lock.yaml | 15 +++ scripts/gen-cordis-catalog.ts | 7 +- scripts/gen-doc-graphs.ts | 6 +- tsconfig.base.json | 1 + 75 files changed, 353 insertions(+), 350 deletions(-) create mode 100644 packages/preset/agent-presets/src/preset.ts diff --git a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md index f2c4981d6d..0a689603b9 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md +++ b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.md @@ -26,7 +26,7 @@ Three smaller defects sat beside them. `llm/adapters-updated` documented contain **Staleness is detected, not ordered away.** Each namespace carries a monotonic `revision` over its RAW section; writes may carry `expectedRevision`, and a mismatch rejects with `SettingsConflictError` → `settings-conflict` on the wire, both revisions attached. The editor captures the revision it opened at and, on conflict, tells the user to reopen rather than replaying its snapshot. -**The raw layer gets its own event.** `settings/updated` stays gated on the resolved value — that is what a consumer means by change. `settings/document-updated (ns, revision)` fires on any raw-section change, because a configuration surface must learn that a field went from inherited to overridden (same resolved value, different meaning) and that its held revision is stale. The host frame `host/settings-changed` rode this event, and a change to an exposed provider namespace also emits `host/models-changed`: that namespace holds the provider's catalog, which no route change announces. (`settings/document-updated` is now forwarded to consumers verbatim instead of being reframed — see [forwarded Remote events](2026-08-10-remote-event-delivery.md) — which does not change why the raw layer needs its own event; the derived `host/models-changed` still rides it.) +**The raw layer gets its own event.** `settings/updated` stays gated on the resolved value — that is what a consumer means by change. `settings/document-updated (ns, revision)` fires on any raw-section change, because a configuration surface must learn that a field went from inherited to overridden (same resolved value, different meaning) and that its held revision is stale. The event is forwarded verbatim, and model consumers subscribe to it alongside `llm/adapters-updated`, because provider settings hold catalog data that no route change announces. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md index deec710e3c..b0e306a1ad 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.zh.md @@ -26,7 +26,7 @@ Status: implemented **过期是被检测出来的,而不是靠排序绕过去的。**每个 namespace 都带有一个针对其**原始**分节的单调 `revision`;写入可携带 `expectedRevision`,不匹配即以 `SettingsConflictError` 拒绝——在协议上是 `settings-conflict`,并附上两个 revision。编辑器记住自己打开时的 revision,冲突时请用户重新打开,而不是把自己的快照重放上去。 -**原始层拥有自己的事件。**`settings/updated` 仍以解析值为门槛——那才是消费方所说的"变化"。`settings/document-updated (ns, revision)` 则在任何原始分节变化时触发,因为配置界面必须知道某个字段从继承变成了覆盖(解析值相同,含义不同),也必须知道自己持有的 revision 已经过期。host 帧 `host/settings-changed` 曾搭乘这个事件(现在 `settings/document-updated` 改为原样转发给消费端,见[转发的 Remote 事件](2026-08-10-remote-event-delivery.md)——这不改变原始层为何需要自己的事件,派生的 `host/models-changed` 仍搭乘它);而已暴露提供方 namespace 的变更还会额外发出 `host/models-changed`:该 namespace 正持有这个提供方的目录,而没有任何路由变更会宣告它。 +**原始层拥有自己的事件。**`settings/updated` 仍以解析值为门槛——那才是消费方所说的"变化"。`settings/document-updated (ns, revision)` 则在任何原始分节变化时触发,因为配置界面必须知道某个字段从继承变成了覆盖(解析值相同,含义不同),也必须知道自己持有的 revision 已经过期。该事件被原样转发,模型消费方同时订阅它与 `llm/adapters-updated`,因为提供方设置持有不会由路由变化宣告的目录数据。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index 4398ab8c23..8673ad76c7 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -12,7 +12,7 @@ The request-level configuration seam made LLM adapter configuration restart-free ## Decision -**Wire domains on the compiled RPC map, rejections as codes, invalidations as frames.** `settings.describe/openDocument/update/replace/mutate`, `credentials.describe/set/unset`, `llm.providers`, and `llm.models` (claiming the reserved `host.listModels` surface) join `RpcMethodMap`, so the seven compiler-locked wiring sites keep contract, schema, handler, and client in lockstep. Seam rejections fold into `settings-rejected {ns}` / `credential-rejected {ref}` business errors (HTTP stays a carrier), and three `HostFrame`s — `host/settings-changed {ns}`, `host/credentials-changed {ref}`, `host/models-changed` — follow the `host/commands-changed` shape so every client converges without polling. (The settings, credentials, and commands frames were later replaced by verbatim forwarding of the owning Host events; see [forwarded Remote events](2026-08-10-remote-event-delivery.md). `host/models-changed` remains this package's own derived frame, and the convergence-without-polling decision stands.) Settings reads, native actions, and writes join `pickDirectory`/`openPath` in the connection guard's privileged set: loopback + same-origin or 403, because a LAN-exposed dsh web must not accept configuration access from another origin. +**Wire domains on the compiled RPC map, rejections as codes, owner events forwarded verbatim.** `settings.describe/openDocument/update/replace/mutate`, `credentials.describe/set/unset`, `llm.providers`, and `llm.models` join `RpcMethodMap`, so the compiler-locked wiring sites keep schema, handler, and client in lockstep. Seam rejections fold into `settings-rejected {ns}` / `credential-rejected {ref}` business errors, while clients subscribe to forwarded settings, credentials, and LLM owner events and converge without polling ([forwarded Remote events](2026-08-10-remote-event-delivery.md)). Settings reads, native actions, and writes join `pickDirectory`/`openPath` in the connection guard's privileged set: loopback + same-origin or 403, because a LAN-exposed dsh web must not accept configuration access from another origin. **`describe()` grows layers and structural secret redaction.** `SettingsDescriptor` carries `base`/`user` beside the effective value, so the form marks "overridden" by presence in the user layer, not value inequality (an override *equal* to the base is still an override). `describe({ redactSecrets: true })` — mandatory at every wire face — strips `role('secret')` subtrees from all three layers via a pure structural walk of the schema (object/dict/array containers; a secret-role subtree is one opaque leaf) and enumerates the stripped slots as `{path, set}`, so a page can render write-only inputs without ever receiving a value. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index b06b457885..0571b6aee2 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -**wire 领域挂上编译期 RPC 映射,拒绝落为错误码,失效落为帧。**`settings.describe/openDocument/update/replace/mutate`、`credentials.describe/set/unset`、`llm.providers` 与 `llm.models`(认领预留的 `host.listModels` 面)一同加入 `RpcMethodMap`,七处由编译器锁定的接线位点因此让约定、schema、处理器与客户端保持步调一致。seam 侧的拒绝折叠为 `settings-rejected {ns}`/`credential-rejected {ref}` 业务错误(HTTP 仍只是载体),三个 `HostFrame`——`host/settings-changed {ns}`、`host/credentials-changed {ref}`、`host/models-changed`——沿用 `host/commands-changed` 的形状,因此每个客户端都无需轮询即可收敛。(settings、credentials 与 commands 三个帧后来被「原样转发所属 host 事件」取代,见[转发的 Remote 事件](2026-08-10-remote-event-delivery.md);`host/models-changed` 仍是本包自有的派生帧,此处「无需轮询即收敛」的决策依然成立。)settings 读取、原生操作与写入和 `pickDirectory`/`openPath` 一起进入连接守卫的特权集合:回环 + 同源,否则 403,因为暴露在局域网上的 dsh web 绝不能接受来自其他源的配置访问。 +**wire 领域挂上编译期 RPC 映射,拒绝落为错误码,owner 事件原样转发。**`settings.describe/openDocument/update/replace/mutate`、`credentials.describe/set/unset`、`llm.providers` 与 `llm.models` 一同加入 `RpcMethodMap`,由编译器锁定的接线位点让 schema、处理器与客户端保持步调一致。seam 侧拒绝折叠为业务错误,客户端则订阅转发的 settings、credentials 与 LLM owner 事件,无需轮询即可收敛(见[转发的 Remote 事件](2026-08-10-remote-event-delivery.md))。settings 读取、原生操作与写入和 `pickDirectory`/`openPath` 一起进入连接守卫的特权集合:回环 + 同源,否则 403,因为暴露在局域网上的 dsh web 绝不能接受来自其他源的配置访问。 **`describe()` 增加分层与结构化 secret 脱敏。**`SettingsDescriptor` 在生效值之外携带 `base`/`user`,表单据此按「字段是否出现在用户层」来标记「已覆盖」,而非按值是否不等(与 base *相等*的覆盖仍然是覆盖)。`describe({ redactSecrets: true })`——在每个 wire 面都强制启用——经由对 schema 的纯结构遍历(object/dict/array 容器;secret 角色子树整体是一个不透明叶节点)从全部三层剥除 `role('secret')` 子树,并把剥除的槽位枚举为 `{path, set}`,页面因此不必收到任何值就能渲染只写输入框。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md index 81a6805e0f..c6433ce847 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md @@ -8,7 +8,7 @@ English | [中文](2026-08-10-remote-event-delivery.zh.md) [TypeRT Gateway targeted method calls](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md) cover only the request/response shape and deliberately leave Session event streams and stateful interactions to separate designs. Every **one-way Host-to-consumer push** therefore still rides the legacy API Proxy. -The Host owns a family of pure invalidation events — "a registry changed, refetch it" — whose payloads are already JSON and whose emission never binds an AgentScope: `commands/change`, `credentials/updated`, `settings/document-updated`. Reaching one UI subscriber takes four hops: the Host cordis event, a hand-written `HostFrame` variant plus its zod branch in apiproxy, a hand-written bridge in client/runtime that re-emits it as a Client cordis event, and finally the consumer's `ctx.on(...)`. Adding one such event edits five places (frame union, zod union, host-stream listener, client bridge, a duplicated Client-side `Events` declaration), and not one of them states a new fact: the name, the payload type, and the emission point were all declared by the owner package's cordis `Events` merge. +The Host owns a family of one-way events whose payloads are already JSON and whose emission never binds an AgentScope: `agent-preset/selected`, `commands/change`, `credentials/updated`, `llm/adapters-updated`, and `settings/document-updated`. Reaching one UI subscriber took four hops: the Host cordis event, a hand-written `HostFrame` variant plus its zod branch in apiproxy, a hand-written bridge in client/runtime that re-emitted it as a Client cordis event, and finally the consumer's `ctx.on(...)`. Adding one such event edited five places (frame union, zod union, host-stream listener, client bridge, a duplicated Client-side `Events` declaration), and not one of them stated a new fact: the name, the payload type, and the emission point were all declared by the owner package's cordis `Events` merge. That duplicated declaration is also **lossy**: the Client side restates it as `settings/changed(ns: string)`, flattening a branded type into bare `string` — the opposite of the Remote method contract, where a consumer type points at the business package's one canonical symbol. @@ -22,9 +22,9 @@ The consumer Remote surface carries one one-way subscription verb, `ctx.remote.$ - Event **signatures** get no second table. Each owner package moves its cordis `Events` declaration into its client-safe, type-only `./types` export, so both faces read the same declaration and `$on`'s listener type is `Events[Event]` itself. "Verbatim" then holds by construction rather than by proof. - Only cordis's *type shape* is borrowed, not its event system: delivery semantics, the subscription registry, and failure containment belong to TypeRT. -When an `Events` entry's signature reaches a Host-only symbol (a Service, `Agent`, a Context), the answer is to **split the code until the entry lands cleanly in `./types`** — never a declaration half-left in `index.ts`, and never a structurally equivalent shadow type in `./types`. None of the three packages needed that here: their entries reach only `SettingsNamespace`, `SettingsUpdateSource`, and `CredentialRef`, all pure types. +When an `Events` entry's signature reaches a Host-only symbol (a Service, `Agent`, a Context), the answer is to **split the code until the entry lands cleanly in `./types`** — never a declaration half-left in `index.ts`, and never a structurally equivalent shadow type in `./types`. None of the five packages needs that here: their entries reach only `SettingsNamespace`, `SettingsUpdateSource`, `CredentialRef`, and `SessionId`, all pure types. The agent-presets package renames its previous vocabulary module to `preset.ts`, leaving the exported `types.ts` dedicated to the client-safe event declaration. -The three **pure passthrough** events ride this path, and their `HostFrame` variants are gone. Everything with derivation stays untouched: `host/models-changed` (a fan-in of `llm/adapters-updated` with provider/agent-default namespace filtering), `host/workspace-changed`/`-removed`/`host/archived-sessions-changed` (view derivation plus per-connection dedup state), and `host/session-added`/`-removed`/`host/session-status`/`host/agent-error` (live-object projection or frame-time derived fields). +All five events ride this path, and their dedicated `HostFrame` variants or Client aliases are gone. Model consumers subscribe directly to both owner inputs, `llm/adapters-updated` and `settings/document-updated`; preset-derived consumers subscribe to `agent-preset/selected`. Frames that actually project or deduplicate data stay dedicated: `host/workspace-changed`/`-removed`/`host/archived-sessions-changed` (view derivation plus per-connection dedup state), and `host/session-added`/`-removed`/`host/session-status`/`host/agent-error` (live-object projection or frame-time derived fields). `skills/change`, `tools/change`, and `system-prompt/change` have the same shape but **no consumer today**; under "require a current owner and need" they stay out of the allowlist and are recorded here only as the extension seat. @@ -73,8 +73,10 @@ Delivery shares no implementation with the cordis event system: one-way only, no ```ts // remote-events.ts — the value export const API_REMOTE_FORWARDED_EVENTS = [ + 'agent-preset/selected', 'commands/change', 'credentials/updated', + 'llm/adapters-updated', 'settings/document-updated', ] as const @@ -108,7 +110,7 @@ JSON-safety is a runtime concern: before forwarding, apiproxy validates each arg The zod branch keeps `args: z.array(z.unknown())`: the frame arrives from `JSON.parse`, so every element is already a JSON value, and the structural contract belongs to the owner package's `Events` declaration — the same posture the existing `session/projection` frame takes with its `value`. -`events.host()` subscribes by allowlist when the stream opens (each stream owns its disposers, so no broadcast set is needed). **The registration position is part of the contract**: this block must sit *before* the `settings/document-updated` listener. Cordis fires in registration order, and `host/models-changed` is an invalidation frame *derived* from that same Host event; placing the forwarded frame after the derived one flips the relative order of two frames from one emit compared with the previous behavior (two config cases observe it). +`events.host()` subscribes by allowlist when the stream opens. Each stream owns its disposers, so no broadcast set or derived invalidation listener is needed. `api/events.ts` is a wire contract file the browser side also compiles, so every type it references must come from an owner package's **client-safe, type-only subpath**, never the package root. Evidence: importing one type from `@deepseek-ai/dsh-session` root drags the root's `declare module 'cordis' { interface Context { sessions: SessionStore } }` into the Client compilation face and overrides the Client's `ctx.sessions: ISessions`, producing 18 errors in the unrelated `ui-slash` and `ui-conversation`. `JsonValue` therefore needs a re-export from `dsh-session/src/types.ts`. @@ -126,13 +128,13 @@ The few Client-owned symbols are therefore **mirrored** on the test side (`scaff |---|---| | `dsh-type-meta` | `src/types.ts` gains `TypeRTForwardableEvent`, `TypeRTRemoteEventSelection`, and `TypeRTRemoteEvent`; `TypeRTClientRemote` gains `$on` and `$dispatch`. Types only, no runtime | | `api/gateway` Client half | `ClientRemoteService` implements `$on` (subscriptions addressed by registration, `ctx.effect` ownership for the calling fiber) and `$dispatch` (snapshot delivery in registration order, containing a listener that throws or rejects) | -| `api/remotes` | New `src/remote-events.ts` (the allowlist value) and `src/types.ts` (type projection, selection seat), both listed in both faces' `files`; a `./types` export with `lib/types/**/*.js` added to `files`; the Host face adds the shape assertion and `import type {}` for the three owner `./types`; the Client half re-exports those three plus `@deepseek-ai/dsh-api-gateway/client` | -| Root `tsconfig.base.json` | Three `paths` entries (`dsh-settings/types`, `dsh-credentials/types`, `dsh-api-remotes/types`), all pointing at the **source** plane | -| `dsh-commands` / `dsh-settings` / `dsh-credentials` | The `interface Events` sub-block moves into each package's client-safe `./types` (settings and credentials create that export, moving the brands and pure types with it; `index` keeps re-exporting them and keeps the constructors; `files` gains `lib/types/**/*.js`) | -| `host/apiproxy` | `HostFrame` gains `host/remote-event` and loses `host/commands-changed`/`-settings-changed`/`-credentials-changed` with their zod branches; `events.host()` subscribes by allowlist ahead of the `settings/document-updated` listener and validates through `assertJsonArgs`; that listener stays to keep feeding `host/models-changed` | +| `api/remotes` | New `src/remote-events.ts` (the allowlist value) and `src/types.ts` (type projection, selection seat), both listed in both faces' `files`; a `./types` export with `lib/types/**/*.js` added to `files`; the Host face adds the shape assertion and `import type {}` for the five owner `./types`; the Client half re-exports those five plus `@deepseek-ai/dsh-api-gateway/client` | +| Root `tsconfig.base.json` | Client-safe `paths` entries for settings, credentials, llm, agent-presets, and api-remotes types point at the **source** plane | +| `dsh-commands` / `dsh-settings` / `dsh-credentials` / `dsh-llm` / `dsh-agent-presets` | Each forwarded `interface Events` member lives in the owner's client-safe `./types`; agent-presets moves its previous domain vocabulary to `preset.ts` so the exported file itself remains `types.ts` | +| `host/apiproxy` | `HostFrame` gains `host/remote-event` and loses the five dedicated passthrough or invalidation variants with their zod branches; `events.host()` subscribes by allowlist and validates through `assertJsonArgs` | | `dsh-session` | `src/types.ts` re-exports `JsonValue` so wire contract files can use the client-safe subpath | -| `client/runtime` | The bridge's three `ctx.emit` branches collapse into `ctx.remote.$dispatch(frame.event, frame.args)`, adding a `remote` injection; the `Events` merge drops `commands/changed`, `settings/changed`, and `credentials/changed` (`models/changed` stays) | -| Five consumers | ui-command / ui-models / ui-settings-general / ui-permission / ui-agent-preset subscribe through `ctx.remote.$on(...)`, following `ui-goal`'s precedent for the type-only facade import and the `'remote'` injection | +| `client/runtime` | The five Client-event bridge branches collapse into `ctx.remote.$dispatch(frame.event, frame.args)`, adding a `remote` injection and deleting their duplicated `Events` declarations | +| Seven consumers | ui-command / ui-model / ui-models / ui-settings-general / ui-permission / ui-agent-preset / ui-skill subscribe through `ctx.remote.$on(...)`, following `ui-goal`'s precedent for the type-only facade import and the `'remote'` injection | | `client/connection` | The fixture's `emitHost` produces `host/remote-event` | | `apps/web/tests` + `apps/cli` | Client symbols mirrored on the test side (see above); `apps/cli/tsconfig.json` drops its 15 Client project references | @@ -144,7 +146,7 @@ The few Client-owned symbols are therefore **mirrored** on the test side (`scaff **Have the typert generator project Host `Events` declarations** (codec, `.d.ts`, declaration map, like `/remote`). The generator already analyzes Host events, but it cannot see projection or redaction intent, and it would change the generator and the build surface. Verbatim forwarding needs no projection. -**Give forwardable events a payload projection function** (a `{ name, project, zod }` forwarding table). This would cover `models-changed`'s fan-in and workspace view derivation in one step, at the cost of hand-aligning projection logic with payload types — the central table the method side just removed. +**Give forwardable events a payload projection function** (a `{ name, project, zod }` forwarding table). This could fold the two model-directory inputs into one derived invalidation and also cover workspace view derivation, at the cost of hand-aligning projection logic with payload types — the central table the method side just removed. **Move the apps/web browser e2e into the Client aggregate.** "Client tests belong to the Client face" looks right and fails immediately with 21 errors: those tests use Host services, and in the Client program `ctx.sessions` is `ISessions`. @@ -159,9 +161,8 @@ What pins this behavior: - On the consumer side, `$on('settings/document-updated', …)` resolves `ns` as `SettingsNamespace`: the brand survives the wire. - `$on`'s disposer belongs to the calling fiber, and two registrations of one function object retire independently — a table keyed on listener identity would collapse them, so subscriptions are addressed by registration. - Delivery contains a listener that throws AND one that rejects a returned promise: the declared return is `void`, so nobody awaits an async listener, and its rejection would otherwise escape this containment entirely. Delivery iterates a snapshot, so subscribing or disposing mid-frame cannot change who receives that frame. -- For one emit, the forwarded frame and the invalidation frame derived from the same Host event keep the pre-change relative order (two config cases observe it). - `assertJsonArgs` is unit-tested directly rather than by driving a malformed emit through the event bus: a typed `ctx.emit` cannot construct one, since every allowlisted event has a statically JSON-safe payload. -- The three `HostFrame` variants, the three Client-side `Events` declarations, and the three bridge branches are gone in the same change; `host/models-changed` behavior is unchanged. +- The five dedicated `HostFrame` variants, five Client-side aliases, and their bridge branches are absent. The model directories observe both owner inputs, while command, skill, and session-row consumers observe the preset owner's committed-selection event. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md index 0d901d65b0..9eb124331b 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md @@ -8,7 +8,7 @@ Status: implemented [TypeRT Remote 方法调用](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md)只覆盖「一次请求一个结果」的定向调用,明确把 Session 事件流与有状态交互留在别处;Host 向消费端的**单向事件推送**因此仍然全部压在遗留的 API Proxy 上。 -Host 上一族「注册表变了,重新拉一次」的纯失效事件(`commands/change`、`credentials/updated`、`settings/document-updated`)既不依赖 AgentScope、载荷也本来就是 JSON,却要穿过四跳才能到达一个 UI 订阅者:host cordis 事件 → apiproxy 手写 `HostFrame` 变体 + zod → client/runtime 手写桥 `ctx.emit(...)` → 消费者 `ctx.on(...)`。每加一个这类事件要改 5 处(帧联合、zod 联合、host 流监听、client 桥、client 侧重复的 `Events` 声明),而这 5 处没有一处是在陈述新事实——事件名、载荷类型、发射时机全都由 owner 包早已在 cordis `Events` 里声明过。 +Host 拥有 `agent-preset/selected`、`commands/change`、`credentials/updated`、`llm/adapters-updated`、`settings/document-updated` 这五条单向事件;它们既不依赖 AgentScope,载荷也本来就是 JSON。过去每条都要穿过 host cordis 事件、apiproxy 手写帧、client/runtime 手写桥和 Client 事件别名才能抵达 UI,而这些层没有陈述 owner 事件之外的新事实。 那份重复声明还是**有损**的:client 侧写成 `settings/changed(ns: string)`,brand 类型在这一跳被拍平成裸 `string`,与 Remote 方法侧「消费端类型指向业务包唯一符号」的既有契约相反。 @@ -22,9 +22,9 @@ Host 上一族「注册表变了,重新拉一次」的纯失效事件(`comma - 事件**签名**不另立表:owner 包把自己的 cordis `Events` 声明搬进 client-safe 的 `./types` 纯类型出口,两侧读**同一份**——`$on` 的 listener 类型就是 `Events[Event]` 本身。「原样」不需要证明,是构造性成立的。 - 但**只借 cordis 的类型形状,不接 cordis 的事件系统**:投递语义、注册表、异常处置全归 TypeRT 自己。 -一条 `Events` 条目若签名里够到了 host-only 符号(Service、`Agent`、Context 等),处理方式是**把代码拆到能干净落进 `./types` 为止**;不接受「一半留 index、一半搬走」的分裂声明,也不接受在 `./types` 里造结构等价的影子类型。这三个包都不需要拆:它们的条目只够到 `SettingsNamespace`、`SettingsUpdateSource`、`CredentialRef`,全是纯类型。 +一条 `Events` 条目若签名里够到了 host-only 符号(Service、`Agent`、Context 等),处理方式是**把代码拆到能干净落进 `./types` 为止**;不接受「一半留 index、一半搬走」的分裂声明,也不接受在 `./types` 里造结构等价的影子类型。这五个包都不需要拆:它们的条目只够到纯类型。agent-presets 把原词汇模块改名为 `preset.ts`,让导出的 `types.ts` 专门承载 client-safe 事件声明。 -**纯透传**的三条走这条路径,对应的 `HostFrame` 变体已删除;带派生逻辑的一律不动:`host/models-changed`(`llm/adapters-updated` 与 provider/agent-default 命名空间过滤的 fan-in)、`host/workspace-changed`/`-removed`/`host/archived-sessions-changed`(需 view 派生 + 每连接 dedup 状态)、`host/session-added`/`-removed`/`host/session-status`/`host/agent-error`(需活对象投影或帧时派生字段)。 +五条事件全部走这条路径,专用帧与 Client 别名都已删除。模型消费方直接订阅 `llm/adapters-updated` 和 `settings/document-updated`;preset 消费方订阅 `agent-preset/selected`。真正需要投影或去重的数据仍保留专用帧。 `skills/change`、`tools/change`、`system-prompt/change` 是同形状的纯失效事件但目前**没有任何消费者**,按「每个抽象都要有当前 owner 与需求」不进名单,只作为扩展位记录在此。 @@ -73,8 +73,10 @@ $dispatch(event: string, args: readonly unknown[]): void ```ts // remote-events.ts — the value export const API_REMOTE_FORWARDED_EVENTS = [ + 'agent-preset/selected', 'commands/change', 'credentials/updated', + 'llm/adapters-updated', 'settings/document-updated', ] as const @@ -108,7 +110,7 @@ API_REMOTE_FORWARDED_EVENTS satisfies readonly TypeRTForwardableEvent[] zod 侧 `args: z.array(z.unknown())`:帧本身来自 `JSON.parse`,元素必然已是 JSON 值,结构契约由 owner 包的 `Events` 声明承担——与既有 `session/projection` 帧的 `value` 同 posture。 -`events.host()` 打开时按名单挂监听(host 流每条自持 disposers,无需新增广播集合)。**注册位置是契约的一部分**:这段必须挂在 `settings/document-updated` 监听**之前**。cordis 按注册序触发,而 `host/models-changed` 是由同一条 host 事件**派生**出来的失效帧——转发帧排到派生帧之后会让同一次 emit 的两帧顺序相对改动前颠倒(已被两条 config 用例实测到)。规则:**转发帧必须先于由它派生的失效帧**。 +`events.host()` 打开时按名单挂监听;每条流自持 disposers,无需新增广播集合或派生失效 listener。 `api/events.ts` 是浏览器侧也要编译的 wire 契约文件,所以它引用的每个类型都必须走 owner 包的 **client-safe type-only 子路径**,绝不能走包根出口。实证:从 `@deepseek-ai/dsh-session` 根引一个类型,就把根出口的 `declare module 'cordis' { interface Context { sessions: SessionStore } }` 拖进 client 编译面、把 client 的 `ctx.sessions: ISessions` 顶掉,在完全无关的 `ui-slash` / `ui-conversation` 里炸出 18 条错。`JsonValue` 因此需要 `dsh-session/src/types.ts` 补一条 re-export。 @@ -130,9 +132,9 @@ zod 侧 `args: z.array(z.unknown())`:帧本身来自 `JSON.parse`,元素必 | `api/remotes` | 新增 `src/remote-events.ts`(名单值)与 `src/types.ts`(类型投影 + 选择座位),两者都双列进两个 face 的 `files`;`./types` 出口 + `files` 补 `lib/types/**/*.js`;host 半加形状断言并 `import type {}` 三个 owner 包的 `./types`;client 半 `export type {}` 那三个 `./types` 与 `@deepseek-ai/dsh-api-gateway/client` | | 根 `tsconfig.base.json` | 加 `dsh-settings/types`、`dsh-credentials/types`、`dsh-api-remotes/types` 三条 `paths`,全部指向**源**平面 | | `dsh-commands` / `dsh-settings` / `dsh-credentials` | `interface Events` 子块移入各自 client-safe 的 `./types`(settings/credentials 新建该出口,brand 与纯类型一并移入,index 继续 re-export 并留住构造器;`files` 补 `lib/types/**/*.js`) | -| `host/apiproxy` | `HostFrame` 增 `host/remote-event`、删 `host/commands-changed`/`-settings-changed`/`-credentials-changed` 三变体及其 zod;`events.host()` 按名单挂监听(位置在 `settings/document-updated` 之前)+ `assertJsonArgs`;该监听保留以继续喂 `host/models-changed` | +| `host/apiproxy` | `HostFrame` 增 `host/remote-event`、删除五个专用变体及其 zod;`events.host()` 按名单挂监听并通过 `assertJsonArgs` 校验 | | `dsh-session` | `src/types.ts` 补 `export type { JsonValue }`,让 wire 契约文件能走 client-safe 子路径 | -| `client/runtime` | 桥里三条 `ctx.emit` 换成一行 `ctx.remote.$dispatch(frame.event, frame.args)` 并新增 `remote` 注入;`Events` 声明删 `commands/changed`/`settings/changed`/`credentials/changed`(`models/changed` 保留) | +| `client/runtime` | 五条 Client 事件桥分支收敛为 `ctx.remote.$dispatch(frame.event, frame.args)`,并删除重复声明 | | 5 个消费者 | ui-command / ui-models / ui-settings-general / ui-permission / ui-agent-preset 改订 `ctx.remote.$on(...)`;照 `ui-goal` 先例 type-only 引 `@deepseek-ai/dsh-api-remotes/client` 并把 `'remote'` 加进 `inject` | | `client/connection` | fixture 的 `emitHost` 造 `host/remote-event` | | `apps/web/tests` + `apps/cli` | 客户端符号镜像(见上节);`apps/cli/tsconfig.json` 删 15 条 client 工程引用 | @@ -160,9 +162,8 @@ zod 侧 `args: z.array(z.unknown())`:帧本身来自 `JSON.parse`,元素必 - 消费端 `$on('settings/document-updated', …)` 把 `ns` 解析为 `SettingsNamespace`:brand 穿过 wire 存活。 - `$on` 的 disposer 归属调用方 fiber;同一个函数对象订阅两次时两条注册各自独立退订——按 listener 身份做键的表会把它们合并,所以订阅按注册项寻址。 - 投递同时收容抛出的 listener 与拒绝所返回 promise 的 listener:声明返回值是 `void`,没人 await 异步 listener,其拒绝否则会完全逃出这层收容。投递遍历快照,因此派发中订阅或退订都不会改变本帧的接收者集合。 -- 同一次 emit 下,转发帧与由同一条 host 事件派生的失效帧保持改动前的相对顺序(两条 config 用例实测)。 - `assertJsonArgs` 直接单测,而不是从事件总线造畸形 emit:类型化的 `ctx.emit` 造不出来——名单内每条事件的载荷在静态上都是 JSON-safe 的。 -- 三个 `HostFrame` 变体、三条 client 侧 `Events` 声明、三条桥分支在同一变更内消失;`host/models-changed` 行为不变。 +- 五个专用帧、五条 Client 别名及其桥分支都不存在;各消费方直接观察 owner 事件。 ## 后果 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md index 85bd5b2134..ec85d4a3a6 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md @@ -8,17 +8,17 @@ English | [中文](2026-08-10-slash-catalog-follows-preset-switch.zh.md) Presets moved the rows that decide what a session's `/` menu contains. The Web composition disables host-plane `skill-local`, `tool-skill`, `plan-mode`, and `command-compact`; a preset supplies them, so which commands and skills exist is a property of the session's composition rather than of the deployment. -Both browser catalogs cache per session — `CommandDirectory` in `dsh-client-ui-command`, the single-flight fetch map in `dsh-client-ui-skill` — and the composer warms both at scope birth, under whatever preset the session was created with. The hero chip then lets the user recompose the still-blank session, and neither cache had an invalidation edge for that: `commands/changed` is registry-wide and `connection/reset` needs a reconnect. `agentPresets.recompose` re-parents the agent's scope onto a standing mount that may already exist, so it registers nothing and the registry-wide signal never fires for it. +Both browser catalogs cache per session — `CommandDirectory` in `dsh-client-ui-command`, the single-flight fetch map in `dsh-client-ui-skill` — and the composer warms both at scope birth, under whatever preset the session was created with. The hero chip then lets the user recompose the still-blank session, and neither cache had an invalidation edge for that: `commands/change` is registry-wide and `connection/reset` needs a reconnect. `agentPresets.recompose` re-parents the agent's scope onto a standing mount that may already exist, so it registers nothing and the registry-wide signal never fires for it. The menu therefore kept serving the composition the session no longer ran. Switching down left `compact`, `plan`, and every project skill listed; switching up left the narrower catalog — the four host-plane rows and the client's own `model` contribution — with no skills at all, which is what the bug report described. The catalog only healed when an unrelated registry change or a reconnect happened to invalidate it. ## Decision -The switch's commit point is the logged `agent-preset/selected` event. The host stream frames it as `host/session-preset-changed { sessionId, agentPreset }`, the browser runtime bridges that frame to the typed `session/preset-changed` ctx event beside the registry-invalidation bridges it already owns, and each catalog owner drops its own entry for that session: `ui-command` soft-refreshes the key (the old snapshot keeps serving the open menu until the new one lands), `ui-skill` invalidates it (aborting an in-flight prewarm, so a warm racing the switch cannot publish the stale catalog). +The switch's commit point is the logged `agent-preset/selected` event. The preset owner re-emits that commit as the client-safe cordis owner event `agent-preset/selected(sessionId, agentPreset)`, the host stream forwards it verbatim, and each catalog subscribes directly through `ctx.remote.$on`: `ui-command` soft-refreshes the key (the old snapshot keeps serving the open menu until the new one lands), while `ui-skill` invalidates it (aborting an in-flight prewarm, so a warm racing the switch cannot publish the stale catalog). -The frame is per session and carries no catalog, only the preset id — which the manager folds into the session row, because the `agentPresets.select` echo reaches only the client that issued the switch and the row is what the session header labels itself from (and what the hero chip compares the next pick against). +The owner event is per session and carries no catalog, only the preset id. `ui-agent-preset` folds it into the session row because the `agentPresets.select` echo reaches only the client that issued the switch and the row is what the session header labels itself from (and what the hero chip compares the next pick against). -Deriving the frame from the logged event rather than from the RPC handler's return keeps one authority for "this session's composition changed": every connected client observes the switch, not only the tab that issued it, and a client that is not the switcher never has to infer it from a registry signal that will not come. +Deriving the owner event from the logged event rather than from the RPC handler's return keeps one authority for "this session's composition changed": every connected client observes the switch, not only the tab that issued it, and a client that is not the switcher never has to infer it from a registry signal that will not come. ## Alternatives considered @@ -26,15 +26,15 @@ Deriving the frame from the logged event rather than from the RPC handler's retu **Derive the client event from the existing `session/event` mux frame.** The logged event already reaches every subscribed client, so no new wire type would be needed. Rejected on face separation: narrowing `event.type` to `agent-preset/selected` requires the `SessionEventMap` augmentation, and the only ways to load it in the Client program are a project reference to `dsh-agent-presets` — which drags the host `ctx.sessions` merge into a program that publishes its own — or a cast that defeats the discriminant. -**Reuse `host/commands-changed`.** It is the existing catalog-invalidation frame, but it is registry-wide, carries no session, and says nothing about skills; a client would repull every session's commands and still never refresh a skill catalog. +**Reuse forwarded `commands/change`.** It is the existing catalog-invalidation event, but it is registry-wide, carries no session, and says nothing about skills; a client would repull every session's commands and still never refresh a skill catalog. ## Consequences -The wire gains one frame and the Client one typed event, and every catalog a preset decides now has one place to subscribe: a future per-session surface derived from the composition invalidates on the same signal instead of inventing another. The cost is that the frame is a second reader of a logged fact — the host stream must keep deriving it from `agent-preset/selected`, so a future switch path that recomposes without logging would go unannounced. `ui-command` stays soft (the open menu never blanks) while `ui-skill` drops its entry outright, because a skill catalog has no partial-serve mode; a menu opened inside the refetch window shows no skills for that instant rather than the wrong ones. +The forwarding allowlist gains the preset owner's typed event, and every catalog a preset decides has one place to subscribe: a future per-session surface derived from the composition invalidates on the same signal instead of inventing another. The owner event remains a second publication of a logged fact, so a future switch path that recomposes without logging would go unannounced. `ui-command` stays soft (the open menu never blanks) while `ui-skill` drops its entry outright, because a skill catalog has no partial-serve mode; a menu opened inside the refetch window shows no skills for that instant rather than the wrong ones. ## Testing -`api-proxy-agent-preset.spec.ts` asserts the committed switch frames once with the session and its new preset; `wire-events.spec.ts` asserts the frame-to-event bridge; the `ui-command` and `ui-skill` specs assert that the event repulls the recomposed session and leaves every other session's cache serving. The `agent-preset-selection` web e2e seeds a project skill and, after the hero chip applies `minimal`, asserts the `/` menu drops `compact`, `plan`, and the skill while keeping the host-plane rows — the assembled-application evidence that the panel follows the composition. +`api-proxy-agent-preset.spec.ts` asserts the committed switch is forwarded once with the session and its new preset; the `ui-agent-preset`, `ui-command`, and `ui-skill` specs assert that direct Remote subscriptions merge the row or repull only the recomposed session. The `agent-preset-selection` web e2e seeds a project skill and, after the hero chip applies `minimal`, asserts the `/` menu drops `compact`, `plan`, and the skill while keeping the host-plane rows — the assembled-application evidence that the panel follows the composition. That e2e also stopped reading its staged-pick assertion off the serialized session list: the seeded session records `minimal` too, so the substring answered before the switch had landed. It now addresses the live session by id. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md index 97c8f08a7b..b68fa90e99 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.zh.md @@ -8,17 +8,17 @@ Status: implemented preset 把决定 `/` 菜单内容的那些行搬走了。Web 组装禁用了宿主面的 `skill-local`、`tool-skill`、`plan-mode` 和 `command-compact`,改由 preset 提供,因此一个会话有哪些命令和技能,是它自身组成的属性,而不是部署的属性。 -浏览器侧两份目录都按会话缓存——`dsh-client-ui-command` 的 `CommandDirectory`,`dsh-client-ui-skill` 的 single-flight 拉取表——并且 composer 在 scope 出生时就按会话创建时的 preset 预热了它们。随后 hero 上的 chip 允许用户重组这个仍为空的会话,而两份缓存都没有对应的失效边:`commands/changed` 是注册表级的,`connection/reset` 需要重连。`agentPresets.recompose` 只是把 agent 的 scope 重新挂接到一个可能已经存在的常驻挂载上,不产生任何注册,注册表级信号因此永远不会为它触发。 +浏览器侧两份目录都按会话缓存——`dsh-client-ui-command` 的 `CommandDirectory`,`dsh-client-ui-skill` 的 single-flight 拉取表——并且 composer 在 scope 出生时就按会话创建时的 preset 预热了它们。随后 hero 上的 chip 允许用户重组这个仍为空的会话,而两份缓存都没有对应的失效边:`commands/change` 是注册表级的,`connection/reset` 需要重连。`agentPresets.recompose` 只是把 agent 的 scope 重新挂接到一个可能已经存在的常驻挂载上,不产生任何注册,注册表级信号因此永远不会为它触发。 于是菜单继续提供会话已经不再运行的那套组成。向下切换后 `compact`、`plan` 和全部项目技能仍列在菜单里;向上切换后留在原地的是更窄的目录——四条宿主面行加客户端自己的 `model` 贡献——而且完全没有技能,这正是 bug 报告描述的现象。只有当某个无关的注册表变化或一次重连恰好使其失效时,目录才会自愈。 ## Decision -这次切换的提交点是落账的 `agent-preset/selected` 事件。宿主流把它成帧为 `host/session-preset-changed { sessionId, agentPreset }`,浏览器运行时在它已经拥有的那组注册表失效桥接旁,把该帧桥接为类型化的 `session/preset-changed` ctx 事件,两份目录各自丢弃该会话的那一项:`ui-command` 软刷新该键(新快照落地前,旧快照继续服务已打开的菜单),`ui-skill` 让它失效(并中止在途的预热,使一次与切换赛跑的 warm 无法发布过期目录)。 +这次切换的提交点是落账的 `agent-preset/selected` 事件。preset owner 将该提交重新发为 client-safe 的 cordis owner 事件 `agent-preset/selected(sessionId, agentPreset)`,宿主流原样转发它,两份目录各自通过 `ctx.remote.$on` 直接订阅:`ui-command` 软刷新该键(新快照落地前,旧快照继续服务已打开的菜单),`ui-skill` 让它失效(并中止在途的预热,使一次与切换赛跑的 warm 无法发布过期目录)。 -该帧按会话粒度,不携带目录,只带 preset id——manager 会把它折进会话行,因为 `agentPresets.select` 的回执只会到达发起切换的那个客户端,而会话头部标签正是以这一行为准(hero chip 比较下一次选择时读的也是它)。 +该 owner 事件按会话粒度,不携带目录,只带 preset id。`ui-agent-preset` 会把它折进会话行,因为 `agentPresets.select` 的回执只会到达发起切换的那个客户端,而会话头部标签正是以这一行为准(hero chip 比较下一次选择时读的也是它)。 -从落账事件而不是 RPC 处理器的返回值派生该帧,使「这个会话的组成变了」只有一个权威来源:每个已连接的客户端都能观察到这次切换,而不只是发起它的那个标签页;不是发起方的客户端也无需从一个根本不会到来的注册表信号里去推断。 +从落账事件而不是 RPC 处理器的返回值派生 owner 事件,使「这个会话的组成变了」只有一个权威来源:每个已连接的客户端都能观察到这次切换,而不只是发起它的那个标签页;不是发起方的客户端也无需从一个根本不会到来的注册表信号里去推断。 ## Alternatives considered @@ -26,15 +26,15 @@ preset 把决定 `/` 菜单内容的那些行搬走了。Web 组装禁用了宿 **从既有的 `session/event` mux 帧派生客户端事件。** 落账事件本来就会送达每个已订阅的客户端,不需要新增协议类型。因面(face)分离而否决:把 `event.type` 收窄到 `agent-preset/selected` 需要 `SessionEventMap` 增补,而在 Client 程序里加载它只有两条路——引用 `dsh-agent-presets` 工程,那会把宿主的 `ctx.sessions` 合并拖进一个自己也发布同名服务的程序;或者用一次类型断言绕过判别式。 -**复用 `host/commands-changed`。** 它是既有的目录失效帧,但它是注册表级的、不带会话、也与技能无关;客户端会把每个会话的命令都重拉一遍,却依然永远刷不新技能目录。 +**复用转发的 `commands/change`。** 它是既有的目录失效事件,但它是注册表级的、不带会话、也与技能无关;客户端会把每个会话的命令都重拉一遍,却依然永远刷不新技能目录。 ## Consequences -协议多了一个帧,Client 多了一个类型化事件,而每一份由 preset 决定的目录从此有了统一的订阅点:将来任何从组成派生的按会话界面,都在同一个信号上失效,而不必再发明一个。代价是该帧成为一项落账事实的第二个读者——宿主流必须持续从 `agent-preset/selected` 派生它,因此将来若出现一条不落账就重组的切换路径,它将无人宣告。`ui-command` 保持软失效(已打开的菜单不会变空),而 `ui-skill` 直接丢弃该项,因为技能目录没有「部分可服务」的状态;在重拉窗口内打开的菜单,那一瞬间显示的是没有技能,而不是错误的技能。 +转发名单加入了 preset owner 的类型化事件,而每一份由 preset 决定的目录从此有了统一的订阅点:将来任何从组成派生的按会话界面,都在同一个信号上失效,而不必再发明一个。owner 事件仍是落账事实的第二次发布,因此将来若出现一条不落账就重组的切换路径,它将无人宣告。`ui-command` 保持软失效(已打开的菜单不会变空),而 `ui-skill` 直接丢弃该项,因为技能目录没有「部分可服务」的状态;在重拉窗口内打开的菜单,那一瞬间显示的是没有技能,而不是错误的技能。 ## Testing -`api-proxy-agent-preset.spec.ts` 断言已提交的切换恰好成帧一次,并带上会话与新 preset;`wire-events.spec.ts` 断言帧到事件的桥接;`ui-command` 与 `ui-skill` 的 spec 断言该事件只重拉被重组的会话,其他会话的缓存继续服务。`agent-preset-selection` web e2e 播种一个项目技能,并在 hero chip 应用 `minimal` 之后断言 `/` 菜单丢掉了 `compact`、`plan` 和该技能,同时保留宿主面的那几行——这是面板跟随组成的整装应用证据。 +`api-proxy-agent-preset.spec.ts` 断言已提交的切换恰好转发一次,并带上会话与新 preset;`ui-agent-preset`、`ui-command` 与 `ui-skill` 的 spec 断言直接 Remote 订阅会合并会话行或只重拉被重组的会话。`agent-preset-selection` web e2e 播种一个项目技能,并在 hero chip 应用 `minimal` 之后断言 `/` 菜单丢掉了 `compact`、`plan` 和该技能,同时保留宿主面的那几行——这是面板跟随组成的整装应用证据。 同一条 e2e 也不再从序列化后的会话列表里读它的 staged-pick 断言:被播种的会话同样记录着 `minimal`,子串匹配在切换落地之前就会通过。现在它按 id 寻址那个活跃会话。 diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md index 63df4514f8..3606470d32 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md @@ -16,7 +16,7 @@ The service reads the current Settings value synchronously at `session/created`. The existing `/permission` command and `permissions` projection remain the current-session path. The browser plugin now contributes the Permission row to `settings.general.item`, reads the dynamic enum from the redacted Settings descriptor, and writes only `defaultPreset` through a revision-checked `settings.mutate`. The row injects its observable through the slot `hooks` compartment instead of binding a renderer-specific hook, and the Permission service sweeps already-live sessions when it mounts so HMR cannot leave an unpinned session. The ownerless General-settings package contributes no placeholder rows. -ApiProxy explicitly adds `permission` to its Web settings allowlist beside the configurable-provider namespaces. This is a local boundary decision, not a general registration flag or a `local-client` access model: registering another Settings namespace still does not expose it. Permission changes reach the client as a settings invalidation — `host/settings-changed` then, the forwarded `settings/document-updated` now ([forwarded Remote events](../architecture/2026-08-10-remote-event-delivery.md)) — but never as `host/models-changed`. +ApiProxy explicitly adds `permission` to its Web settings allowlist beside the configurable-provider namespaces. This is a local boundary decision, not a general registration flag or a `local-client` access model: registering another Settings namespace still does not expose it. Permission changes reach the client through forwarded `settings/document-updated` ([forwarded Remote events](../architecture/2026-08-10-remote-event-delivery.md)); they do not announce model topology. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md index 969fa1aae6..236fb14866 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md @@ -16,7 +16,7 @@ Web「通用」设置页将「权限」显示为禁用的骨架控件,尽管 ` 现有 `/permission` 命令和 `permissions` 投影仍是当前会话的操作路径。浏览器插件现在向 `settings.general.item` 贡献「权限」行,从脱敏后的 Settings 描述符读取动态 enum,并只通过经过 revision 校验的 `settings.mutate` 写入 `defaultPreset`。该行通过 slot 的 `hooks` 格注入 observable,而不是绑定渲染器专用钩子;权限服务挂载时会遍历并固定所有已存活会话,因此 HMR(热模块替换)不会遗留未固定的会话。无归属的「通用」设置包不贡献任何占位行。 -ApiProxy 在可配置提供方 namespace 之外,将 `permission` 显式加入 Web Settings allowlist。这是局部的边界决策,而不是通用注册标志或 `local-client` 访问模型:注册其他 Settings namespace 仍不会将其暴露。权限变更以 settings 失效通知形式到达客户端——当时是 `host/settings-changed`,现在是转发的 `settings/document-updated`([转发的 Remote 事件](../architecture/2026-08-10-remote-event-delivery.md))——但绝不会是 `host/models-changed`。 +ApiProxy 在可配置提供方 namespace 之外,将 `permission` 显式加入 Web Settings allowlist。这是局部的边界决策,而不是通用注册标志或 `local-client` 访问模型:注册其他 Settings namespace 仍不会将其暴露。权限变更通过转发的 `settings/document-updated` 到达客户端([转发的 Remote 事件](../architecture/2026-08-10-remote-event-delivery.md)),不会宣告模型拓扑。 ## 后果 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index a4802914ca..c82dfab988 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -67,8 +67,6 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | -| `models/changed` | `runtime` (`emit`) | - | -| `session/preset-changed` | `runtime` (`emit`) | - | | `slash/input-begin-command` | - | `ui-conversation` | | `slash/input-consume-token` | - | `ui-conversation` | | `slash/input-insert-reference` | - | `ui-conversation` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 966555779a..9a95fa6527 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -69,8 +69,6 @@ | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale` | -| `models/changed` | `runtime` (`emit`) | - | -| `session/preset-changed` | `runtime` (`emit`) | - | | `slash/input-begin-command` | - | `ui-conversation` | | `slash/input-consume-token` | - | `ui-conversation` | | `slash/input-insert-reference` | - | `ui-conversation` | diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index 34c78caf98..cc903af720 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -12,17 +12,17 @@ This package contains no transport or Host service discovery logic. Its Client f ## Forwarded Host events -`src/types.ts` holds `API_REMOTE_FORWARDED_EVENTS`, the allowlist of Host cordis events this application forwards to consumers verbatim — no projection, no redaction, no renaming — and therefore the legal key set of `ctx.remote.$on`. Forwarding one more event is an entry in that array and nothing else: the type projection, the consumer key face, and the Host forwarding loop all derive from it. +`src/remote-events.ts` holds `API_REMOTE_FORWARDED_EVENTS`, the allowlist of Host cordis events this application forwards to consumers verbatim — no projection, no redaction, no renaming — and therefore the legal key set of `ctx.remote.$on`; the type-only `src/types.ts` derives its selection face. Forwarding one more event is an entry in that array and nothing else: the type projection, the consumer key face, and the Host forwarding loop all derive from it. -The listener signature is not restated here. Each allowlisted event's cordis `Events` declaration lives in its owner package's client-safe `./types` export (`dsh-commands`, `dsh-credentials`, `dsh-settings`), and both faces of this package pull those declarations in, so "forwarded verbatim" holds by construction rather than by proof. The Host face additionally asserts the list against `TypeRTForwardableEvent`, which rejects a name that is not a declared event, one that binds an AgentScope, and one whose shape is not one-way. +The listener signature is not restated here. Each allowlisted event's cordis `Events` declaration lives in its owner package's client-safe `./types` export (`dsh-agent-presets`, `dsh-commands`, `dsh-credentials`, `dsh-llm`, `dsh-settings`), and both faces of this package pull those declarations in, so "forwarded verbatim" holds by construction rather than by proof. The Host face additionally asserts the list against `TypeRTForwardableEvent`, which rejects a name that is not a declared event, one that binds an AgentScope, and one whose shape is not one-way. ## Build boundary An ordinary repository package belongs to one TypeScript face: Host packages are registered in the root `tsconfig.host.json`, and Client packages in the root `tsconfig.client.json`. `api-remotes` is the only deliberate exception because its Host entry must participate in the Host TypeRT graph, while `src/client/index.ts` cannot compile until Host tsdown has generated the business packages' `/remote` declarations. -This package's root `tsconfig.json` is only a solution that references `tsconfig.host.json` and `tsconfig.client.json`. The Host aggregate and direct Host consumers reference the former, while the Client aggregate and direct Client consumers reference the latter; the package-root solution must not enter either aggregate's dependency graph. The two projects own disjoint source files and `.tsbuildinfo` files but share the `lib/types` output directory, with one deliberate exception: `src/types.ts` is listed in BOTH faces' `files`, because the forwarded-event allowlist is the single control point over what a consumer can receive, and the Host forwarding loop and the Client `ctx.remote.$on` key face must read one declaration rather than two that could drift. +This package's root `tsconfig.json` is only a solution that references `tsconfig.host.json` and `tsconfig.client.json`. The Host aggregate and direct Host consumers reference the former, while the Client aggregate and direct Client consumers reference the latter; the package-root solution must not enter either aggregate's dependency graph. The two projects own disjoint source files and `.tsbuildinfo` files but share the `lib/types` output directory, with one deliberate exception: `src/remote-events.ts` and `src/types.ts` are listed in BOTH faces' `files`, because the forwarded-event allowlist is the single control point over what a consumer can receive, and the Host forwarding loop and the Client `ctx.remote.$on` key face must read one declaration rather than two that could drift. -That exception is not just a `files` entry. The root `tsconfig.base.json` maps `@deepseek-ai/dsh-api-remotes/types` to `src/types.ts` — the source plane, like every other workspace subpath and unlike the generated `/remote` artifacts, which have no `paths` entry and resolve through `exports` to built output. Both faces therefore admit that one source file into their own program and each emits its own `lib/types/types.js`/`.d.ts` over the other's. The two emissions are byte-identical because they compile the same source, and the `.tsbuildinfo` files stay independent. No gate enforces the faces' source-file disjointness — `scripts/project-reference-faces.ts` only checks that a reference into a split project names the matching face — so this paragraph is the only thing standing between the next reader and the conclusion that the double listing is a mistake. +That exception is not just a `files` entry. The root `tsconfig.base.json` maps `@deepseek-ai/dsh-api-remotes/types` to `src/types.ts` — the source plane, like every other workspace subpath and unlike the generated `/remote` artifacts, which have no `paths` entry and resolve through `exports` to built output. Both faces therefore admit the same allowlist and type projection into their own programs and emit byte-identical `remote-events` and `types` outputs into `lib/types`; the `.tsbuildinfo` files stay independent. No gate enforces the faces' source-file disjointness — `scripts/project-reference-faces.ts` only checks that a reference into a split project names the matching face — so this paragraph records why the double listing is intentional. The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bundle the Host entry and the later Client tsdown bundle only the browser entry. Ordinary Client plugins remain single Client projects and produce both their Node loader entry and browser bundle during Client tsdown; do not copy this package's split merely because a package has both `src/index.ts` and `src/client/index.ts`. diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index 4fff19673f..fe34b8774c 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -13,17 +13,17 @@ ## 转发的 Host 事件 -`src/types.ts` 持有 `API_REMOTE_FORWARDED_EVENTS`——本应用原样转发给消费端的 Host cordis 事件名单(无投影、无脱敏、无改名),它同时就是 `ctx.remote.$on` 的合法键集。多转发一个事件只需在该数组里加一行:类型投影、消费端键面与 Host 转发循环全部由它派生。 +`src/remote-events.ts` 持有 `API_REMOTE_FORWARDED_EVENTS`——本应用原样转发给消费端的 Host cordis 事件名单(无投影、无脱敏、无改名),它同时就是 `ctx.remote.$on` 的合法键集;只含类型的 `src/types.ts` 派生其选择面。多转发一个事件只需在该数组里加一行:类型投影、消费端键面与 Host 转发循环全部由它派生。 -监听器签名不在此处重写。名单内每条事件的 cordis `Events` 声明都住在其 owner 包 client-safe 的 `./types` 出口(`dsh-commands`、`dsh-credentials`、`dsh-settings`),本包两个 face 都把那些声明纳入编译面,因此「原样转发」是构造性成立的,不需要另立证明。Host face 还额外把名单断言给 `TypeRTForwardableEvent`:未声明的事件名、绑定 AgentScope 的事件、以及形状不是单向的事件都会在此被拒绝。 +监听器签名不在此处重写。名单内每条事件的 cordis `Events` 声明都住在其 owner 包 client-safe 的 `./types` 出口(`dsh-agent-presets`、`dsh-commands`、`dsh-credentials`、`dsh-llm`、`dsh-settings`),本包两个 face 都把那些声明纳入编译面,因此「原样转发」是构造性成立的,不需要另立证明。Host face 还额外把名单断言给 `TypeRTForwardableEvent`:未声明的事件名、绑定 AgentScope 的事件、以及形状不是单向的事件都会在此被拒绝。 ## 构建边界 仓库中的普通包只属于一个 TypeScript face:Host 包登记在根 `tsconfig.host.json`,Client 包登记在根 `tsconfig.client.json`。`api-remotes` 是唯一刻意拆分的特例,因为它的 Host 入口要参与 Host TypeRT 图,而 `src/client/index.ts` 必须等 Host tsdown 生成业务包的 `/remote` 声明后才能编译。 -本包根 `tsconfig.json` 只是引用 `tsconfig.host.json` 与 `tsconfig.client.json` 的 solution。Host aggregate 和 Host 直接消费方引用前者,Client aggregate 和 Client 直接消费方引用后者;禁止把包根 solution 放进任一 aggregate 的依赖图。两个 project 拥有互不重叠的源码和 `.tsbuildinfo`,但共享 `lib/types` 输出目录——只有一处刻意的例外:`src/types.ts` **同时**列进两个 face 的 `files`,因为转发事件名单是「消费端能收到什么」的唯一控制点,Host 转发循环与 Client 的 `ctx.remote.$on` 键面必须读同一份声明,而不是两份可能彼此漂移的声明。 +本包根 `tsconfig.json` 只是引用 `tsconfig.host.json` 与 `tsconfig.client.json` 的 solution。Host aggregate 和 Host 直接消费方引用前者,Client aggregate 和 Client 直接消费方引用后者;禁止把包根 solution 放进任一 aggregate 的依赖图。两个 project 拥有互不重叠的源码和 `.tsbuildinfo`,但共享 `lib/types` 输出目录——只有一处刻意的例外:`src/remote-events.ts` 与 `src/types.ts` **同时**列进两个 face 的 `files`,因为转发事件名单是「消费端能收到什么」的唯一控制点,Host 转发循环与 Client 的 `ctx.remote.$on` 键面必须读同一份声明,而不是两份可能彼此漂移的声明。 -这条例外不止是一行 `files`。根 `tsconfig.base.json` 把 `@deepseek-ai/dsh-api-remotes/types` 映射到 `src/types.ts`——**源平面**,与其余所有 workspace 子路径一致,也与生成的 `/remote` 产物相反(后者没有 `paths` 条目,靠 `exports` 命中构建产物)。于是两个 face 都把这一个源文件收进各自的 program,各自把 `lib/types/types.js`/`.d.ts` 覆写到对方的输出上。两次 emit 编译的是同一份源码,内容逐字相同,而 `.tsbuildinfo` 各自独立。没有任何门禁强制两个 face 的源文件互不重叠——`scripts/project-reference-faces.ts` 只校验「引用一个 split project 必须指到对应 face」——因此挡在下一个读者与「这个双列是写错了」这个结论之间的,只有这一段文字。 +这条例外不止是一行 `files`。根 `tsconfig.base.json` 把 `@deepseek-ai/dsh-api-remotes/types` 映射到 `src/types.ts`——**源平面**,与其余所有 workspace 子路径一致,也与生成的 `/remote` 产物相反(后者没有 `paths` 条目,靠 `exports` 命中构建产物)。于是两个 face 都把同一份名单与类型投影收进各自的 program,并向 `lib/types` 发射逐字相同的 `remote-events` 与 `types` 输出;`.tsbuildinfo` 仍各自独立。没有任何门禁强制两个 face 的源文件互不重叠——`scripts/project-reference-faces.ts` 只校验「引用一个 split project 必须指到对应 face」——因此本段记录这次双列为何是有意的。 包内 `clientBundle(..., { hostPhase: true })` 让 Host tsdown 打包 Host 入口,让后续 Client tsdown 只打包 browser 入口。普通 Client 插件仍使用单一 Client project,并在 Client tsdown 阶段一起生成 Node loader 入口和 browser bundle;不得因一个包同时存在 `src/index.ts` 与 `src/client/index.ts` 就复制本包的拆分。 diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 490c06c13a..56ade19443 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -65,6 +65,8 @@ "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", @@ -77,6 +79,8 @@ "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 78f83cb48d..9be953c6c6 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -14,6 +14,8 @@ export type { ApiRemoteForwardedEvent } from '../types.ts' // declaration the Host emits rather than a flattened restatement of it. export type {} from '@deepseek-ai/dsh-commands/types' export type {} from '@deepseek-ai/dsh-credentials/types' +export type {} from '@deepseek-ai/dsh-llm/types' +export type {} from '@deepseek-ai/dsh-agent-presets/types' export type {} from '@deepseek-ai/dsh-settings/types' /** * The Gateway Client face's own declaration merges, type-only: `ctx.remote` and diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index 705c8ee573..a15268f8c8 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -9,6 +9,8 @@ import { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts' // event vocabulary. import type {} from '@deepseek-ai/dsh-commands/types' import type {} from '@deepseek-ai/dsh-credentials/types' +import type {} from '@deepseek-ai/dsh-llm/types' +import type {} from '@deepseek-ai/dsh-agent-presets/types' import type {} from '@deepseek-ai/dsh-settings/types' export { diff --git a/packages/api/remotes/src/remote-events.ts b/packages/api/remotes/src/remote-events.ts index f8137b72cf..5992b0e410 100644 --- a/packages/api/remotes/src/remote-events.ts +++ b/packages/api/remotes/src/remote-events.ts @@ -15,7 +15,9 @@ * else. */ export const API_REMOTE_FORWARDED_EVENTS = [ + 'agent-preset/selected', 'commands/change', 'credentials/updated', + 'llm/adapters-updated', 'settings/document-updated', ] as const diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index c02e98656a..256258c668 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -26,6 +26,12 @@ { "path": "../../interaction/commands" }, + { + "path": "../../llm/llm" + }, + { + "path": "../../preset/agent-presets" + }, { "path": "../../settings/settings" }, diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index fbdfb6a94c..c796add816 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -28,6 +28,12 @@ { "path": "../../interaction/commands" }, + { + "path": "../../llm/llm" + }, + { + "path": "../../preset/agent-presets" + }, { "path": "../../session/session-persistence" }, diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 69634d4ca5..be04f56ac5 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list and scope state, and the shared event window and history paging used by registered conversation view targets. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into Session and Workspace owners and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `session/preset-changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. `host/session-preset-changed` also folds its preset into the session row, because the switch's RPC echo reaches only the client that issued it. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. +Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list and scope state, and the shared event window and history paging used by registered conversation view targets. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into Session and Workspace owners and hands each generic `host/remote-event` frame to `ctx.remote.$dispatch`; domain packages subscribe to their owner events through `ctx.remote.$on` and decide which caches or session rows they invalidate. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. `bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, revision, writability, host/memory mode), serializes `set` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime. ## Slot declaration injection diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 9e03cc1903..372922b8b0 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表与 scope 状态,以及供已注册 conversation view target 共用的事件窗口与历史分页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session 与 Workspace 所有者,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`session/preset-changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。`host/session-preset-changed` 还会把其中的 preset 折进会话行,因为这次切换的 RPC 回执只会到达发起它的那个客户端。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。约定:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 +客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表与 scope 状态,以及供已注册 conversation view target 共用的事件窗口与历史分页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session 与 Workspace 所有者,并把每个通用 `host/remote-event` 帧交给 `ctx.remote.$dispatch`;各领域包通过 `ctx.remote.$on` 订阅自身 owner 事件,并自行决定使哪些缓存或会话行失效。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。约定:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 `bindSettingsScope` 面向单个由领域持有的 namespace,是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照(状态、分节值、revision、可写性、host/内存模式),使用已知最新 namespace revision 串行执行 `set` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema(经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API,远程页面则停留在内存模式。namespace schema、默认值与实时服务归领域包所有,而非把产品政策放入运行时。 ## Slot 声明注入 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 0d8671eb4f..4f80cd14e6 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -157,24 +157,6 @@ declare module '@deepseek-ai/cordis' { * @param key - the mutated SlotMap key. */ 'slots/changed'(key: string): void - /** - * The host provider topology changed (host/models-changed passthrough). - * Subscribers refetch `llm.providers`/`llm.models`/`session.models`. - * @mode emit - */ - 'models/changed'(): void - /** - * One session's agent preset changed (host/session-preset-changed - * passthrough), so everything its composition decides — the command - * catalog, the skill catalog — is stale for that session and no other. - * Every connected client observes it, not only the one that issued the - * switch. Subscribers refetch their own session-keyed caches; the frame - * carries no catalog. - * @mode emit - * @param sessionId - the session whose composition changed. - * @param agentPreset - the preset it now runs. - */ - 'session/preset-changed'(sessionId: SessionId, agentPreset: string): void /** * A connection generation was (re-)established. Wire-derived caches must * treat their state as stale and repull (commands directory; the queue @@ -231,10 +213,6 @@ export function apply(ctx: Context): void { // `ctx.remote.$on` subscribers; no consumer reads a frame. const frame = envelope.payload if (frame.type === 'host/remote-event') ctx.remote.$dispatch(frame.event, frame.args) - else if (frame.type === 'host/session-preset-changed') { - ctx.emit('session/preset-changed', frame.sessionId, frame.agentPreset) - } - else if (frame.type === 'host/models-changed') ctx.emit('models/changed') }, onConnected: () => { sessions.handleConnected() diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 53e042952a..acb56fbde0 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -800,14 +800,6 @@ export class SessionManager { } return } - case 'host/session-preset-changed': { - // Every connected client observes the switch here; only the tab that - // issued it also gets the RPC echo. The merge keeps the row's own - // updatedAt and lowers `blank` only, so re-applying the switching - // tab's own frame is a no-op. - this.noteAgentPreset(frame.sessionId, frame.agentPreset) - return - } case 'host/session-removed': { const summary = this.summaries.find(candidate => candidate.sessionId === frame.sessionId) const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(frame.sessionId) diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index c80e7e78c1..757376b616 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -87,23 +87,6 @@ describe('list store projection', () => { expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal') }) - it('learns a preset switch from the host frame, not only from the tab that issued it', async () => { - const b = bench() - await feedList(b, [{ id: 's1', blank: true, agentPreset: 'standard' }]) - - // Every connected client gets this frame; only the switching tab gets the - // RPC echo. A client that ignored the payload would keep labelling the - // session with the composition it replaced. - b.svc.handleHostEnvelope({ - rpcId: 'r1' as never, - payload: { type: 'host/session-preset-changed', sessionId: sid('s1'), agentPreset: 'minimal' } as never, - }) - await Promise.resolve() - - expect(b.svc.list.getSnapshot().byId[sid('s1')]?.agentPreset).toBe('minimal') - expect(b.svc.list.getSnapshot().byId[sid('s1')]?.blank).toBe(true) - }) - it('reflects live increments (host stream via manager) into the store', async () => { const b = bench() await feedList(b, [{ id: 's1' }]) diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index ca47db2a0b..4e653fb8d6 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -1,11 +1,8 @@ /** * Wire-to-typed-event bridge: a `host/remote-event` frame is handed verbatim to * the Remote service's `$dispatch` (its fan-out to `ctx.remote.$on` is - * api-gateway's own coverage); - * host/session-preset-changed → ctx 'session/preset-changed'; - * `host/models-changed` still broadcasts the typed `models/changed`; each - * established connection generation → ctx 'connection/reset' (the forced - * cache-invalidation broadcast). + * api-gateway's own coverage); each established connection generation emits + * `connection/reset` for generation-scoped cache invalidation. */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' @@ -33,6 +30,10 @@ function forwardedEventContracts(ctx: Context): void { }) ctx.remote.$on('credentials/updated', () => {}) ctx.remote.$on('commands/change', () => {}) + ctx.remote.$on('llm/adapters-updated', () => {}) + ctx.remote.$on('agent-preset/selected', (sessionId, agentPreset) => { + void sessionId; void agentPreset + }) // @ts-expect-error -- client-local event outside the allowlist ctx.remote.$on('slots/changed', () => {}) // @ts-expect-error -- declared host event the allowlist does not select @@ -117,25 +118,6 @@ describe('wire event bridge', () => { ]) }) - it('still broadcasts the typed models/changed invalidation (its host frame is unchanged)', async () => { - const bench = await mount() - let models = 0 - bench.ctx.on('models/changed', () => { models++ }) - bench.sinks?.onHostEnvelope?.({ rpcId: 'r6' as never, payload: { type: 'host/models-changed' } }) - expect(models).toBe(1) - }) - - it('broadcasts session/preset-changed with the recomposed session and its new preset', async () => { - const bench = await mount() - const seen: Array<[string, string]> = [] - bench.ctx.on('session/preset-changed', (sessionId, agentPreset) => { seen.push([sessionId, agentPreset]) }) - bench.sinks?.onHostEnvelope?.({ - rpcId: 'r1' as never, - payload: { type: 'host/session-preset-changed', sessionId: 's1' as never, agentPreset: 'minimal' }, - }) - expect(seen).toEqual([['s1', 'minimal']]) - }) - it('broadcasts connection/reset on every established generation (reconnect invalidation)', async () => { const bench = await mount() let resets = 0 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 3b0db5a3ee..f0fdff4b1f 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -18,7 +18,7 @@ A session that has started is refused rather than queued: the host answers `agen ## The session-header label -A third surface, beside the session title: the preset THIS session runs, as static chrome. A control there would promise a switch the host refuses outright. It reads the preset from the session's own summary — a resumed session runs what it was created with, not today's default — and resolves the display name against the same roster the General row reads. +A third surface, beside the session title: the preset THIS session runs, as static chrome. A control there would promise a switch the host refuses outright. It reads the preset from the session's own summary and resolves the display name against the same roster the General row reads. Forwarded `agent-preset/selected` owner events fold committed blank-session switches into that shared summary in every tab; the initiating tab may already have applied the RPC echo, and the merge is idempotent. ## What it reads and writes diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 6f3c350f97..c391a4548f 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -18,7 +18,7 @@ chip 以部署默认值打开,其选择是**暂存**的——该界面先于 ## 会话标题旁的标签 -第三个表层,位于会话标题旁:**本会话**所运行的 preset,作为静态装饰呈现。在那里放一个控件,等于承诺一次宿主会断然拒绝的切换。它从会话自身的摘要读取 preset——被恢复的会话运行的是它创建时的那一份,而非今天的默认值——并在 General 行所读的同一份名单上解析显示名称。 +第三个表层,位于会话标题旁:**本会话**所运行的 preset,作为静态装饰呈现。在那里放一个控件,等于承诺一次宿主会断然拒绝的切换。它从会话自身的摘要读取 preset,并在 General 行所读的同一份名单上解析显示名称。转发的 owner 事件 `agent-preset/selected` 会在每个标签页中把已经提交的空会话切换折进这份共享摘要;发起方标签页可能已经采用 RPC 回执,而合并是幂等的。 ## 它读什么、写什么 diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 05abfbd851..2ec44b48bc 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -141,6 +141,11 @@ export function apply(ctx: ClientContext): void { if (ns !== AGENT_PRESET_SETTINGS_NS) return void seat.load() }) + // Every tab folds the committed preset into the shared session row; the + // initiating tab may already have applied the RPC echo, which is idempotent. + const presetSelected = scope.remote.$on('agent-preset/selected', (sessionId, agentPreset) => { + scope.sessions.noteAgentPreset(sessionId, agentPreset) + }) // Authoring writes a FILE, not a setting, so nothing on the wire // announces it — without this the screen that starts the next session // keeps offering the roster as it stood when the chip first loaded, and @@ -173,6 +178,7 @@ export function apply(ctx: ClientContext): void { return () => { stop() settingsMoved() + presetSelected() rosterReaders.delete(readRoster) creatorDraft = undefined chip() diff --git a/packages/client/ui-agent-preset/tests/apply.spec.ts b/packages/client/ui-agent-preset/tests/apply.spec.ts index f719f9f371..81120d2822 100644 --- a/packages/client/ui-agent-preset/tests/apply.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.spec.ts @@ -165,6 +165,12 @@ function sessionsDouble(state: { return () => listeners.delete(fn) }, }, + noteAgentPreset: (sessionId: string, agentPreset: string) => { + const summary = state.byId[sessionId] + if (summary === undefined || summary.agentPreset === agentPreset) return + summary.agentPreset = agentPreset + for (const fn of listeners) fn() + }, /** Push a list change the way the runtime's store does. */ notify: () => { for (const fn of listeners) fn() }, } @@ -347,6 +353,24 @@ describe('ui-agent-preset apply', () => { conversation() }) + it('folds a remote preset commit into the shared session row', async () => { + const { ctx, slots } = await bench() + declareRoot(slots) + declareConversation(slots) + ctx.provide('conversation', {} as never) + const state = { + current: 's1', + byId: { s1: { id: 's1', blank: true, agentPreset: 'standard' } }, + } + ctx.provide('sessions', sessionsDouble(state) as never) + ctx.provide('workspaces', workspacesDouble() as never) + await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await() + + ctx.remote.$dispatch('agent-preset/selected', ['s1', 'minimal']) + + expect(state.byId.s1.agentPreset).toBe('minimal') + }) + it('offers a just-authored preset on the new-session chip', async () => { const { ctx, slots } = await bench() declareRoot(slots) diff --git a/packages/client/ui-command/README.md b/packages/client/ui-command/README.md index e49ce89804..60b70cfbc3 100644 --- a/packages/client/ui-command/README.md +++ b/packages/client/ui-command/README.md @@ -6,7 +6,7 @@ Client command API (`ctx.command`): the session-keyed command-directory cache, t `src/client/contract.ts` is the fixed business contract: `CommandServiceContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-contained — the shell component belongs to this package and business packages never see it. A contribution is a client-owned command (a host-name collision fails loud); a decoration adds a bare-invocation popup to an EXISTING host command. The host keeps its catalog row, argument claim (space / argued Enter), and lifecycle logging, and a decorated name with no host row in the session's directory never fires. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is `leadingInput`, a registered `CommandUiSpec` is `popupSelect`, and everything else is `execute`. -`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies) and by `session/preset-changed` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt. +`CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the forwarded `commands/change` owner event (old snapshots serve while the repull flies) and by forwarded `agent-preset/selected` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, and epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt. Menu queries fuzzy-match ordered, case-insensitive subsequences of command names. Prefixes rank first; separator boundaries, adjacent characters, and shorter gaps rank the remaining matches, with directory and contribution order breaking ties. This affects discovery only: space and Enter still require an exact command name. Rationale: [Web slash-command fuzzy discovery](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md). diff --git a/packages/client/ui-command/README.zh.md b/packages/client/ui-command/README.zh.md index 8bd5afd7d0..639b7f997e 100644 --- a/packages/client/ui-command/README.zh.md +++ b/packages/client/ui-command/README.zh.md @@ -6,7 +6,7 @@ `src/client/contract.ts` 是固定的业务 API 约定:`CommandServiceContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 自己提供 popup 数据——外层组件归本包所有,业务包永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-loud);decoration(装饰)则为**已存在的** host 命令添加裸调用 popup。host 保留目录行、带参 claim(space / 带参 Enter)与生命周期记账,被装饰的名字若在会话目录中无 host 行,则永不触发。命令类型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 `leadingInput`,注册了 `CommandUiSpec` 的是 `popupSelect`,其余全部是 `execute`。 -`CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),也由 `session/preset-changed` 对该会话单独软失效(重组 agent 不产生任何注册,注册表级信号不会为它触发),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。 +`CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由转发的 owner 事件 `commands/change` 软失效(重拉在途期间旧快照继续服务),也由转发的 `agent-preset/selected` 对该会话单独软失效(重组 agent 不产生任何注册,注册表级信号不会为它触发),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。 菜单查询会按顺序且不区分大小写地模糊匹配命令名的子序列。前缀排名最高;其余匹配项按分隔符边界优先、相邻字符优先、间隔越短越优先的规则排序,若仍同分,则以目录顺序和 contribution 顺序打破平局。此行为只影响命令发现:space 和 Enter 仍要求命令名精确匹配。原理:[Web 斜杠命令模糊发现](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)。 diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index 02d5b1f14b..86a7550cef 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -131,7 +131,7 @@ export class CommandService extends Service implements CommandServiceContract { // registers nothing globally, so the registry-wide signal above never // fires for it: repull that key alone, soft, so the old snapshot serves // the menu until the new one lands. - ctx.on('session/preset-changed', (sessionId) => { void this.directory.refresh(sessionId) }) + ctx.remote.$on('agent-preset/selected', (sessionId) => { void this.directory.refresh(sessionId) }) ctx.on('connection/reset', () => { this.directory.resetConnected() }) } diff --git a/packages/client/ui-command/tests/service.spec.ts b/packages/client/ui-command/tests/service.spec.ts index 839934eb53..b2e5826cdd 100644 --- a/packages/client/ui-command/tests/service.spec.ts +++ b/packages/client/ui-command/tests/service.spec.ts @@ -621,7 +621,7 @@ describe('directory invalidation events', () => { expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined() }) - it('session/preset-changed repulls the recomposed session and leaves the others served', async () => { + it('agent-preset/selected repulls the recomposed session and leaves the others served', async () => { const rounds = new Map() const { ctx, source, warm } = await bench({ commands: (payload) => { @@ -638,7 +638,7 @@ describe('directory invalidation events', () => { await warm(proj('s2')) // A preset switch changes which commands one session's agent resolves; // every other session keeps the catalog its own composition serves. - ctx.emit('session/preset-changed', sid('s1'), 'minimal') + ctx.remote.$dispatch('agent-preset/selected', [sid('s1'), 'minimal']) await new Promise(resolve => setTimeout(resolve, 0)) expect(source.matchSpace!(proj('s1'), '/fresh')).not.toBeUndefined() expect(source.matchSpace!(proj('s1'), '/goal')).toBeUndefined() diff --git a/packages/client/ui-model/README.md b/packages/client/ui-model/README.md index fdc3258eb3..e49fdee52c 100644 --- a/packages/client/ui-model/README.md +++ b/packages/client/ui-model/README.md @@ -10,6 +10,8 @@ When the Host reports that no adapter serves the session's route (`session.model Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. Addressed subagent sessions expose neither entry, and their directory rejects loads, selections, and reconnect refreshes, because ordinary Agent-bound model RPCs would activate persisted child history outside the direct-parent continuation path. +Every resident directory refetches directly on forwarded `llm/adapters-updated` and `settings/document-updated` owner events. Provider topology, provider catalogs, and the default selection therefore converge without the Host or client runtime deriving a separate model-change alias. + The `/client` exports are the plugin body (`apply`/`inject`), `ModelService`, `ModelDirectory` with its state fields, and the seat's injected face type. ## Model Experience diff --git a/packages/client/ui-model/README.zh.md b/packages/client/ui-model/README.zh.md index 116e151d1a..366c96c3e3 100644 --- a/packages/client/ui-model/README.zh.md +++ b/packages/client/ui-model/README.zh.md @@ -10,6 +10,8 @@ Host 报告的 `ModelSelection` 是唯一的选择事实,其中包含提供方 目录按会话惰性解析(`ctx.models.directoryFor(sessionId)`),随会话作用域一并释放。已寻址 subagent 会话不公开任一入口,其目录会拒绝加载、选择与重新连接刷新,因为绑定到 agent(智能体)的普通模型 RPC 会在直接 parent 继续执行路径之外激活持久化 child 历史。 +每一份常驻目录都会直接在转发的 owner 事件 `llm/adapters-updated` 与 `settings/document-updated` 上重拉。因此提供方拓扑、提供方目录与默认选择都能收敛,Host 与 client runtime 无需再派生一个单独的模型变更别名。 + `/client` 导出面为插件本体(`apply`/`inject`)、`ModelService`、`ModelDirectory` 及其状态形状、slot 注入面类型。 ## 模型体验 diff --git a/packages/client/ui-model/package.json b/packages/client/ui-model/package.json index 069fb47c8e..1e4e70e356 100644 --- a/packages/client/ui-model/package.json +++ b/packages/client/ui-model/package.json @@ -34,7 +34,8 @@ "inject": [ "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-command" + "@deepseek-ai/dsh-client-ui-command", + "@deepseek-ai/dsh-api-remotes" ], "platform": "web" } @@ -45,6 +46,7 @@ }, "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", @@ -59,9 +61,11 @@ "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "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-command": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", diff --git a/packages/client/ui-model/src/client/index.ts b/packages/client/ui-model/src/client/index.ts index 63feadebef..f883b16659 100644 --- a/packages/client/ui-model/src/client/index.ts +++ b/packages/client/ui-model/src/client/index.ts @@ -12,6 +12,8 @@ * history outside the direct-parent continuation path. */ import type { ModelSelection, SessionModels } from '@deepseek-ai/dsh-client-connection/client' +// Type-only: pulls the forwarded Host-event face and ctx.remote merge. +import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client' // Type-only: pulls the ui-conversation SlotMap merge (the input.model seat). @@ -96,7 +98,7 @@ function selectionOf(state: ModelDirectoryState, id: string): ModelSelection | u const NS = 'model' /** Required services: the contribution registry, the seat's slot registry, locale, and the service's own faces. */ -export const inject = ['command', 'connection', 'locale', 'sessions', 'slots'] +export const inject = ['command', 'connection', 'locale', 'sessions', 'slots', 'remote'] /** * Client plugin body: mount ModelService, register the `model` dictionaries, diff --git a/packages/client/ui-model/src/client/service.ts b/packages/client/ui-model/src/client/service.ts index 5a35ce0039..9653d8ab5a 100644 --- a/packages/client/ui-model/src/client/service.ts +++ b/packages/client/ui-model/src/client/service.ts @@ -32,7 +32,7 @@ interface LiveState { /** The `ctx.models` session model-selection service. */ export class ModelService extends Service { - static inject = ['connection', 'sessions'] + static inject = ['connection', 'sessions', 'remote'] private readonly live: LiveState = { directories: new Map() } @@ -49,14 +49,15 @@ export class ModelService extends Service { ctx.on('connection/reset', () => { for (const directory of this.live.directories.values()) directory.resetConnected() }) - // Provider topology changed on the host (a settings-born route appeared - // or dropped): refresh every open directory in the background so pickers - // show the new catalog without a reopen. Failures stay on each store. - ctx.on('models/changed', () => { + // Either source can change the directory: registry topology commits and + // settings documents that carry provider catalogs or default selection. + const refresh = (): void => { for (const directory of this.live.directories.values()) { directory.load().catch(() => undefined) } - }) + } + ctx.remote.$on('llm/adapters-updated', refresh) + ctx.remote.$on('settings/document-updated', refresh) } /** diff --git a/packages/client/ui-model/tests/browser-plugin.spec.ts b/packages/client/ui-model/tests/browser-plugin.spec.ts index 4c7a1430b1..c21a83b0ce 100644 --- a/packages/client/ui-model/tests/browser-plugin.spec.ts +++ b/packages/client/ui-model/tests/browser-plugin.spec.ts @@ -13,6 +13,7 @@ import { describe, expect, it } from 'vitest' import { createScope } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { ModelSelection } from '@deepseek-ai/dsh-client-connection/client' import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client' import type { ModelSelectInjected } from '../src/client/slots.ts' @@ -112,6 +113,7 @@ async function bench() { ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } : undefined, }) + new TestRemote(ctx) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() await ctx.plugin(function probe() {}).await() @@ -245,14 +247,14 @@ describe('ui-model dual entry', () => { expect(b.blockOf('s1')).toBeUndefined() b.setRoutable(false) - b.ctx.emit('models/changed') + b.ctx.remote.$dispatch('llm/adapters-updated', []) await Promise.resolve() await Promise.resolve() expect(b.blockOf('s1')?.reason).toBe(zh['blocked.composer']) // Recovering clears it without a reload of the surface. b.setRoutable(true) - b.ctx.emit('models/changed') + b.ctx.remote.$dispatch('settings/document-updated', ['llm-deepseek', 1]) await Promise.resolve() await Promise.resolve() expect(b.blockOf('s1')).toBeUndefined() diff --git a/packages/client/ui-model/tsconfig.json b/packages/client/ui-model/tsconfig.json index f3319b6312..80165da01f 100644 --- a/packages/client/ui-model/tsconfig.json +++ b/packages/client/ui-model/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../api/remotes/tsconfig.client.json" } ] } diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index 89a253fca9..f6604f8224 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -8,7 +8,7 @@ Rows are the *configured* providers (their profile resolves in the owning namesp The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. -Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, with the same fields the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that pasted-line check runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. +Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it mutates the fields it can see rather than rebuilding a section. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, with the same fields the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that pasted-line check runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. Once loaded, the page subscribes directly to forwarded `settings/document-updated`, `credentials/updated`, and `llm/adapters-updated` owner events, plus local `connection/reset`, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. ## Model list and endpoint interrogation diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 350be495f1..2500bbae09 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -8,7 +8,7 @@ 前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。凭据引用已配置时,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 -每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它只修改自己看得见的字段,而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,使用与 pi-ai 提供方表单相同的字段。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。与整行粘贴的 `NAME=value` 环境变量匹配或首尾成对引号包裹的值,会以同一条格式失败被拒绝;这项粘贴行检查只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 +每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它只修改自己看得见的字段,而不重建分节。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,使用与 pi-ai 提供方表单相同的字段。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。与整行粘贴的 `NAME=value` 环境变量匹配或首尾成对引号包裹的值,会以同一条格式失败被拒绝;这项粘贴行检查只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会直接订阅转发的 owner 事件 `settings/document-updated`、`credentials/updated`、`llm/adapters-updated`,以及本地 `connection/reset`,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 ## 模型列表与端点询问 diff --git a/packages/client/ui-models/src/client/index.ts b/packages/client/ui-models/src/client/index.ts index 3262c0c1d3..079318c4d2 100644 --- a/packages/client/ui-models/src/client/index.ts +++ b/packages/client/ui-models/src/client/index.ts @@ -87,7 +87,7 @@ export function apply(ctx: ClientContext): void { const disposers = [ ctx.remote.$on('settings/document-updated', refresh), ctx.remote.$on('credentials/updated', refresh), - ctx.on('models/changed', refresh), + ctx.remote.$on('llm/adapters-updated', refresh), ctx.on('connection/reset', refresh), ] return () => { for (const dispose of disposers) dispose() } diff --git a/packages/client/ui-models/tests/apply.spec.ts b/packages/client/ui-models/tests/apply.spec.ts index a5aa39e281..7be4f6d7a4 100644 --- a/packages/client/ui-models/tests/apply.spec.ts +++ b/packages/client/ui-models/tests/apply.spec.ts @@ -140,7 +140,7 @@ describe('pushed invalidations', () => { // The fake wire face has no methods: a fetch attempt would throw. b.ctx.remote.$dispatch('settings/document-updated', ['llm-pi-ai', 1]) b.ctx.remote.$dispatch('credentials/updated', ['OPENAI_API_KEY']) - b.ctx.emit('models/changed') + b.ctx.remote.$dispatch('llm/adapters-updated', []) b.ctx.emit('connection/reset') }) diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index 0456db4de9..f6818ae9f4 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry, `session/preset-changed` drops that one session's entry (the catalog belongs to the preset, and a blank session may switch after the warm), and `connection/reset` clears everything. Results filter by `startsWith(query)`. +Skill invocation source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Ordinary-session candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}`, with the host resolving `cwd` from the session header. The host serves every user-invocable skill; a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Catalog-addressed continuable children resolve no skill candidates locally because the existing skill RPC requires an attached session; viewing their persisted history must not activate them. Catalogs cache per ordinary session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry, the forwarded `agent-preset/selected` owner event drops that one session's entry (the catalog belongs to the preset, and a blank session may switch after the warm), and `connection/reset` clears everything. Results filter by `startsWith(query)`. A pick lands the literal `/name ` text and the prompt ships the same literal ([slash-pipeline Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md)) — this source implements no adjudication hooks and no reference codec. Determinism lives host-side: the pre-step gesture boundary (`dsh-tool-skill`) recognizes whitespace-bounded `/name` tokens naming user-invocable skills anywhere in a user message and injects the rendered `` for every entry point, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. A name shared with a host command still resolves to the command: adjudication claims the line client-side before it ever becomes a prompt — deliberate precedence, matching peer products. The list RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument; draft chip visuals derive from the `lexicon` scan. diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 336f43117e..4b9d646fe3 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`session/preset-changed` 丢弃该会话这一项(目录属于 preset,而空会话可能在预热之后才切换),`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。 +skill(技能)调用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。普通会话的候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址,host 从会话 header 解析 `cwd`。宿主提供每一个用户可调用的 skill;`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。由目录寻址的可继续 subagent 在客户端解析为没有 skill 候选,因为现有 skill RPC 要求会话已挂载;查看其持久化历史不得激活它。目录按普通会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,转发的 owner 事件 `agent-preset/selected` 丢弃该会话这一项(目录属于 preset,而空会话可能在预热之后才切换),`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤。 pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文本([slash 流水线 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md))——本 source 不实现任何裁决钩子,也没有引用 codec。确定性在宿主侧:pre-step 手势边界(`dsh-tool-skill`)识别用户消息中任意位置、以空白为界、指名用户可调用 skill 的 `/name` token,并为每个入口注入渲染后的 ``,因此菜单 pick、手动键入的 token 与 TUI/ACP(Agent Client Protocol)提示词都以同一种方式加载 skill。与宿主命令同名的名称仍解析为命令:裁决在客户端把该行认领走,它根本不会成为提示词——这是有意的优先级,与同行产品一致。列表 RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务;草稿 chip 视觉由 `lexicon` 扫描派生。 diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index 5734767d7b..cc6dd4bb60 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -35,7 +35,8 @@ "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-tool", - "@deepseek-ai/dsh-client-ui-slash" + "@deepseek-ai/dsh-client-ui-slash", + "@deepseek-ai/dsh-api-remotes" ], "platform": "web" } @@ -46,6 +47,7 @@ }, "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", @@ -58,6 +60,7 @@ "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 7a51c6f5d6..5511398234 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -30,6 +30,8 @@ * accent row derived only from each logged call/result slice. */ import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-client-connection/client' +// Type-only: pulls the forwarded Host-event face and ctx.remote merge. +import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client' import type { SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -53,7 +55,7 @@ interface CatalogFetch { } /** Required services: reference source faces plus the tool-row and locale registries. */ -export const inject = ['slash', 'connection', 'sessions', 'slots', 'locale'] +export const inject = ['slash', 'connection', 'sessions', 'slots', 'locale', 'remote'] /** * Client plugin body: register the '/' source, dictionaries, and keyed tool row. @@ -178,7 +180,7 @@ export function apply(ctx: ClientContext): void { const slash = ctx.get('slash') as SlashServiceContract // A preset decides which skill providers an agent reads, so a switched // session's cached catalog belongs to the composition it no longer runs. - ctx.on('session/preset-changed', invalidate) + ctx.remote.$on('agent-preset/selected', invalidate) ctx.on('connection/reset', clearAll) ctx.effect(() => { const unregister = slash.registerSource(source) diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index 844333dd81..4a75ad187f 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -18,6 +18,7 @@ import { describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' import { apply, inject } from '../src/client/index.ts' import { SkillRow as SkillToolRow } from '../src/client/SkillRow.tsx' @@ -73,6 +74,7 @@ async function bench(list: ListFn, addressed?: SessionId, invoke?: InvokeFn) { ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } : undefined, }) + new TestRemote(ctx) providePresentation(ctx) await ctx.plugin({ inject: [...inject], apply }).await() return { ctx, source: captured! } @@ -105,7 +107,7 @@ const req = (query: string, signal?: AbortSignal) => describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['slash', 'connection', 'sessions', 'slots', 'locale']) + expect(inject).toEqual(['slash', 'connection', 'sessions', 'slots', 'locale', 'remote']) }) it('registers the dedicated skill row and its locale dictionaries', async () => { @@ -113,6 +115,7 @@ describe('apply', () => { ctx.provide('slash', { registerSource: () => () => {} }) ctx.provide('connection', { api: { skills: { list: listOk(CATALOG) } } }) ctx.provide('sessions', { subagentAddress: () => undefined }) + new TestRemote(ctx) const presentation = providePresentation(ctx) await ctx.plugin({ inject: [...inject], apply }).await() const entry = presentation.slots.entries('tool.call.toolview')[0] @@ -263,7 +266,7 @@ describe('catalog cache', () => { expect(payloads).toHaveLength(2) }) - it('session/preset-changed clears only the recomposed session', async () => { + it('agent-preset/selected clears only the recomposed session', async () => { const { list, payloads } = countingList() const { ctx, source } = await bench(list) await source.candidates(proj('s1'), req('')) @@ -271,7 +274,7 @@ describe('catalog cache', () => { expect(payloads).toHaveLength(2) // The catalog a preset supplies is the preset's; the other session's // composition did not change, so its cached catalog still holds. - ctx.emit('session/preset-changed', sid('s1'), 'minimal') + ctx.remote.$dispatch('agent-preset/selected', [sid('s1'), 'minimal']) await source.candidates(proj('s1'), req('')) await source.candidates(proj('s2'), req('')) expect(payloads).toHaveLength(3) diff --git a/packages/client/ui-skill/tsconfig.json b/packages/client/ui-skill/tsconfig.json index d6ec931648..b1d9835eee 100644 --- a/packages/client/ui-skill/tsconfig.json +++ b/packages/client/ui-skill/tsconfig.json @@ -34,6 +34,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../api/remotes/tsconfig.client.json" } ] } diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 426aa32c6b..7967b41b73 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -54,9 +54,9 @@ The `agentPreset.list` domain exposes the deployment's preset roster so a browse `agentPreset.read`, `copy`, `openDocument`, and `remove` manage the compositions themselves. `read` reports the text with its `trust`, for the read-only viewer. Authoring is copy-only: `copy` takes `{ from, agentPreset, name? }` — two ids the Host resolves against its own roots plus an optional display name — and copies the source's whole directory, so no composition text crosses the wire and a copy is exactly as loadable as its source; an uncontainable or already-taken id answers `agent-preset-invalid`, and `remove` refuses a shipped preset as `agent-preset-read-only`. `openDocument` hands one locally authored preset's DIRECTORY to the platform opener — the request carries an id, never a path, so no browser payload can select an arbitrary filesystem target; where the deployment has no native opener the reply is `{ opened: false, path }` for the surface to show as text, a shipped preset is refused like `remove`, and the gateway's `nativeOpen` config pins the capability where platform detection (`canOpenNativePath`) would mislead. These four are loopback-pinned in [`dsh-client-connection`](../../client/connection/README.md): a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop. `list` and `select` stay ordinary — the roster carries ids and trust and every preset picker needs it, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash. `list` reports two path-free capability flags: `authorable`, whether the deployment configures a root a new preset could be copied to, and `hasDocument`, whether `openDocument` would open natively rather than answer a path. -The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only invocation path is the slash gesture. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `` context, so every entry point (Web, TUI, and ACP) shares one deterministic path—including for hand-typed text—with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `commands/change` rides the forwarded-event frame as the registry-wide catalog invalidation signal: clients refetch `command.list` instead of diffing. `host/session-preset-changed` is its per-session counterpart, framed off the logged `agent-preset/selected` commit: recomposing a blank session's agent re-parents its scope without registering anything, so both catalogs that session's composition decides (`command.list`, `skill.list`) go stale with no registry change to announce it. +The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only invocation path is the slash gesture. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `` context, so every entry point (Web, TUI, and ACP) shares one deterministic path—including for hand-typed text—with no dedicated invocation wire. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `commands/change` rides the forwarded-event frame as the registry-wide catalog invalidation signal: clients refetch `command.list` instead of diffing. Forwarded `agent-preset/selected` is its per-session counterpart, emitted from the logged selection commit: recomposing a blank session's agent re-parents its scope without registering anything, so both catalogs that session's composition decides (`command.list`, `skill.list`) go stale with no registry change to announce it. -The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preferences `locale`, `permission`, `ui-conversation`, and `ui-theme`, and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Invalidations keep every surface converged without polling. `settings/document-updated` and `credentials/updated` ride the verbatim forwarded-event frame (see below), so a raw settings change whose resolved value is unchanged still reaches clients, and a credential invalidation still carries reference names only, never values. `host/models-changed` stays a derived frame of this package's own: it is fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `locale`, `permission`, `ui-conversation`, `ui-theme`, or `ui-onboarding` change emits only its forwarded settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. +The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preferences `locale`, `permission`, `ui-conversation`, and `ui-theme`, and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Invalidations keep every surface converged without polling. `settings/document-updated` and `credentials/updated` ride the verbatim forwarded-event frame (see below), so a raw settings change whose resolved value is unchanged still reaches clients, and a credential invalidation still carries reference names only, never values. `llm/adapters-updated` is forwarded beside `settings/document-updated`; concrete model consumers subscribe to both owner events directly because topology commits and settings documents can independently change their directories. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. ## Carrier layer (`/client` + root) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 7538379855..325ea29a73 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -54,9 +54,9 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `agentPreset.read`、`copy`、`openDocument` 与 `remove` 负责管理组装本身。`read` 返回文本连同它的 `trust`,供只读查看器使用。创作只有复制一种写入:`copy` 接收 `{ from, agentPreset, name? }`——两个由 Host 对照自身根目录解析的 id 加一个可选显示名——并整目录复制来源,因此组装文本不经过传输层,副本与其来源同等可加载;不可约束或已被占用的 id 回答 `agent-preset-invalid`,`remove` 对随附 preset 回答 `agent-preset-read-only`。`openDocument` 把一个本地创作 preset 的**目录**交给平台打开器——请求只携带 id、绝不携带路径,因此没有任何浏览器载荷能选中任意文件系统目标;部署没有原生打开器时回答 `{ opened: false, path }` 供界面以文本展示,随附 preset 与 `remove` 一样被拒绝,而网关的 `nativeOpen` 配置可在平台探测(`canOpenNativePath`)失真处钉死该能力。这四个方法在 [`dsh-client-connection`](../../client/connection/README.md) 中被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面。`list` 与 `select` 保持为普通方法——名单只携带 id 与信任级别,每个 preset 选择器都需要它;而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash。`list` 报告两个不含路径的能力标志:`authorable`,即部署是否配置了可供复制新 preset 的根目录;`hasDocument`,即 `openDocument` 会原生打开、还是回答一个路径。 -`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的调用路径。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `` 上下文作答,因此所有入口(Web、TUI 与 ACP(Agent Client Protocol))共享同一条确定性路径,手动键入的文本也走该路径,且没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`commands/change` 搭乘转发事件帧作为注册表级目录失效信号:客户端重新拉取 `command.list` 而不是做差分。`host/session-preset-changed` 是它按会话粒度的对应物,由落账的 `agent-preset/selected` 提交点成帧:重组空会话的 agent 只是重新挂接其 scope,不产生任何注册,因此该会话组成所决定的两份目录(`command.list`、`skill.list`)都会失效,却没有任何注册表变化来宣告它。 +`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的调用路径。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `` 上下文作答,因此所有入口(Web、TUI 与 ACP(Agent Client Protocol))共享同一条确定性路径,手动键入的文本也走该路径,且没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`commands/change` 搭乘转发事件帧作为注册表级目录失效信号:客户端重新拉取 `command.list` 而不是做差分。转发的 `agent-preset/selected` 是它按会话粒度的对应物,由落账的选择提交点发出:重组空会话的 agent 只是重新挂接其 scope,不产生任何注册,因此该会话组成所决定的两份目录(`command.list`、`skill.list`)都会失效,却没有任何注册表变化来宣告它。 -`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `locale`、`permission`、`ui-conversation` 与 `ui-theme`,以及产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。失效通知让每个面无需轮询即保持收敛。`settings/document-updated` 与 `credentials/updated` 搭乘原样转发事件帧(见下),因此解析值未变的原始设置变更同样能到达客户端,凭据失效通知也仍然只带引用名、绝不带值。`host/models-changed` 仍是本包自有的派生帧:它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`locale`、`permission`、`ui-conversation`、`ui-theme` 或 `ui-onboarding` 变更只会发出自身的转发 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 +`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `locale`、`permission`、`ui-conversation` 与 `ui-theme`,以及产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;陈旧的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。失效通知让每个面无需轮询即保持收敛。`settings/document-updated` 与 `credentials/updated` 搭乘原样转发事件帧(见下),因此解析值未变的原始设置变更同样能到达客户端,凭据失效通知也仍然只带引用名、绝不带值。`llm/adapters-updated` 与 `settings/document-updated` 一并原样转发;具体模型消费方直接订阅这两个 owner 事件,因为拓扑提交和设置文档都能独立改变其目录。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 ## 载体层(`/client` + 根路径) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 16390a3331..b39909a198 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -9,7 +9,7 @@ import { dirname } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import { installModelSelection } from '@deepseek-ai/dsh-agent' import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent' -import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model' +import type {} from '@deepseek-ai/dsh-agent-presets/types' import { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { contentHasImage, createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' @@ -3467,10 +3467,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }), // Allowlisted host events ride one verbatim wrapper frame each. The // allowlist is api-remotes', and `ctx.remote.$on` is the consumer - // face; nothing here projects, redacts, or renames. Registered ahead - // of the derived frames below so a forwarded event still precedes the - // invalidation derived from it (`settings/document-updated` before - // its `host/models-changed`), which is the order a client sees. + // face; nothing here projects, redacts, or renames. ...API_REMOTE_FORWARDED_EVENTS.map(name => ctx.on( name, // The allowlist's shape assertion proves each name is a real, @@ -3485,37 +3482,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro })) }), )), - // The recompose itself registers nothing (it re-parents the agent's - // scope onto a standing mount that may already exist), so the - // logged selection is the only commit point a client can follow. - ctx.on('session/event', (session: Session, event: SessionEvent) => { - if (event.type !== 'agent-preset/selected') return - queue.push(frame({ - type: 'host/session-preset-changed', - sessionId: session.id, - agentPreset: event.data.agentPreset, - })) - }), - ctx.on('settings/document-updated', (ns) => { - // The RAW-section event, not the resolved one: a field going from - // inherited to overridden leaves the resolved value equal, and a - // configuration client still has to re-read (its held revision is - // stale, and the field's meaning changed). - const name = String(ns) - // A provider's own settings carry its model catalog and endpoint, - // so a change there invalidates the model list even when the route - // set is untouched — `llm/adapters-updated` alone misses it. The - // Agent default section is the other such source: it names the - // selection every session with no logged one resolves to, so an - // externally edited default (another tab, a hand-edited - // settings.yaml) has to reach an open selector too. - if (modelProviderNamespaces().has(name) || name === String(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE)) { - queue.push(frame({ type: 'host/models-changed' })) - } - }), - ctx.on('llm/adapters-updated', () => { - queue.push(frame({ type: 'host/models-changed' })) - }), ] return queue.iterate(signal, () => { for (const dispose of disposers) dispose() }) }, diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index f5e99894b7..c8ddf99e8d 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -88,7 +88,5 @@ export const hostFrameSchema = z.discriminatedUnion('type', [ // structural contract belongs to the owner package's cordis `Events` // declaration — the host validated JSON-safety before forwarding. z.object({ type: z.literal('host/remote-event'), event: z.string().min(1), args: z.array(z.unknown()) }), - z.object({ type: z.literal('host/session-preset-changed'), sessionId: sessionIdSchema, agentPreset: z.string() }), - z.object({ type: z.literal('host/models-changed') }), z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }), ]) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 7b200944d9..901379b181 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -150,23 +150,4 @@ export type HostFrame = * per-event frame variant. */ | { type: 'host/remote-event'; event: string; args: JsonValue[] } - /** - * One blank session was recomposed onto another agent preset (the logged - * `agent-preset/selected` commit point, read off the session stream). The - * registry-wide `commands/change` forwarded above cannot stand in for it: - * recomposing re-parents that agent's scope without registering anything, - * so a preset already mounted for another session produces no registry - * change at all. Clients refetch the catalogs this session's composition - * decides (`command.list`, `skill.list`) for this sessionId alone, and fold - * the preset id into their session row — the RPC echo reaches only the - * client that issued the switch, so the row is where every other one learns - * it. - */ - | { type: 'host/session-preset-changed'; sessionId: SessionId; agentPreset: string } - /** - * The provider topology changed (`llm/adapters-updated` passthrough): - * routes registered or dropped, or the configurable directory moved. Pure - * invalidation: clients refetch `llm.providers`/`llm.models`/`session.models`. - */ - | { type: 'host/models-changed' } | { type: 'stream/error'; error: RpcError } diff --git a/packages/host/apiproxy/src/api/llm.ts b/packages/host/apiproxy/src/api/llm.ts index 01564992d0..e9a5b52e72 100644 --- a/packages/host/apiproxy/src/api/llm.ts +++ b/packages/host/apiproxy/src/api/llm.ts @@ -4,7 +4,8 @@ * (which providers CAN be configured, and where their settings live) with the * live route registry; `llm.models` is the session-independent model catalog * (the same groups as `session.models`, without a per-session selection). - * Both invalidate on the `host/models-changed` frame. + * Clients invalidate from the forwarded `llm/adapters-updated` and + * `settings/document-updated` owner events. */ import type { RpcRequest, RpcResponse } from './rpc.ts' diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index 34eb306913..3396f83647 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -373,7 +373,7 @@ describe('agentPreset.select', () => { .toBe('minimal') }) - it('frames the committed switch so clients can drop that session\'s catalogs', async () => { + it('forwards the committed switch so clients can drop that session\'s catalogs', async () => { const { api, ctx } = await harness(['standard', 'minimal']) await api.sessions.create(request({ sessionId: SessionId('sel-frame'), agentPreset: 'standard' })) // The host-stream opener reads the committed-workspace baseline; this @@ -385,7 +385,8 @@ describe('agentPreset.select', () => { const stream = api.events.host(request({}), abort.signal) const consume = (async () => { for await (const frame of stream) { - if (frame.payload.type === 'host/session-preset-changed') frames.push(frame.payload) + if (frame.payload.type === 'host/remote-event' + && frame.payload.event === 'agent-preset/selected') frames.push(frame.payload) } })() @@ -397,10 +398,10 @@ describe('agentPreset.select', () => { abort.abort() await consume - // Recomposing registers nothing, so this frame — not the registry-wide - // commands one — is what tells a client its cached catalogs are stale. + // Recomposing registers nothing, so the owner event — not the + // registry-wide commands one — tells clients their cached catalogs are stale. expect(frames).toEqual([ - { type: 'host/session-preset-changed', sessionId: 'sel-frame', agentPreset: 'minimal' }, + { type: 'host/remote-event', event: 'agent-preset/selected', args: ['sel-frame', 'minimal'] }, ]) }) diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 4f5d40191f..165f406b24 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -431,7 +431,7 @@ describe('settings domain', () => { .toBe('settings-not-exposed') }) - it('invalidates the model catalog when a provider namespace changes, and broadcasts a raw-only change', async () => { + it('forwards a provider settings change for model-catalog consumers', async () => { // Editing `models` changes no route, so llm/adapters-updated never fires // and an open model picker would keep serving the stale catalog. Storing // an override equal to the resolved value emits nothing on @@ -440,13 +440,10 @@ describe('settings domain', () => { const ctx = await harness() ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } }) const api = createApiProxy(ctx, DEFAULTS) - const frames = await collectHost(api, ['host/remote-event', 'host/models-changed'], 2, async () => { + const frames = await collectHost(api, ['host/remote-event'], 1, async () => { await api.settings.update(request({ ns: 'llm-deepseek', patch: { baseURL: 'https://base' } })) }) - expect(frames).toEqual([ - forwardedSettings('llm-deepseek'), - { type: 'host/models-changed' }, - ]) + expect(frames).toEqual([forwardedSettings('llm-deepseek')]) // The resolved value never moved: base already said https://base. expect(expectOk(await api.settings.describe(request({}))).namespaces[0]!.value) .toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' }) @@ -460,13 +457,13 @@ describe('settings domain', () => { base: { defaultPreset: 'read-only' }, }) const api = createApiProxy(ctx, DEFAULTS) - const frames = await collectHost(api, ['host/remote-event', 'host/models-changed'], 1, async () => { + const frames = await collectHost(api, ['host/remote-event'], 1, async () => { await permission.update({ defaultPreset: 'workspace-write' }) }) expect(frames).toEqual([forwardedSettings('permission')]) }) - it('invalidates the model catalog when the Agent default selection changes', async () => { + it('forwards an Agent-default settings change for model-catalog consumers', async () => { const ctx = await harness() const defaultModel = ctx.settings.register(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE, z.object({ provider: z.string().required(), @@ -476,13 +473,10 @@ describe('settings domain', () => { // The shared section names the selection every blank session resolves to, // so an externally edited default — another tab, a // hand-edited settings.yaml — has to reach an open selector as well. - const frames = await collectHost(api, ['host/remote-event', 'host/models-changed'], 2, async () => { + const frames = await collectHost(api, ['host/remote-event'], 1, async () => { await defaultModel.replace({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) }) - expect(frames).toEqual([ - forwardedSettings('agent-default-model'), - { type: 'host/models-changed' }, - ]) + expect(frames).toEqual([forwardedSettings('agent-default-model')]) }) it('maps a stale expectedRevision to settings-conflict carrying both revisions', async () => { @@ -641,15 +635,18 @@ describe('llm domain', () => { expect(value.failures).toEqual([{ id: 'broken', name: 'Broken', message: 'catalog backend down' }]) }) - it('broadcasts host/models-changed at every topology commit point', async () => { + it('forwards llm/adapters-updated at every topology commit point', async () => { const ctx = await harness() const api = createApiProxy(ctx, DEFAULTS) - const frames = await collectHost(api, ['host/models-changed'], 2, async () => { + const frames = await collectHost(api, ['host/remote-event'], 2, async () => { const dispose = ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', [])) dispose() return Promise.resolve() }) - expect(frames).toEqual([{ type: 'host/models-changed' }, { type: 'host/models-changed' }]) + expect(frames).toEqual([ + { type: 'host/remote-event', event: 'llm/adapters-updated', args: [] }, + { type: 'host/remote-event', event: 'llm/adapters-updated', args: [] }, + ]) }) }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index c3af246240..dd7d9009af 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -507,7 +507,8 @@ describe('events frame schemas', () => { { type: 'host/workspace-removed', workspaceId: 'w' }, { type: 'host/remote-event', event: 'commands/change', args: [] }, { type: 'host/remote-event', event: 'settings/document-updated', args: ['ns', 3] }, - { type: 'host/session-preset-changed', sessionId: 's', agentPreset: 'minimal' }, + { type: 'host/remote-event', event: 'agent-preset/selected', args: ['s', 'minimal'] }, + { type: 'host/remote-event', event: 'llm/adapters-updated', args: [] }, { type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } }, ] for (const frame of frames) expect(hostFrameSchema.parse(frame)).toMatchObject({ type: frame.type }) diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index b5d67d93d9..55faefac7b 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -63,16 +63,6 @@ declare module '@deepseek-ai/cordis' { */ 'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable): AsyncIterable - /** - * The provider topology changed: an adapter registered or unregistered - * routes, or the configurable-provider directory gained or lost entries. - * This is a payload-free registry notification fired at each commit point - * (including registration disposal); consumers re-read `listProviders()`, - * `listModels()`, or `listConfigurableProviders()` for the new state. - * Observer failures are contained and cannot veto the registry mutation. - * @mode emit - */ - 'llm/adapters-updated'(): void } } diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 70528bf53a..0334637518 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -9,6 +9,21 @@ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { CallId, ProviderRequestId, ReasoningEffortId } from './brand.ts' import type { Message } from './message.ts' +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * The provider topology changed: an adapter registered or unregistered + * routes, or the configurable-provider directory gained or lost entries. + * This payload-free registry notification fires at each commit point + * (including registration disposal); consumers re-read `listProviders()`, + * `listModels()`, or `listConfigurableProviders()` for the new state. + * Observer failures are contained and cannot veto the registry mutation. + * @mode emit + */ + 'llm/adapters-updated'(): void + } +} + export type { AssistantMessage, AssistantProvenance, diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index 98891c8710..28b9a31ed4 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -41,7 +41,7 @@ The child records the joined id on its own durable header ([`dsh-subagent`](../. The creation header names the preset a session STARTED with; `resolveSessionPreset(session)` names the one it RUNS. They differ whenever a blank session switched, so every reconstruction path — the summary a picker reads, a resume, a fork — resolves rather than reading the header. -The header stays frozen because it is a creation fact. A switch is an `agent-preset/selected` session event appended after the swap commits, which is what the model-visible ⟺ logged rule requires: the preset decides the tool schemas and prompt sections the model sees, so it has to be reconstructable from the log. Reading the header alone would rebuild a switched session under the composition it was created with, replaying history the new tool set cannot act on — the exact hazard the blank-only lock exists to prevent. +The header stays frozen because it is a creation fact. A switch is an `agent-preset/selected` session event appended after the swap commits, which is what the model-visible ⟺ logged rule requires: the preset decides the tool schemas and prompt sections the model sees, so it has to be reconstructable from the log. The service re-emits that committed fact as the non-scoped cordis event `agent-preset/selected(sessionId, agentPreset)` declared by the client-safe `./types` export, allowing remote consumers to invalidate session-derived state without importing Host runtime types. Reading the header alone would rebuild a switched session under the composition it was created with, replaying history the new tool set cannot act on — the exact hazard the blank-only lock exists to prevent. ### Switching a blank agent diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 0fcc5fa4d6..1d8d1481c2 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -41,7 +41,7 @@ subagent 的子 agent 通过 `composeFrom()` 加入其父方的常驻组装, 创建头部记录的是会话**以什么开始**,`resolveSessionPreset(session)` 给出的才是它**实际运行的**。空白会话一旦切换过,两者就不同,因此所有重建路径——选择器读取的摘要、resume、fork——都走解析,而非直接读头部。 -头部保持冻结,因为它是创建期事实。切换以 `agent-preset/selected` 会话事件记录,在替换提交之后追加;这正是 model-visible ⟺ logged 规则的要求:preset 决定模型看到的工具 schema 与提示词段落,因此必须能从日志重建。只读头部会让切换过的会话按创建时的组装重建,从而重放新工具集无法执行的历史——这正是「仅空白可切」那道锁要防的危险。 +头部保持冻结,因为它是创建期事实。切换以 `agent-preset/selected` 会话事件记录,在替换提交之后追加;这正是 model-visible ⟺ logged 规则的要求:preset 决定模型看到的工具 schema 与提示词段落,因此必须能从日志重建。服务会把这项已提交事实重新发为不带 scope 的 cordis 事件 `agent-preset/selected(sessionId, agentPreset)`,其声明位于 client-safe 的 `./types` 出口,使远端消费方无需导入 Host 运行时类型即可让会话派生状态失效。只读头部会让切换过的会话按创建时的组装重建,从而重放新工具集无法执行的历史——这正是「仅空白可切」那道锁要防的危险。 ### 切换空白 agent diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index f744bf2965..2a7dd7da63 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -22,12 +22,17 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", diff --git a/packages/preset/agent-presets/src/authoring.ts b/packages/preset/agent-presets/src/authoring.ts index 0f8788ee9b..c60bce3084 100644 --- a/packages/preset/agent-presets/src/authoring.ts +++ b/packages/preset/agent-presets/src/authoring.ts @@ -17,7 +17,7 @@ import { dirname, isAbsolute, join, resolve } from 'node:path' import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write' import { expandHomePath } from '@deepseek-ai/dsh-paths' import { METADATA_FILE, renderPresetMetadata } from './metadata.ts' -import { PRESET_ID, type AgentPreset, type PresetRoot } from './types.ts' +import { PRESET_ID, type AgentPreset, type PresetRoot } from './preset.ts' /** A preset id that cannot be used as a directory name under a root. */ export class InvalidPresetIdError extends Error { diff --git a/packages/preset/agent-presets/src/discovery.ts b/packages/preset/agent-presets/src/discovery.ts index 9e91bc85a2..4ab3f67e50 100644 --- a/packages/preset/agent-presets/src/discovery.ts +++ b/packages/preset/agent-presets/src/discovery.ts @@ -20,7 +20,7 @@ import { load } from 'js-yaml' import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' import { expandHomePath } from '@deepseek-ai/dsh-paths' import { readPresetMetadata } from './metadata.ts' -import { PRESET_ID, type AgentPreset, type PresetRoot } from './types.ts' +import { PRESET_ID, type AgentPreset, type PresetRoot } from './preset.ts' /** The composition file that makes a directory a preset. */ export const COMPOSITION_FILE = 'agent.cordis.yml' diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index bddab50676..3da5e3b5c9 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -32,7 +32,8 @@ import { discoverPresets } from './discovery.ts' import { copyComposition, deleteComposition, readComposition } from './authoring.ts' import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts' import { PresetExistsError } from './authoring.ts' -import { PresetMountError, UnknownPresetError, type AgentPreset, type Config } from './types.ts' +import { PresetMountError, UnknownPresetError, type AgentPreset, type Config } from './preset.ts' +import type {} from './types.ts' /** Settings namespace carrying the user's chosen default preset. */ export const SETTINGS_NAMESPACE = 'agent-presets' @@ -61,8 +62,8 @@ export { PresetNotWritableError, readComposition, writableRoot, } from './authoring.ts' export { resolveSessionPreset, type PresetBearingSession } from './session.ts' -export { PresetMountError, UnknownPresetError } from './types.ts' -export type { AgentPreset, Config, PresetRoot, PresetTrust } from './types.ts' +export { PresetMountError, UnknownPresetError } from './preset.ts' +export type { AgentPreset, Config, PresetRoot, PresetTrust } from './preset.ts' declare module '@deepseek-ai/cordis' { interface Context { @@ -154,6 +155,13 @@ export class AgentPresets extends Service { + '(join through AgentPresets.mount() or composeFrom() in the agent factory setup)', ) }) + + // The durable record is the commit point. Its public notification carries + // only the stable identity needed by clients, never the live Session. + ctx.on('session/event', (session, event) => { + if (event.type !== 'agent-preset/selected') return + ctx.emit('agent-preset/selected', session.id, event.data.agentPreset) + }) } /** diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index d78603b749..d9a7108fd7 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -20,7 +20,7 @@ import { Context, type Fiber } from '@deepseek-ai/cordis' import { Include } from '@deepseek-ai/cordis-plugin-include' import type { EntryTree } from '@deepseek-ai/cordis-plugin-loader' import { scopeOf, scopeParentOf, type ScopeKey } from '@deepseek-ai/dsh-scope' -import { PresetMountError, type AgentPreset } from './types.ts' +import { PresetMountError, type AgentPreset } from './preset.ts' /** What one mounted subtree publishes about itself for the audit to read. */ interface MountedTree { diff --git a/packages/preset/agent-presets/src/preset.ts b/packages/preset/agent-presets/src/preset.ts new file mode 100644 index 0000000000..b2b48ea6ea --- /dev/null +++ b/packages/preset/agent-presets/src/preset.ts @@ -0,0 +1,88 @@ +/** Agent-preset vocabulary shared by discovery, mounting, and consumers. */ + +/** + * Where a preset's composition came from. A `system` preset ships with the + * deployment; a `user` preset was authored locally, by a person or by an + * agent, and therefore carries the same trust as shell access. + */ +export type PresetTrust = 'system' | 'user' + +/** + * Ids a preset directory may use. + * + * The id becomes a path segment, so this is a containment boundary rather than + * a style rule: `..`, a separator, or an absolute-looking name would place the + * composition outside the root the deployment authorised. Discovery shares it: + * a directory whose name no copy could ever claim is not a preset slot. + */ +export const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/ + +/** One preset directory that carries a mountable agent composition. */ +export interface AgentPreset { + /** Stable identifier; the preset directory's name. */ + readonly id: string + /** Trust recorded from the root this preset was discovered under. */ + readonly trust: PresetTrust + /** Absolute path of the preset's agent composition file. */ + readonly path: string + /** Display name from the preset's own metadata; absent falls back to {@link id}. */ + readonly name?: string + /** One sentence on what this preset is for, when it published one. */ + readonly description?: string + /** Declared position within its group; absent sorts after those that declare one. */ + readonly order?: number + /** + * Why this preset cannot compose a session, absent when it can. A broken + * preset stays on the roster — hiding it would leave its directory blocking + * the id with nothing to see or delete — but every mounting path refuses it + * up front with this reason instead of failing deep inside the loader. + */ + readonly broken?: string +} + +/** One directory scanned for preset subdirectories. */ +export interface PresetRoot { + /** Directory holding one subdirectory per preset; a leading `~` expands. */ + path: string + /** Trust recorded on every preset discovered under this root. */ + trust: PresetTrust +} + +/** Plugin config: which preset is the default, and where presets live. */ +export interface Config { + /** Preset id mounted when a caller names none. Missing at mount time fails loud. */ + default: string + /** Scanned roots in precedence order; an earlier root wins a duplicate id. */ + roots: PresetRoot[] +} + +/** + * No configured root supplies the requested preset. + * + * Separate from a mount failure because the two mean different things to a + * caller: an unknown id is a bad request, while an unusable composition is a + * broken preset the deployment must fix. + */ +export class UnknownPresetError extends Error { + constructor( + /** The id that was requested. */ + readonly presetId: string, + /** Ids the roster does supply, for the caller to offer instead. */ + readonly available: readonly string[], + ) { + super(`agent-presets: preset "${presetId}" not found (available: ${available.join(', ') || 'none'})`) + } +} + +/** A preset exists but its composition cannot be installed. */ +export class PresetMountError extends Error { + constructor( + /** The preset whose composition failed. */ + readonly presetId: string, + /** Why it failed, without this package's own message prefix. */ + readonly reason: string, + options?: ErrorOptions, + ) { + super(`agent-presets: preset "${presetId}" failed to mount: ${reason}`, options) + } +} diff --git a/packages/preset/agent-presets/src/types.ts b/packages/preset/agent-presets/src/types.ts index f600d5ca01..77803355a1 100644 --- a/packages/preset/agent-presets/src/types.ts +++ b/packages/preset/agent-presets/src/types.ts @@ -1,88 +1,17 @@ -/** Agent-preset vocabulary shared by discovery, mounting, and consumers. @module @deepseek-ai/dsh-agent-presets/types */ +/** Client-safe event declarations owned by the agent-preset domain. */ +import type { SessionId } from '@deepseek-ai/dsh-session/types' -/** - * Where a preset's composition came from. A `system` preset ships with the - * deployment; a `user` preset was authored locally, by a person or by an - * agent, and therefore carries the same trust as shell access. - */ -export type PresetTrust = 'system' | 'user' - -/** - * Ids a preset directory may use. - * - * The id becomes a path segment, so this is a containment boundary rather than - * a style rule: `..`, a separator, or an absolute-looking name would place the - * composition outside the root the deployment authorised. Discovery shares it: - * a directory whose name no copy could ever claim is not a preset slot. - */ -export const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/ - -/** One preset directory that carries a mountable agent composition. */ -export interface AgentPreset { - /** Stable identifier; the preset directory's name. */ - readonly id: string - /** Trust recorded from the root this preset was discovered under. */ - readonly trust: PresetTrust - /** Absolute path of the preset's agent composition file. */ - readonly path: string - /** Display name from the preset's own metadata; absent falls back to {@link id}. */ - readonly name?: string - /** One sentence on what this preset is for, when it published one. */ - readonly description?: string - /** Declared position within its group; absent sorts after those that declare one. */ - readonly order?: number - /** - * Why this preset cannot compose a session, absent when it can. A broken - * preset stays on the roster — hiding it would leave its directory blocking - * the id with nothing to see or delete — but every mounting path refuses it - * up front with this reason instead of failing deep inside the loader. - */ - readonly broken?: string -} - -/** One directory scanned for preset subdirectories. */ -export interface PresetRoot { - /** Directory holding one subdirectory per preset; a leading `~` expands. */ - path: string - /** Trust recorded on every preset discovered under this root. */ - trust: PresetTrust -} - -/** Plugin config: which preset is the default, and where presets live. */ -export interface Config { - /** Preset id mounted when a caller names none. Missing at mount time fails loud. */ - default: string - /** Scanned roots in precedence order; an earlier root wins a duplicate id. */ - roots: PresetRoot[] -} - -/** - * No configured root supplies the requested preset. - * - * Separate from a mount failure because the two mean different things to a - * caller: an unknown id is a bad request, while an unusable composition is a - * broken preset the deployment must fix. - */ -export class UnknownPresetError extends Error { - constructor( - /** The id that was requested. */ - readonly presetId: string, - /** Ids the roster does supply, for the caller to offer instead. */ - readonly available: readonly string[], - ) { - super(`agent-presets: preset "${presetId}" not found (available: ${available.join(', ') || 'none'})`) +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * One session committed a different agent preset to its durable log. + * Consumers invalidate only state derived from that session's composition. + * @mode emit + * @param sessionId - the session whose composition changed. + * @param agentPreset - the preset recorded by the committed selection. + */ + 'agent-preset/selected'(sessionId: SessionId, agentPreset: string): void } } -/** A preset exists but its composition cannot be installed. */ -export class PresetMountError extends Error { - constructor( - /** The preset whose composition failed. */ - readonly presetId: string, - /** Why it failed, without this package's own message prefix. */ - readonly reason: string, - options?: ErrorOptions, - ) { - super(`agent-presets: preset "${presetId}" failed to mount: ${reason}`, options) - } -} +export {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5de1e08c9..f77a37e5c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -822,6 +822,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-presets': + specifier: workspace:^ + version: link:../../preset/agent-presets '@deepseek-ai/dsh-api-gateway': specifier: workspace:^ version: link:../gateway @@ -837,6 +840,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -2206,6 +2212,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -2215,6 +2224,9 @@ importers: '@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-command': specifier: workspace:^ version: link:../ui-command @@ -2645,6 +2657,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index b94eaa80b7..27ab5ddce2 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -173,15 +173,12 @@ export const EVENT_SCOPE_PAGE: Record = { * scan reads EVERY `declare module '@deepseek-ai/cordis'` Events merge under * `packages/x/x/src/**`, so a declared event either renders onto a subsystems * page (via {@link EVENT_SCOPE_PAGE}) or names itself here — never vanishes - * silently. Keys are full event names, not scopes: client-face events share - * scopes with rendered host events (`models/changed` beside `llm/*`), - * so a scope-level exemption would mask a host-face regression. + * silently. Keys are full event names rather than scopes, so a scope-level + * exemption cannot mask another declaration in that scope. */ export const EVENT_WALK_EXEMPTIONS: Record = { 'connection/reset': 'client-face transport signal — packages/client/runtime/README.md owns the API', 'locale/change': 'client-face locale switch signal — packages/client/locale/README.md owns the API', - 'models/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the API', - 'session/preset-changed': 'client-face per-session catalog invalidation signal — packages/client/runtime/README.md owns the API', 'slash/input-begin-command': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the API', 'slash/input-consume-token': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the API', 'slash/input-insert-reference': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the API', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 8c94f9481b..27818d776b 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -758,10 +758,8 @@ const EVENT_API_METHODS = new Set(['on', 'once', 'emit', 'parallel', 'serial', ' * documents why: one program cannot hold both faces' Context merges), so a * Client package enters only when a host file imports it. Client-face * listeners on client-face events are therefore under-reported — - * `connection/reset` omits `ui-skill`/`ui-agent-preset`, `models/changed` - * omits `ui-model`, `session/preset-changed` omits `ui-skill`. Closing it - * needs a second Client program whose relations merge into these, not a - * wider seed. + * `connection/reset` omits `ui-skill`/`ui-agent-preset`. Closing it needs a + * second Client program whose relations merge into these, not a wider seed. */ export class EventRelationCollector { private readonly relations = new Map() diff --git a/tsconfig.base.json b/tsconfig.base.json index afaa558abd..2984f6fafd 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -60,6 +60,7 @@ "@deepseek-ai/dsh-session-title/client": ["./packages/session/session-title/src/client.ts"], "@deepseek-ai/dsh-plan-mode/types": ["./packages/plan/plan-mode/src/types.ts"], "@deepseek-ai/dsh-plan-mode/client": ["./packages/plan/plan-mode/src/client.ts"], + "@deepseek-ai/dsh-agent-presets/types": ["./packages/preset/agent-presets/src/types.ts"], "@deepseek-ai/dsh-pwsh-local": ["./packages/bash/pwsh-local/src/index.ts"], "@deepseek-ai/dsh-tool-pwsh": ["./packages/bash/tool-pwsh/src/index.ts"], "@deepseek-ai/dsh-bash-env": ["./packages/bash/bash-env/src/index.ts"], From ffdf81b28dc5b5e5ad853e846d6b27da6978f7d0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:12:41 +0800 Subject: [PATCH 25/31] fix(ci): repair remote event coverage and catalogs --- ...26-07-30-config-plane-boundaries.i18n.yaml | 4 +-- .../2026-07-30-web-config-plane.i18n.yaml | 4 +-- ...2026-08-10-remote-event-delivery.i18n.yaml | 4 +-- ...sh-catalog-follows-preset-switch.i18n.yaml | 4 +-- ...mission-default-for-new-sessions.i18n.yaml | 4 +-- docs/config-catalog.i18n.yaml | 4 +-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/event-producer-consumer.i18n.yaml | 4 +-- docs/event-producer-consumer.md | 5 ++-- docs/event-producer-consumer.zh.md | 5 ++-- docs/module-graph.i18n.yaml | 4 +-- docs/module-graph.md | 10 +++++--- docs/module-graph.zh.md | 10 +++++--- docs/subsystems/core.i18n.yaml | 4 +-- docs/subsystems/core.md | 25 ++++++++++++++++++- docs/subsystems/core.zh.md | 25 ++++++++++++++++++- docs/subsystems/llm-streaming.i18n.yaml | 4 +-- docs/subsystems/llm-streaming.md | 8 +++--- docs/subsystems/llm-streaming.zh.md | 8 +++--- packages/api/remotes/README.i18n.yaml | 4 +-- packages/client/runtime/README.i18n.yaml | 4 +-- .../client/ui-agent-preset/README.i18n.yaml | 4 +-- packages/client/ui-command/README.i18n.yaml | 4 +-- packages/client/ui-model/README.i18n.yaml | 4 +-- packages/client/ui-models/README.i18n.yaml | 4 +-- .../client/ui-settings/src/client/index.ts | 7 +++--- packages/client/ui-skill/README.i18n.yaml | 4 +-- .../ui-skill/tests/browser-plugin.spec.ts | 1 + packages/host/apiproxy/README.i18n.yaml | 4 +-- .../tests/api-proxy-agent-preset.spec.ts | 12 +++++---- .../preset/agent-presets/README.i18n.yaml | 4 +-- .../preset/agent-presets/tests/mount.spec.ts | 13 ++++++++++ .../tool-cordis/src/api-catalog.ts | 9 ++++++- scripts/gen-cordis-catalog.ts | 1 + 35 files changed, 149 insertions(+), 70 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml index db54057b3c..62d3cdf39a 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-config-plane-boundaries.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-30-config-plane-boundaries.md -2026-07-30-config-plane-boundaries.md: f2c4981d6ddf772ee050db0be67dad0b063e2f50 -2026-07-30-config-plane-boundaries.zh.md: deec710e3c54da58333145a902f22154d15bafbb +2026-07-30-config-plane-boundaries.md: 0a689603b9619453f4a6613d79a82a1e671f0401 +2026-07-30-config-plane-boundaries.zh.md: b0e306a1adad971b611d315edfcdf9103091d022 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index c7b36ebf1a..e5af076714 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.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-30-web-config-plane.md -2026-07-30-web-config-plane.md: 4398ab8c23001d1d75ece439de5d03a57005e3b5 -2026-07-30-web-config-plane.zh.md: b06b45788525d2cd77bf8950251ff84928a27c0a +2026-07-30-web-config-plane.md: 8673ad76c761c8a139a811a2efc1b7b21bcd4483 +2026-07-30-web-config-plane.zh.md: 0571b6aee2062cad12af1aa61156fb7da3a9bead diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml index 43be087974..dc538d47a0 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md -2026-08-10-remote-event-delivery.md: 81a6805e0f4c46e558784eeebfa51bbcd4b7ad5d -2026-08-10-remote-event-delivery.zh.md: 0d901d65b0a9383fa7304925103c075edce4620d +2026-08-10-remote-event-delivery.md: c6433ce847258a1a2768b7fb848df8e98bf4c371 +2026-08-10-remote-event-delivery.zh.md: 9eb124331ba97ae498dd6500f7b0ed944d2a81ba diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml index 55fc08bfb9..d1bb3d87ca 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-10-slash-catalog-follows-preset-switch.md -2026-08-10-slash-catalog-follows-preset-switch.md: 85bd5b2134fd20c86fdeb13f3ce5b007449105b5 -2026-08-10-slash-catalog-follows-preset-switch.zh.md: 97c8f08a7b3dfec7c17fbb00bef626e28505c500 +2026-08-10-slash-catalog-follows-preset-switch.md: ec85d4a3a6a053e91c368f65ecb12f123c45287d +2026-08-10-slash-catalog-follows-preset-switch.zh.md: b68fa90e99c3597c61b3edcc03399bb7ea7844f4 diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml index 0c25e37283..f6987b7bb2 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.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-31-permission-default-for-new-sessions.md -2026-07-31-permission-default-for-new-sessions.md: 63df4514f8503f13eec4b4a47daf057d07062ab3 -2026-07-31-permission-default-for-new-sessions.zh.md: 969fa1aae667e171ba0427a026ef16124fe5ee88 +2026-07-31-permission-default-for-new-sessions.md: 3606470d32b97002d3ef491e5a98d4fec0f29a0e +2026-07-31-permission-default-for-new-sessions.zh.md: 236fb1486611a447807d0727c5b79e078e5e6017 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index c05c8578f4..56590143db 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: db1c9a826bbd1e529ee59519c70454c3371d61b1 -config-catalog.zh.md: 7c7cdda31d627ac0415f9f8f515cfcb47e8b8d36 +config-catalog.md: b6e27b0915b09430dd22dcc9054e43e62728a1d3 +config-catalog.zh.md: c1ae11dca2389eb8cd3d2f109aa3b31e14199aac diff --git a/docs/config-catalog.md b/docs/config-catalog.md index db1c9a826b..b6e27b0915 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -153,7 +153,7 @@ export interface PresetRoot { export type PresetTrust = 'system' | 'user' ``` -Source: [`packages/preset/agent-presets/src/types.ts:52`](../packages/preset/agent-presets/src/types.ts) +Source: [`packages/preset/agent-presets/src/preset.ts:52`](../packages/preset/agent-presets/src/preset.ts) ## `@deepseek-ai/dsh-agent-spine-demo` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 7c7cdda31d..c1ae11dca2 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -155,7 +155,7 @@ export interface PresetRoot { export type PresetTrust = 'system' | 'user' ``` -来源:[`packages/preset/agent-presets/src/types.ts:52`](../packages/preset/agent-presets/src/types.ts) +来源:[`packages/preset/agent-presets/src/preset.ts:52`](../packages/preset/agent-presets/src/preset.ts) ## `@deepseek-ai/dsh-agent-spine-demo` diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 92815faa9d..d56a4dcd28 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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/event-producer-consumer.md -event-producer-consumer.md: a4802914ca5080dfb1e4ac7ed41d62f6c95a3f00 -event-producer-consumer.zh.md: 966555779a96d7aa2ac4ad6db55ede6b33d42870 +event-producer-consumer.md: 05f0ebfeba307cd4bf5f9fa061bc728593960424 +event-producer-consumer.zh.md: c75dda333a2064b76ea35477d9f5be2583fd5238 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index c82dfab988..05f0ebfeba 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,6 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:182`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | +| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `apiproxy` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`goal-session`](../packages/goal/goal-session) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/session/session-telemetry) | @@ -28,11 +29,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | +| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:64`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 9a95fa6527..c75dda333a 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -10,6 +10,7 @@ | 事件 | 模式 | 声明位置 | 派发方 | 监听方 | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:182`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | +| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `apiproxy` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`goal-session`](../packages/goal/goal-session) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/session/session-telemetry) | @@ -30,11 +31,11 @@ | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | +| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:64`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index ce44c570d7..290dd135bb 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: 69577adc46b93de85e997030fc12638511ec3b0c -module-graph.zh.md: e483b1e093989f81260d32a8588fae9e4f44dbd0 +module-graph.md: dcba0a2401afa252fb6d3b81e4e1728784fd3cac +module-graph.zh.md: 862401c987e33cd6223804a240aa509373981c16 diff --git a/docs/module-graph.md b/docs/module-graph.md index 69577adc46..dcba0a2401 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -625,11 +625,13 @@ flowchart TD pkg_acp --> pkg_session pkg_acp --> pkg_user_approval pkg_api_remotes --> pkg_agent + pkg_api_remotes --> pkg_agent_presets pkg_api_remotes --> pkg_api_gateway pkg_api_remotes --> pkg_commands pkg_api_remotes --> pkg_credentials pkg_api_remotes --> pkg_goal pkg_api_remotes --> pkg_invariants + pkg_api_remotes --> pkg_llm pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_persistence pkg_api_remotes --> pkg_settings @@ -1260,6 +1262,7 @@ flowchart TD pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver pkg_host_directory_picker_auto --> pkg_invariants + pkg_client_ui_model --> pkg_api_remotes pkg_client_ui_model --> pkg_client_connection pkg_client_ui_model --> pkg_client_locale pkg_client_ui_model --> pkg_client_runtime @@ -1281,6 +1284,7 @@ flowchart TD pkg_client_ui_permission --> pkg_client_ui_slots pkg_client_ui_permission --> pkg_invariants pkg_client_ui_permission --> pkg_permission + pkg_client_ui_skill --> pkg_api_remotes pkg_client_ui_skill --> pkg_client_connection pkg_client_ui_skill --> pkg_client_locale pkg_client_ui_skill --> pkg_client_runtime @@ -1398,7 +1402,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), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`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), [`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) | @@ -1496,6 +1500,6 @@ flowchart TD | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | -| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-model`](../packages/client/ui-model) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/interaction/permission) | -| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/support/invariants) | +| [`client-ui-skill`](../packages/client/ui-skill) | `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-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/support/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index e483b1e093..862401c987 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -627,11 +627,13 @@ flowchart TD pkg_acp --> pkg_session pkg_acp --> pkg_user_approval pkg_api_remotes --> pkg_agent + pkg_api_remotes --> pkg_agent_presets pkg_api_remotes --> pkg_api_gateway pkg_api_remotes --> pkg_commands pkg_api_remotes --> pkg_credentials pkg_api_remotes --> pkg_goal pkg_api_remotes --> pkg_invariants + pkg_api_remotes --> pkg_llm pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_persistence pkg_api_remotes --> pkg_settings @@ -1262,6 +1264,7 @@ flowchart TD pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver pkg_host_directory_picker_auto --> pkg_invariants + pkg_client_ui_model --> pkg_api_remotes pkg_client_ui_model --> pkg_client_connection pkg_client_ui_model --> pkg_client_locale pkg_client_ui_model --> pkg_client_runtime @@ -1283,6 +1286,7 @@ flowchart TD pkg_client_ui_permission --> pkg_client_ui_slots pkg_client_ui_permission --> pkg_invariants pkg_client_ui_permission --> pkg_permission + pkg_client_ui_skill --> pkg_api_remotes pkg_client_ui_skill --> pkg_client_connection pkg_client_ui_skill --> pkg_client_locale pkg_client_ui_skill --> pkg_client_runtime @@ -1400,7 +1404,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), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`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), [`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) | @@ -1498,6 +1502,6 @@ flowchart TD | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | -| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-model`](../packages/client/ui-model) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/interaction/permission) | -| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/support/invariants) | +| [`client-ui-skill`](../packages/client/ui-skill) | `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-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/support/invariants) | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index dc9105c6a8..5653d2de4a 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.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/core.md -core.md: 1199943eb5770fd5fa7cc21cbed3023b844a55d6 -core.zh.md: 138d1d6292b89b2a9ff01cb925fdb81f008833fa +core.md: d3b7f4d3ad9ff4a99383f132b316f64c426f0c59 +core.zh.md: 7a5372b0982e4dbb9dcf83d844ae3a82554664aa diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 1199943eb5..d3b7f4d3ad 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -546,7 +546,7 @@ async standingKeyFor(id?: string): Promise Types: [ScopeKey](scope.md) -Source: [`packages/preset/agent-presets/src/index.ts:80`](../../packages/preset/agent-presets/src/index.ts) +Source: [`packages/preset/agent-presets/src/index.ts:81`](../../packages/preset/agent-presets/src/index.ts) @@ -1042,4 +1042,27 @@ A declarative agent entry failed before it could publish a live agent. Consumers ``` Source: [`packages/core/agent-loop/src/index.ts:182`](../../packages/core/agent-loop/src/index.ts) + + + +### `agent-preset/*` events + + + +#### `agent-preset/selected` — emit + +One session committed a different agent preset to its durable log. Consumers invalidate only state derived from that session's composition. + +```ts cordis-catalog +/** + * One session committed a different agent preset to its durable log. + * Consumers invalidate only state derived from that session's composition. + * @mode emit + * @param sessionId - the session whose composition changed. + * @param agentPreset - the preset recorded by the committed selection. + */ +'agent-preset/selected'(sessionId: SessionId, agentPreset: string): void +``` + +Source: [`packages/preset/agent-presets/src/types.ts:13`](../../packages/preset/agent-presets/src/types.ts) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 138d1d6292..7a5372b098 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -554,7 +554,7 @@ async standingKeyFor(id?: string): Promise Types: [ScopeKey](scope.md) -Source: [`packages/preset/agent-presets/src/index.ts:80`](../../packages/preset/agent-presets/src/index.ts) +Source: [`packages/preset/agent-presets/src/index.ts:81`](../../packages/preset/agent-presets/src/index.ts) @@ -1050,4 +1050,27 @@ A declarative agent entry failed before it could publish a live agent. Consumers ``` Source: [`packages/core/agent-loop/src/index.ts:182`](../../packages/core/agent-loop/src/index.ts) + + + +### `agent-preset/*` events + + + +#### `agent-preset/selected` — emit + +One session committed a different agent preset to its durable log. Consumers invalidate only state derived from that session's composition. + +```ts cordis-catalog +/** + * One session committed a different agent preset to its durable log. + * Consumers invalidate only state derived from that session's composition. + * @mode emit + * @param sessionId - the session whose composition changed. + * @param agentPreset - the preset recorded by the committed selection. + */ +'agent-preset/selected'(sessionId: SessionId, agentPreset: string): void +``` + +Source: [`packages/preset/agent-presets/src/types.ts:13`](../../packages/preset/agent-presets/src/types.ts) diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 376b1a976c..667fe8028a 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.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/llm-streaming.md -llm-streaming.md: 2063ba2deadada4689161110200af177320fcc15 -llm-streaming.zh.md: c3d1c4d895f35032bfe7602a2880a7922b984a1f +llm-streaming.md: bd2c9b856d34e625b023a20c8be331eb7ea89880 +llm-streaming.zh.md: 34420669ca8cbdf9d9a0d8b38e67bc196c290cc8 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 2063ba2dea..bd2c9b856d 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -833,7 +833,7 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise ``` -Source: [`packages/llm/llm/src/index.ts:294`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:284`](../../packages/llm/llm/src/index.ts) @@ -843,13 +843,13 @@ Source: [`packages/llm/llm/src/index.ts:294`](../../packages/llm/llm/src/index.t #### `llm/adapters-updated` — emit -The provider topology changed: an adapter registered or unregistered routes, or the configurable-provider directory gained or lost entries. This is a payload-free registry notification fired at each commit point (including registration disposal); consumers re-read `listProviders()`, `listModels()`, or `listConfigurableProviders()` for the new state. Observer failures are contained and cannot veto the registry mutation. +The provider topology changed: an adapter registered or unregistered routes, or the configurable-provider directory gained or lost entries. This payload-free registry notification fires at each commit point (including registration disposal); consumers re-read `listProviders()`, `listModels()`, or `listConfigurableProviders()` for the new state. Observer failures are contained and cannot veto the registry mutation. ```ts cordis-catalog /** * The provider topology changed: an adapter registered or unregistered * routes, or the configurable-provider directory gained or lost entries. - * This is a payload-free registry notification fired at each commit point + * This payload-free registry notification fires at each commit point * (including registration disposal); consumers re-read `listProviders()`, * `listModels()`, or `listConfigurableProviders()` for the new state. * Observer failures are contained and cannot veto the registry mutation. @@ -858,7 +858,7 @@ The provider topology changed: an adapter registered or unregistered routes, or 'llm/adapters-updated'(): void ``` -Source: [`packages/llm/llm/src/index.ts:75`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/types.ts:23`](../../packages/llm/llm/src/types.ts) diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index c3d1c4d895..34420669ca 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -841,7 +841,7 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise ``` -Source: [`packages/llm/llm/src/index.ts:294`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:284`](../../packages/llm/llm/src/index.ts) @@ -851,13 +851,13 @@ Source: [`packages/llm/llm/src/index.ts:294`](../../packages/llm/llm/src/index.t #### `llm/adapters-updated` — emit -The provider topology changed: an adapter registered or unregistered routes, or the configurable-provider directory gained or lost entries. This is a payload-free registry notification fired at each commit point (including registration disposal); consumers re-read `listProviders()`, `listModels()`, or `listConfigurableProviders()` for the new state. Observer failures are contained and cannot veto the registry mutation. +The provider topology changed: an adapter registered or unregistered routes, or the configurable-provider directory gained or lost entries. This payload-free registry notification fires at each commit point (including registration disposal); consumers re-read `listProviders()`, `listModels()`, or `listConfigurableProviders()` for the new state. Observer failures are contained and cannot veto the registry mutation. ```ts cordis-catalog /** * The provider topology changed: an adapter registered or unregistered * routes, or the configurable-provider directory gained or lost entries. - * This is a payload-free registry notification fired at each commit point + * This payload-free registry notification fires at each commit point * (including registration disposal); consumers re-read `listProviders()`, * `listModels()`, or `listConfigurableProviders()` for the new state. * Observer failures are contained and cannot veto the registry mutation. @@ -866,7 +866,7 @@ The provider topology changed: an adapter registered or unregistered routes, or 'llm/adapters-updated'(): void ``` -Source: [`packages/llm/llm/src/index.ts:75`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/types.ts:23`](../../packages/llm/llm/src/types.ts) diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index 5492938176..421a56d951 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: 34c78caf985c93137e7369329302ffd5feb67595 -README.zh.md: 4fff19673f10b0d173c9e3f724ca205845a0b9ed +README.md: cc903af7204ca715c6c7931cfe44823d4d5fc71e +README.zh.md: fe34b8774c9864cef442ff8a58f22f541d40768a diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index a58d0960d2..0cbdbb5294 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/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 packages/client/runtime/README.md -README.md: 69634d4ca577e9fa5c508a5fb2b50333290154b1 -README.zh.md: 9e03cc1903b5e9dc1d13e07bf8394a6e7aee9209 +README.md: be04f56ac5151c756aa6d4e2233461cc173fe261 +README.zh.md: 372922b8b02f694512505c97bf08b9ab480912a5 diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 4f949bfa86..a77c301b51 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/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-agent-preset/README.md -README.md: 3b0db5a3eedca256a00b65a3bd2738f22c0eb62e -README.zh.md: 6f3c350f973119c201572f2c03145338b5cc5b00 +README.md: f0fdff4b1f2453be9e8b4c1d20f7ca0fee506d55 +README.zh.md: c391a4548f2bd2fc4bfe168e9d494bc0073a8cb2 diff --git a/packages/client/ui-command/README.i18n.yaml b/packages/client/ui-command/README.i18n.yaml index 58d54302ad..c4bb939e01 100644 --- a/packages/client/ui-command/README.i18n.yaml +++ b/packages/client/ui-command/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-command/README.md -README.md: e49ce89804886a11f102fcaf60316e8044965c10 -README.zh.md: 8bd5afd7d0a173980f476cb96f8115525602b0b4 +README.md: 60b70cfbc3784dd5b138f8857270c4bbdc0fd634 +README.zh.md: 639b7f997e967527232bb88116558c5457b4d497 diff --git a/packages/client/ui-model/README.i18n.yaml b/packages/client/ui-model/README.i18n.yaml index a824ac6828..60d732425b 100644 --- a/packages/client/ui-model/README.i18n.yaml +++ b/packages/client/ui-model/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-model/README.md -README.md: fdc3258eb37b45f4773648d2d796c275f8657d47 -README.zh.md: 116e151d1afeaaa22618c408eed2c7542d1357e7 +README.md: e49fdee52caa99cd249db7d7fbec0064c47e2dc7 +README.zh.md: 366c96c3e37619297075ad495142416ffd3baea6 diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index a60ff6776e..1a4c53cc8d 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/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-models/README.md -README.md: 89a253fca9f16ea5655cdd7536a441e98dcc4d3c -README.zh.md: 350be495f1491738e6e861ba127b3b0070a5f073 +README.md: f6604f822412e9eb4574696f5b99e73fb7bd98ff +README.zh.md: 2500bbae0982571a9a88dd5c259749e3504728de diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index d671654d37..8a6fbf25c9 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -26,11 +26,10 @@ export const inject = [] /** * Provide the settings-namespace scope service. * - * `Service` registers itself under its own name from its constructor, so the - * class mounts as a plugin; a second `ctx.provide` for the same name would - * throw. + * Constructing the service in this plugin's fiber keeps its traced methods + * bound to each consuming plugin's context. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - ctx.plugin(SettingsScopeService) + new SettingsScopeService(ctx) } diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index 0cabc6024d..dd91c436f9 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/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-skill/README.md -README.md: 0456db4de9453e5060e39b5f061422486e44dfc9 -README.zh.md: 336f43117e7bc4de41a31e636ee0966e5d1a2cd6 +README.md: f6818ae9f44a7d0a484302fe8e294ee2159890ec +README.zh.md: 4b9d646fe39887e9d655b28c61c11876b55d1111 diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index 4a75ad187f..28b01d4bfe 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -148,6 +148,7 @@ describe('apply', () => { ctx.provide('sessions', {}) await ctx.plugin(SlashService).await() ctx.provide('connection', { api: { skills: { list: listOk(CATALOG) } } }) + new TestRemote(ctx) const presentation = providePresentation(ctx) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 61ffd7dcfd..5717d045a5 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: 426aa32c6b56b8be597969125206b4e1e7025622 -README.zh.md: 75383798555e3b775685b926630eedd97794005a +README.md: 7967b41b73f5f853fbfa67364b8a1c1a4156de08 +README.zh.md: 325ea29a736385ffef511ea1e02211b8b43be69f diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index 3396f83647..f6e971d157 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -18,6 +18,7 @@ import type { HostFrame } from '../src/api/events.ts' import { InvalidPresetIdError, PresetExistsError, resolveSessionPreset, UnknownPresetError, } from '@deepseek-ai/dsh-agent-presets' +import type {} from '@deepseek-ai/dsh-agent-presets/types' import { GoalId } from '@deepseek-ai/dsh-goal' import { createApiProxy } from '../src/api-proxy.ts' import { describe, expect, it } from 'vitest' @@ -373,7 +374,7 @@ describe('agentPreset.select', () => { .toBe('minimal') }) - it('forwards the committed switch so clients can drop that session\'s catalogs', async () => { + it('forwards the owner event so clients can drop that session\'s catalogs', async () => { const { api, ctx } = await harness(['standard', 'minimal']) await api.sessions.create(request({ sessionId: SessionId('sel-frame'), agentPreset: 'standard' })) // The host-stream opener reads the committed-workspace baseline; this @@ -390,10 +391,11 @@ describe('agentPreset.select', () => { } })() - await api.agentPresets.select( - request({ sessionId: SessionId('sel-frame'), agentPreset: 'minimal' })) - // The queue push rides the synchronous append, so one turn of the loop is - // enough to deliver it; closing the stream bounds the read either way. + // AgentPresets owns the committed-log-to-event mapping; this spec owns the + // forwarding of that event without recreating the owner's implementation. + ctx.emit('agent-preset/selected', SessionId('sel-frame'), 'minimal') + // The queue push is synchronous; one turn lets the async iterator consume + // it before the stream closes. await new Promise(resolve => setTimeout(resolve, 0)) abort.abort() await consume diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index 2465865bff..559144f6df 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/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/preset/agent-presets/README.md -README.md: 98891c8710adc7d72dee20a8466742ee6f649956 -README.zh.md: 0fcc5fa4d697affc2185b9251c6a60dee6510042 +README.md: 28b9a31ed41e5fc41e38d6b0349c5bd9cbaeed9d +README.zh.md: 1d8d1481c20005b9e7fed5341aa26dca63dcd815 diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 9f2760e704..92a080a930 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -16,6 +16,7 @@ import AgentPresets, { COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, PresetMountError, serviceForAgent, } from '@deepseek-ai/dsh-agent-presets' import type { Config } from '@deepseek-ai/dsh-agent-presets' +import type {} from '@deepseek-ai/dsh-agent-presets/types' import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' declare module '@deepseek-ai/cordis' { @@ -457,6 +458,18 @@ describe('attributing a service to a subtree', () => { }) describe('replacing a composition', () => { + it('publishes a committed preset selection for remote consumers', async () => { + const agent = await agentOn(ctx, 'sess-selected', 'standard') + const selected: Array<[SessionId, string]> = [] + ctx.on('agent-preset/selected', (sessionId, agentPreset) => { + selected.push([sessionId, agentPreset]) + }) + + agent.session.append('agent-preset/selected', { agentPreset: 'minimal' }) + + expect(selected).toEqual([[SessionId('sess-selected'), 'minimal']]) + }) + it('swaps the agent\'s tools without touching another session', async () => { const keeper = await agentOn(ctx, 'sess-keeper', 'standard') const handle = await ctx.agents.create({ diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 94835f96b5..ba5afe3995 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -1405,6 +1405,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * A declarative agent entry failed before it could publish a live agent.\n * Consumers that buffer work for the configured identity use this\n * transient signal to reject that work instead of waiting forever. Normal\n * factory teardown suppresses failures from the cancelled startup attempt.\n * @param payload.sessionId - exact shared agent/session identity that failed startup.\n * @param payload.error - persistence, setup, or publication failure.\n * @mode emit\n */', summary: 'A declarative agent entry failed before it could publish a live agent.', }, + { + name: 'agent-preset/selected', + mode: 'emit', + signature: '\'agent-preset/selected\'(sessionId: SessionId, agentPreset: string): void', + jsDoc: '/**\n * One session committed a different agent preset to its durable log.\n * Consumers invalidate only state derived from that session\'s composition.\n * @mode emit\n * @param sessionId - the session whose composition changed.\n * @param agentPreset - the preset recorded by the committed selection.\n */', + summary: 'One session committed a different agent preset to its durable log.', + }, { name: 'agent/created', mode: 'emit', @@ -1549,7 +1556,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'llm/adapters-updated', mode: 'emit', signature: '\'llm/adapters-updated\'(): void', - jsDoc: '/**\n * The provider topology changed: an adapter registered or unregistered\n * routes, or the configurable-provider directory gained or lost entries.\n * This is a payload-free registry notification fired at each commit point\n * (including registration disposal); consumers re-read `listProviders()`,\n * `listModels()`, or `listConfigurableProviders()` for the new state.\n * Observer failures are contained and cannot veto the registry mutation.\n * @mode emit\n */', + jsDoc: '/**\n * The provider topology changed: an adapter registered or unregistered\n * routes, or the configurable-provider directory gained or lost entries.\n * This payload-free registry notification fires at each commit point\n * (including registration disposal); consumers re-read `listProviders()`,\n * `listModels()`, or `listConfigurableProviders()` for the new state.\n * Observer failures are contained and cannot veto the registry mutation.\n * @mode emit\n */', summary: 'The provider topology changed: an adapter registered or unregistered routes, or the configurable-provider directory gained or lost entries.', }, { diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 27ab5ddce2..8c873043f1 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -149,6 +149,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { export const EVENT_SCOPE_PAGE: Record = { 'agent': 'core.md', 'agent-loop': 'core.md', + 'agent-preset': 'core.md', 'approval': 'approval.md', 'commands': 'commands.md', 'credentials': 'credentials.md', From 2c4b3d015b382e8727a9944ff0c67b9bb421cb49 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:19:43 +0800 Subject: [PATCH 26/31] fix: setting scope ctx --- packages/client/ui-settings/src/client/settings-scope.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index 7af1c1eb97..a744482d09 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -31,6 +31,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/types' // never — the owning package's client-safe, type-only subpath supplies the // cordis `Events` entry (and with it the branded `SettingsNamespace`). import type {} from '@deepseek-ai/dsh-settings/types' +import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta' type SettingsFace = Pick @@ -239,7 +240,7 @@ export class SettingsScopeService extends Service { void controller.load() } const disposers = [ - ctx.remote.$on('settings/document-updated', refresh), + ctx.get('remote')!.$on('settings/document-updated', refresh), ctx.on('connection/reset', () => { refresh() }), ] void controller.load() From f484111e376e7daa7f14feec4f1a9abd58efe30c Mon Sep 17 00:00:00 2001 From: imccyu Date: Tue, 11 Aug 2026 18:25:33 +0800 Subject: [PATCH 27/31] fix: build --- packages/client/ui-settings/src/client/settings-scope.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index a744482d09..256ef1b67c 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -31,7 +31,6 @@ import type {} from '@deepseek-ai/dsh-api-remotes/types' // never — the owning package's client-safe, type-only subpath supplies the // cordis `Events` entry (and with it the branded `SettingsNamespace`). import type {} from '@deepseek-ai/dsh-settings/types' -import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta' type SettingsFace = Pick From 473289b46ae2d925fcf95f7ae3fe8791045b3875 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:38:47 +0800 Subject: [PATCH 28/31] fix: snapshot --- .../translation-prompt-v4/request-response.expected.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 3e7308ee97..6d2784ea0d 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -16,11 +16,11 @@ }, { "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. 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\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。普通 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。不要把该结构推广到其他包;[`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单次运行的 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单次运行的 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", From ac01c3b03561776e158b040086e825dcdfcc5fe1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:01:52 +0800 Subject: [PATCH 29/31] fix: lint --- packages/client/ui-settings/src/client/settings-scope.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index 256ef1b67c..e08e4da42e 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -31,6 +31,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/types' // never — the owning package's client-safe, type-only subpath supplies the // cordis `Events` entry (and with it the branded `SettingsNamespace`). import type {} from '@deepseek-ai/dsh-settings/types' +import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta' type SettingsFace = Pick @@ -239,7 +240,7 @@ export class SettingsScopeService extends Service { void controller.load() } const disposers = [ - ctx.get('remote')!.$on('settings/document-updated', refresh), + (ctx.get('remote') as TypeRTClientRemote).$on('settings/document-updated', refresh), ctx.on('connection/reset', () => { refresh() }), ] void controller.load() From 3727e242cd39132d78e7b8fbb04b876c18b0f1ed Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:07:16 +0800 Subject: [PATCH 30/31] fix(ci): satisfy lint contracts-ready lane --- packages/api/gateway/tests/client.spec.ts | 6 +----- packages/client/ui-settings/src/client/settings-scope.ts | 4 +--- packages/host/apiproxy/tests/api-proxy-config.spec.ts | 6 ++---- .../session-query/tests/session-query.spec.ts | 7 ++----- 4 files changed, 6 insertions(+), 17 deletions(-) diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index 3c9a1b5ff4..d4a5965611 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -667,11 +667,7 @@ describe('Client TypeRT API', () => { const seen: string[] = [] // The declared return is void, so nobody awaits an async listener: the // rejection has to be contained here or it escapes as an unhandled one. - /* oxlint-disable-next-line typescript/no-misused-promises -- - * Deliberately the shape the contract does not invite: `$on` declares a void - * listener, and this pins what the service does when a caller hands it an - * async one anyway. */ - ctx.remote.$on('fixture/changed', () => Promise.reject(new Error('fixture async failure'))) + ctx.remote.$on('fixture/changed', () => Promise.reject(new Error('fixture async failure'))) // oxlint-disable-line typescript/no-misused-promises ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) try { ctx.remote.$dispatch('fixture/changed', ['credentials']) diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index e08e4da42e..674b6ea517 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -31,8 +31,6 @@ import type {} from '@deepseek-ai/dsh-api-remotes/types' // never — the owning package's client-safe, type-only subpath supplies the // cordis `Events` entry (and with it the branded `SettingsNamespace`). import type {} from '@deepseek-ai/dsh-settings/types' -import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta' - type SettingsFace = Pick /** @@ -240,7 +238,7 @@ export class SettingsScopeService extends Service { void controller.load() } const disposers = [ - (ctx.get('remote') as TypeRTClientRemote).$on('settings/document-updated', refresh), + (ctx.get('remote') as Context['remote']).$on('settings/document-updated', refresh), ctx.on('connection/reset', () => { refresh() }), ] void controller.load() diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 165f406b24..e748d3a38c 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -229,10 +229,8 @@ function forwardedSettings(ns: string): HostFrame { return { type: 'host/remote-event', event: 'settings/document-updated', - /* oxlint-disable-next-line typescript/no-unsafe-assignment -- - * expect.any is typed `any`; the frame's args are JsonValue[]. The - * revision is the Host's own counter, so the matcher is the assertion. */ - args: [ns, expect.any(Number)], + // The revision is the Host's own counter, so the matcher is the assertion. + args: [ns, expect.any(Number)], // oxlint-disable-line typescript/no-unsafe-assignment } } diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index a61be6a7a0..b87bd017fb 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -134,11 +134,8 @@ function expectCode(code: SessionQueryErrorCode): Error { } function rejectUnknown(reason: unknown): Promise { - return new Promise((_resolve, reject) => { - // Exercise containment for an implementation that violates the Error rejection convention. - // oxlint-disable-next-line typescript/prefer-promise-reject-errors - reject(reason) - }) + // Exercise containment for an implementation that violates the Error rejection convention. + return Promise.reject(reason) // oxlint-disable-line typescript/prefer-promise-reject-errors } const cancellableSessionListings = [ From 23c35108d6f7de55bbd163b565b416f33fb8589a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:25:06 +0800 Subject: [PATCH 31/31] fix(test): inject locale settings dependencies --- .../client/ui-deliverables/tests/produced-files.spec.tsx | 4 ++-- .../client/ui-workflow-run/tests/workflow-run.spec.tsx | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index 78d3317d75..bc7324cac1 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -16,7 +16,7 @@ import type { ConversationTimelineSnapshot, ConversationTurnDataMap, ConversationViewDefinition, ConversationViewNode, ToolResultNode, TurnLocation, } from '@deepseek-ai/dsh-client-runtime/client' -import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' +import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { ProducedFiles } from '../src/client/ProducedFiles.tsx' @@ -344,7 +344,7 @@ describe('plugin registration', () => { // ui-theme's Appearance row binds a durable scope through these two. ctx.provide('remote', { $on: () => () => {} } as never) ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) - await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() + await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() diff --git a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx index 667eabe903..196bdf1144 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx @@ -9,8 +9,8 @@ import type { ChatConversationViewNode, ConversationEventInput, ConversationMatch, ConversationNodeDefinition, ConversationViewDefinition, SessionId, SessionListState, } from '@deepseek-ai/dsh-client-runtime/client' -import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' +import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { WorkflowRunPanel, type WorkflowRunInjected, type WorkflowRunPanelProps, } from '../src/client/WorkflowRunPanel.tsx' @@ -481,13 +481,15 @@ describe('plugin lifecycle', () => { const ctx = new Context() await ctx.plugin(SlotsService).await() ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + ctx.provide('remote', { $on: () => () => {} } as never) + ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) await ctx.plugin(ConversationEventRegistry).await() await ctx.plugin(TestSessions).await() ctx.slots.register({ name: 'root', children: { 'conversation.chat.node': { kind: 'keyed', scope: 'session' } }, } as never, () => null) - await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() + await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() expect(ctx.conversationEvents.entries().map(entry => entry.kind)).toEqual(['workflow-run'])