mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge worktree/schedule-explicit-at into worktree/schedule-fixed-rate
This commit is contained in:
@@ -10,4 +10,4 @@ When a shipped note is unlikely to guide future work, archive its complete tripl
|
||||
|
||||
### This is not a license to rewrite the *decision*
|
||||
|
||||
Update factual realization in place. A reversal of the decision or its rationale requires a new Agent Note and cross-link; a fully superseded old note may be deleted only through the consolidation rule in the [Agent Note contract](../README.md).
|
||||
Update factual realization in place. A reversal of the decision or its rationale requires a new Agent Note and cross-link; a fully superseded old note may be deleted only through the consolidation rule in the [Agent Note rules](../README.md).
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-13-capability-seams.md
|
||||
2026-06-13-capability-seams.md: ca5071cff1b26ba3b89537a73f40d7ecbde4b2bb
|
||||
2026-06-13-capability-seams.md: b2dc124f7bf0b56598466e6521e0764af06075ab
|
||||
2026-06-13-capability-seams.zh.md: 7790124b980e0525705c2db398802ea6a418685a
|
||||
|
||||
@@ -6,7 +6,7 @@ English | [中文](2026-06-13-capability-seams.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The harness has swappable capabilities — bash execution today, sandboxed/remote executors and alternative model providers tomorrow. A capability has three concerns that change at different rates and for different reasons: the *contract* (what the capability is), the *implementation* (how it runs), and the *consumer surface* (what the model and other plugins program against). Bundling them in one package couples those rates of change — swapping a local executor for a sandboxed one would churn the tool schemas the model sees, even though the model-facing contract never changed.
|
||||
The harness has swappable capabilities — bash execution today, sandboxed/remote executors and alternative model providers tomorrow. A capability has three concerns that change at different rates and for different reasons: the *contract* (what the capability is), the *implementation* (how it runs), and the *consumer API* (what the model and other plugins program against). Bundling them in one package couples those rates of change — swapping a local executor for a sandboxed one would churn the tool schemas the model sees, even though the model-facing contract never changed.
|
||||
|
||||
This is distinct from "who provides vs. needs a capability at runtime", which Cordis already answers with services + `inject` (a provider registers `ctx.bash`; a consumer declares `inject: ['bash']` and its fiber pends until the service exists). That mechanism is necessary but doesn't dictate package boundaries; this Agent Note does.
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md
|
||||
2026-06-17-filesystem-capability-seam.md: 928ad4926e46ed5b6b35552b75882d565d8ad6db
|
||||
2026-06-17-filesystem-capability-seam.md: 63628d592c17f000ae49f9558597bf8059f04942
|
||||
2026-06-17-filesystem-capability-seam.zh.md: 02af77a3cc291eeba2be966a4bc08aa006675e7d
|
||||
|
||||
@@ -12,7 +12,7 @@ That couples three concerns that change independently:
|
||||
|
||||
1. The filesystem contract: what operations plugins can ask for.
|
||||
2. The backend: local disk now, sandboxed/remote/project-scoped filesystem later.
|
||||
3. The consumer surface: model-facing `read` / `write` / `edit` schemas and result formatting.
|
||||
3. The consumer API: model-facing `read` / `write` / `edit` schemas and result formatting.
|
||||
|
||||
Without a `ctx.fs` interface, swapping local filesystem access for a sandboxed or remote backend would churn the tool schemas, demos, and prompt guidance even when the model-facing contract should stay stable. It also makes permission/sandbox boundaries harder to reason about: a `cwd` option can look like a sandbox even though it is only a base path unless an explicit backend or `tools/execute` policy enforces containment.
|
||||
|
||||
@@ -139,7 +139,7 @@ The defensive-pattern classes this repo has been bitten by are pinned directly:
|
||||
## Alternatives considered
|
||||
|
||||
- **Model-facing tools directly over `node:fs`** — the tool package would own execution policy, path resolution, atomic writes, text decoding, and edit semantics at once, coupling the three independently-changing concerns the Problem names and churning schemas on any backend swap.
|
||||
- **One combined `dsh-fs-tools` package** — the pre-seam shape; rejected for the same Service Definition / Service provider / Consumer split as bash, and the combined name never became public surface.
|
||||
- **One combined `dsh-fs-tools` package** — the pre-seam shape; rejected for the same Service Definition / Service provider / Consumer split as bash, and the combined name never became public API.
|
||||
- **Observed-state on `ctx.fs`** — the shape this Agent Note first landed; superseded by [the split-fs-seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) and [the event-gate Agent Note](2026-06-26-file-context-as-event-gate.md): a sandboxed/remote backend must not inherit model-facing observation policy, so the provider keeps only the version token and the optional version-guarded mutation.
|
||||
|
||||
## Consequences
|
||||
@@ -160,4 +160,4 @@ The defensive-pattern classes this repo has been bitten by are pinned directly:
|
||||
|
||||
**Error codes become part of the seam.** `FsError` codes make stale-version and observation failures machine-routable through the existing structured error taxonomy. The cost is that `dsh-fs` imports the shared `HarnessError` base from `dsh-llm`; that dependency is intentional and stays limited to the error vocabulary.
|
||||
|
||||
**Package churn is front-loaded.** The three-package split adds boilerplate before there is more than one backend. This is intentional: filesystem access is a likely sandbox/remote boundary, and changing the package surface after shipping model-facing tools would be more expensive.
|
||||
**Package churn is front-loaded.** The three-package split adds boilerplate before there is more than one backend. This is intentional: filesystem access is a likely sandbox/remote boundary, and changing the package API after shipping model-facing tools would be more expensive.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.md
|
||||
2026-06-18-agent-lifecycle-and-ownership-contracts.md: ad334d30afcaf1864a1ea3dd42f3c5b7d157603b
|
||||
2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md: b9240969c9e952c1b38cbd4c088853005e156067
|
||||
2026-06-18-agent-lifecycle-and-ownership-contracts.md: c3522d18dad2703664f12364e4d70cc057a3a945
|
||||
2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md: 16781c8a5d1a1c67d1fe9296b0f34f2a00b11caf
|
||||
|
||||
@@ -43,7 +43,7 @@ The bash owner-token comparison relies on the shared `Agent.id`/`SessionId` bein
|
||||
|
||||
- **A public `BashTask.owner` field** instead of the `BashExecutor.ownerOf(id)` Service Definition method — rejected: one read path, no redundant API.
|
||||
- **Sibling cordis effects for the agent's session lifecycle** — rejected: a fiber unload disposes sibling effects concurrently (`Promise.all`), racing removal of the store-owned append publication hooks against the loop's closing `session/flush`; the single composite effect's ordered LIFO chain is what captures the closing `turn/end` on both disposal paths.
|
||||
- **A separate step-only `abort()` beside `cancel()`** — shipped originally, then removed as unused; `cancel()` is the single public stop primitive ([the public-stop-surface Agent Note](../simplification/2026-06-20-public-agent-stop-surface.md)).
|
||||
- **A separate step-only `abort()` beside `cancel()`** — shipped originally, then removed as unused; `cancel()` is the single public stop primitive ([the public-stop-API Agent Note](../simplification/2026-06-20-public-agent-stop-api.md)).
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ bash 所有者 token 比较依赖共享的 `Agent.id`/`SessionId` 在存活 agen
|
||||
|
||||
- **公开的 `BashTask.owner` 字段**而非 `BashExecutor.ownerOf(id)` Service Definition 方法:否决。一条读取路径即可,无需冗余 API。
|
||||
- **为 agent 的会话生命周期使用兄弟 Cordis effect**:否决。fiber 卸载时并发释放兄弟 effect(`Promise.all`),store 拥有的 append 发布钩子的移除与循环的关闭 `session/flush` 产生竞争;单一复合 effect 的有序 LIFO 链才能在两条释放路径上都捕获关闭的 `turn/end`。
|
||||
- **在 `cancel()` 之外另设一个仅中止步骤的 `abort()`**:最初发布过,后因无人使用而移除;`cancel()` 是唯一的公开停止原语(见[公开停止接口 Agent Note](../simplification/2026-06-20-public-agent-stop-surface.md))。
|
||||
- **在 `cancel()` 之外另设一个仅中止步骤的 `abort()`**:最初发布过,后因无人使用而移除;`cancel()` 是唯一的公开停止原语(见[公开停止接口 Agent Note](../simplification/2026-06-20-public-agent-stop-api.md))。
|
||||
|
||||
## 后果
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md
|
||||
2026-06-18-shared-persistence-write-coordinator.md: c376bb4a209a250d33274dfd60531475354ba363
|
||||
2026-06-18-shared-persistence-write-coordinator.md: 93b6cd1bd058499e71948d3909de8e4c076b445e
|
||||
2026-06-18-shared-persistence-write-coordinator.zh.md: 06a2dafc4d14c29bb7758fdc67d3f0f8136983e2
|
||||
|
||||
@@ -40,7 +40,7 @@ The shared `runPersistenceContract` (public-API contract) runs for every backend
|
||||
## Alternatives considered
|
||||
|
||||
- **A base class the backends extend** — rejected for composition: a backend exposes only the hooks, cannot reach the coordinator's private orchestration state, and a third-party backend may still implement the abstract service directly without the coordinator at all.
|
||||
- **A wider hook surface** — each candidate hook folds away: there is no scope-specific live lookup because `loadStored` plus the coordinator's cwd check preserves the collision boundary, no storage-locator generic because validated JSONL metadata reproduces its path while SQLite is already id-bound, no separate `materialize` hook because the first batch must commit atomically with materialization, no separate create-collision probe because it is `loadStored(id) !== undefined`, and no coordinator pass-through for `list()` because listing needs none of the orchestration.
|
||||
- **A wider hook API** — each candidate hook folds away: there is no scope-specific live lookup because `loadStored` plus the coordinator's cwd check preserves the collision boundary, no storage-locator generic because validated JSONL metadata reproduces its path while SQLite is already id-bound, no separate `materialize` hook because the first batch must commit atomically with materialization, no separate create-collision probe because it is `loadStored(id) !== undefined`, and no coordinator pass-through for `list()` because listing needs none of the orchestration.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-20-branded-ids.md
|
||||
2026-06-20-branded-ids.md: 78242226103fef0eac320cdfac0ccb82292fd9e8
|
||||
2026-06-20-branded-ids.md: ded48409bcb3deb19e35029fa795b5ea28c9f6d7
|
||||
2026-06-20-branded-ids.zh.md: 613ef81d198f10ec70ca5cb019776e607f4a8a79
|
||||
|
||||
@@ -60,7 +60,7 @@ Kept deliberately narrow per the "not every string needs a brand" policy. Each o
|
||||
|
||||
## Verification
|
||||
|
||||
The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-bash` and threaded end-to-end (Service Definition, the `dsh-bash-local` generation site, the `dsh-tool-bash` model-facing surface) with no `dsh-bash` dependency on `dsh-session`; no collection keyed by an in-scope branded id (`CallId`/`SessionId`/`BashTaskId`) is keyed by bare `string`; public method params and exported signatures keep the brand; and brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `task_id`), never as scattered `as` casts.
|
||||
The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-bash` and threaded end-to-end (Service Definition, the `dsh-bash-local` generation site, the `dsh-tool-bash` model-facing tool) with no `dsh-bash` dependency on `dsh-session`; no collection keyed by an in-scope branded id (`CallId`/`SessionId`/`BashTaskId`) is keyed by bare `string`; public method params and exported signatures keep the brand; and brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `task_id`), never as scattered `as` casts.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md
|
||||
2026-06-20-generic-long-running-tool-runtime.md: 0dd49fe60f2c45081973657ff960b780d2d47257
|
||||
2026-06-20-generic-long-running-tool-runtime.zh.md: 6e8458aea73536859bf4c81894679d31a5034d3a
|
||||
2026-06-20-generic-long-running-tool-runtime.md: 457eaac7de92ea37287803349672271148d59185
|
||||
2026-06-20-generic-long-running-tool-runtime.zh.md: 9a86c4cf2b49973212adfd4b9acdb54150af01d1
|
||||
|
||||
@@ -25,7 +25,7 @@ Long-running tools are producers. `dsh-tool-bash` adapts a `BashProcess` into in
|
||||
|
||||
The literal types live on the [tasks subsystem page](../../../../docs/subsystems/tasks.md). A producer calls `ctx.tasks.start()` with a kind, label, optional owning `Agent`, optional positive `outputLimitBytes`, and a `run()` function. The runtime completes all failable preflight work before calling `run()` and invokes it once. After `run()` returns hooks, registration commits without another failable step; a producer cannot start work that lacks a collectable task id.
|
||||
|
||||
`outputLimitBytes` is producer-owned presentation policy, not a registry buffer. The registry validates and projects it unchanged into `TaskSnapshot`; generic control surfaces apply the cap to complete model-facing output after adding their own status or notice metadata. Omitting it preserves the existing surface behavior, so the runtime does not impose a hidden default on unrelated producer families.
|
||||
`outputLimitBytes` is producer-owned presentation policy, not a registry buffer. The registry validates and projects it unchanged into `TaskSnapshot`; generic control APIs apply the cap to complete model-facing output after adding their own status or notice metadata. Omitting it preserves the existing controller behavior, so the runtime does not impose a hidden default on unrelated producer families.
|
||||
|
||||
A model-facing producer exposes that committed id in its canonical success value, normally `{ kind: 'background', taskId }`; Native rendering may keep human-readable prose. A pre-aborted background call fails rather than returning a no-op because no task exists to satisfy the promised handle. Once registration publishes the id, cancellation belongs to the task's own controller and the task runtime: later cancellation of the producing tool call must not kill the published task. `task_kill`, owner disposal, and service teardown request cancellation; foreground execution remains coupled to the call's `exec.signal`.
|
||||
|
||||
@@ -39,7 +39,7 @@ Statuses are `running`, `stopping`, `completed`, `killed`, and `failed`. Produce
|
||||
|
||||
The runtime attaches one continuation to `done`, records the first terminal outcome, resolves waiters, and invokes completion listeners with per-listener error containment. First-wins settlement matters during teardown: if `cancel` throws, the runtime force-fails the record and warns that work may be orphaned rather than waiting forever for a promise that may never settle. A later producer outcome cannot overwrite that diagnosis or notify twice. A `cancel` that returns without eventually settling `done` still blocks teardown because the runtime cannot distinguish it from a slow, valid stop.
|
||||
|
||||
Task registrations are not effects of the producer tool fiber. Reloading a tool or control-surface plugin therefore does not kill work owned by an agent and backend. The task service's own disposal cancels all live tasks and awaits contract-compliant producers.
|
||||
Task registrations are not effects of the producer tool fiber. Reloading a tool or controller plugin therefore does not kill work owned by an agent and backend. The task service's own disposal cancels all live tasks and awaits contract-compliant producers.
|
||||
|
||||
## Authorization and owner lifecycle
|
||||
|
||||
@@ -51,7 +51,7 @@ The first task for an owner attaches one asynchronous effect to `owner.ctx`. Age
|
||||
|
||||
For contract-compliant producers, `AgentHandle.dispose()` resolves only after owned background work has stopped. Work intended to outlive an agent must be started unowned; survival across runtime restarts requires a separate durable-job design.
|
||||
|
||||
## Service surface
|
||||
## Service API
|
||||
|
||||
`TaskService` provides:
|
||||
|
||||
@@ -61,13 +61,13 @@ For contract-compliant producers, `AgentHandle.dispose()` resolves only after ow
|
||||
- `kill(id, caller?, reason?)` for cancellation.
|
||||
- `wait(id, timeoutMs, caller?, signal?)` for bounded terminal waiting.
|
||||
- `onTaskDone(listener)` for effect-scoped observation with exact-owner delivery and listener containment.
|
||||
- `attachSurface(name)` for the control-surface availability fence.
|
||||
- `attachController(name)` for the task-controller availability fence.
|
||||
|
||||
`wait` returns the terminal snapshot when the task settles or the live snapshot when its timeout expires. Aborting a wait cancels only that wait. If settlement has already assigned terminal delivery to the waiter, the terminal snapshot still wins. Waiters unregister synchronously on abort so a same-tick settlement cannot suppress a completion notice on behalf of a reader that receives nothing.
|
||||
|
||||
A producer loaded without any control surface would let callers start work they cannot collect or stop. `dsh-tool-tasks` therefore calls `attachSurface()` for its lifetime, and `start()` fails before producer execution when no surface is attached. This check occurs at start rather than plugin load because sibling plugins may activate concurrently. Custom non-model surfaces can attach themselves without teaching the registry tool names.
|
||||
A producer loaded without any controller would let callers start work they cannot collect or stop. `dsh-tool-tasks` therefore calls `attachController()` for its lifetime, and `start()` fails before producer execution when no controller is attached. This check occurs at start rather than plugin load because sibling plugins may activate concurrently. Custom non-model controllers can attach themselves without teaching the registry tool names.
|
||||
|
||||
## Model-facing control surface
|
||||
## Model-facing control API
|
||||
|
||||
`dsh-tool-tasks` registers three kind-independent tools with generic UI cards:
|
||||
|
||||
@@ -79,13 +79,13 @@ Stream reads share one task-scoped consuming cursor because the owning model is
|
||||
|
||||
The system prompt tells the model to retain task ids, continue independent work instead of busy-polling or duplicating a running task, collect relevant tasks before its final answer, and kill work that no longer matters. Completion injects a logged `context/message` into the exact owner's session; it becomes durable context for the next request but does not wake an idle agent.
|
||||
|
||||
The runtime marks a terminal task `reported` when a read or wait delivers it, when a live waiter has claimed delivery at settlement, or when the model explicitly kills it. Reported tasks do not inject redundant completion notices. Listener failures are logged independently, do not stop later listeners, and are not awaited by waiters or teardown. When a snapshot carries `outputLimitBytes`, `dsh-tool-tasks` preserves UTF-8 boundaries and reuses an existing producer truncation marker rather than duplicating it. Reads reserve status suffixes and retain the output tail; completion notices reserve the stable `background task <id>` prefix and `task_output` instruction before truncating variable kind, label, status, detail, or the truncation marker itself, so the minimum PTY cap still identifies the task to collect. The task surface resolves the caller-visible producer cap in a prepended pre-execute listener before policy can deny or short-circuit dispatch, then applies it through the task definitions' last-mile `finalizeContent` callback so normalized tool errors, outer pipeline failures, and single-text policy results cannot escape the bound; deliberately structured multi-block policy results retain policy ownership of their shape and size.
|
||||
The runtime marks a terminal task `reported` when a read or wait delivers it, when a live waiter has claimed delivery at settlement, or when the model explicitly kills it. Reported tasks do not inject redundant completion notices. Listener failures are logged independently, do not stop later listeners, and are not awaited by waiters or teardown. When a snapshot carries `outputLimitBytes`, `dsh-tool-tasks` preserves UTF-8 boundaries and reuses an existing producer truncation marker rather than duplicating it. Reads reserve status suffixes and retain the output tail; completion notices reserve the stable `background task <id>` prefix and `task_output` instruction before truncating variable kind, label, status, detail, or the truncation marker itself, so the minimum PTY cap still identifies the task to collect. The task controller resolves the caller-visible producer cap in a prepended pre-execute listener before policy can deny or short-circuit dispatch, then applies it through the task definitions' last-mile `finalizeContent` callback so normalized tool errors, outer pipeline failures, and single-text policy results cannot escape the bound; deliberately structured multi-block policy results retain policy ownership of their shape and size.
|
||||
|
||||
## Producer opt-in
|
||||
|
||||
Each producer owns whether its schema exposes `run_in_background` through defaulted config. `dsh-tool-bash`, `dsh-tool-pty`, and each `dsh-tool-subagent` instance use `enableRunInBackground`, defaulting to true. A disabled instance omits the parameter and also rejects a forced background argument at execution because the generic argument validator permits undeclared keys. Schema omission advertises the capability; the execution check enforces it.
|
||||
|
||||
`ctx.tasks` does not rewrite producer schemas. A bundle forwards configuration only for producers it owns. If a background call reaches `start()` without an attached surface, the runtime fence fails before execution.
|
||||
`ctx.tasks` does not rewrite producer schemas. A bundle forwards configuration only for producers it owns. If a background call reaches `start()` without an attached controller, the runtime fence fails before execution.
|
||||
|
||||
## Producer integrations
|
||||
|
||||
@@ -107,7 +107,7 @@ The current `TaskStart.run()` contract passes in-process callbacks and exact `Ag
|
||||
|
||||
### Consumer-owned authorization or cleanup events
|
||||
|
||||
Consumer-owned checks invite inconsistent or missing isolation on each new surface. A broadcast cleanup event makes every listener filter every agent and provides no registration disposer. Central authorization plus one owner-scoped effect gives every consumer the same fence and an awaited, removable lifecycle hook.
|
||||
Consumer-owned checks invite inconsistent or missing isolation on each new controller. A broadcast cleanup event makes every listener filter every agent and provides no registration disposer. Central authorization plus one owner-scoped effect gives every consumer the same fence and an awaited, removable lifecycle hook.
|
||||
|
||||
### Blocking output or a separate wait tool
|
||||
|
||||
@@ -125,7 +125,7 @@ Authorization, not unguessability, is the access boundary, and ids do not derive
|
||||
|
||||
## Testing
|
||||
|
||||
Unit coverage pins preflight atomicity, per-kind ids, output-limit validation and projection, complete UTF-8 result bounds, stream and final reads, wait timeout and abort races, cancellation, first-wins settlement, listener containment, notice suppression, owner isolation, stale owner instances, owner cleanup, service teardown, and the no-surface fence. Producer tests cover bash process mapping, subagent startup cancellation, terminal mapping, and disposal. Snapshot coverage pins the control-tool schemas and prompt guidance.
|
||||
Unit coverage pins preflight atomicity, per-kind ids, output-limit validation and projection, complete UTF-8 result bounds, stream and final reads, wait timeout and abort races, cancellation, first-wins settlement, listener containment, notice suppression, owner isolation, stale owner instances, owner cleanup, service teardown, and the no-controller fence. Producer tests cover bash process mapping, subagent startup cancellation, terminal mapping, and disposal. Snapshot coverage pins the control-tool schemas and prompt guidance.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ Status: implemented
|
||||
|
||||
字面类型见[任务子系统页面](../../../../docs/subsystems/tasks.md)。生产方调用 `ctx.tasks.start()`,传入 kind、label、可选的所属 `Agent`、可选的正数 `outputLimitBytes` 与一个 `run()` 函数。运行时会在调用 `run()` 前完成所有可能失败的预检工作,并且只调用一次。`run()` 返回钩子后,注册过程不会再执行可能失败的步骤而直接提交;生产方无法启动没有可收集 task id 的工作。
|
||||
|
||||
`outputLimitBytes` 是生产方拥有的呈现策略,而非注册表缓冲区。注册表校验该值,并将其原样投影到 `TaskSnapshot`;通用控制接口添加自身的状态或通知元数据后,再将该上限应用于完整的面向模型输出。省略该值时保持现有接口行为,因此运行时不会向无关的生产方类别施加隐式默认值。
|
||||
`outputLimitBytes` 是生产方拥有的呈现策略,而非注册表缓冲区。注册表校验该值,并将其原样投影到 `TaskSnapshot`;通用任务控制器添加自身的状态或通知元数据后,再将该上限应用于完整的面向模型输出。省略该值时保持现有控制器行为,因此运行时不会向无关的生产方类别施加隐式默认值。
|
||||
|
||||
面向模型的生产方会在规范成功值中暴露已提交的 id,通常为 `{ kind: 'background', taskId }`;Native 渲染仍可保留便于人类阅读的行文。预先被中止的后台调用会失败,而不是返回空操作,因为不存在可履行所承诺句柄的任务。一旦注册过程发布 id,取消就归任务自身的控制器与任务运行时所有:随后取消生产工具调用不得终止已发布的任务。`task_kill`、所有者资源释放和服务拆除会请求取消;前台执行仍与调用的 `exec.signal` 耦合。
|
||||
|
||||
@@ -39,7 +39,7 @@ Status: implemented
|
||||
|
||||
运行时为 `done` 附加一个 continuation,记录第一个终止结果、解决等待方,并逐个调用完成监听器,同时隔离每个监听器的错误。首次结果优先的结算在资源销毁期间至关重要:如果 `cancel` 抛出,运行时会强制将记录标为失败,并警告工作可能遗留,而不是永远等待一个可能永不完成的 promise。后续生产方结果不能覆盖该诊断,也不能重复通知。`cancel` 返回后如果最终未使 `done` 完成,仍会阻塞资源销毁,因为运行时无法区分这种情况与缓慢但有效的停止。
|
||||
|
||||
任务注册不是生产方工具 fiber 的 effect。因此,重新加载工具或控制接口插件不会终止由 agent(智能体)和后端拥有的工作。任务服务自身释放时会取消所有实时任务,并等待遵守约定的生产方。
|
||||
任务注册不是生产方工具 fiber 的 effect。因此,重新加载工具或控制器插件不会终止由 agent(智能体)和后端拥有的工作。任务服务自身释放时会取消所有实时任务,并等待遵守约定的生产方。
|
||||
|
||||
## 授权与所有者生命周期
|
||||
|
||||
@@ -61,13 +61,13 @@ task id 在运行时全局可见且可预测,因此注册表会授权每次访
|
||||
- `kill(id, caller?, reason?)`:取消。
|
||||
- `wait(id, timeoutMs, caller?, signal?)`:有界的终止等待。
|
||||
- `onTaskDone(listener)`:effect 作用域内的观察,具有精确所有者投递和监听器隔离。
|
||||
- `attachSurface(name)`:控制接口可用性防线。
|
||||
- `attachController(name)`:任务控制器可用性防线。
|
||||
|
||||
`wait` 在任务完成时返回终止快照,在等待超时时返回实时快照。中止一次等待只取消该次等待。如果结算已经将终止投递分配给该等待方,终止快照仍然优先。等待方在中止时同步注销,因此同一 tick 内的结算无法代表一个什么也未收到的读取方压制完成通知。
|
||||
|
||||
如果生产方加载时没有任何控制接口,调用方就能启动无法收集或停止的工作。因此,`dsh-tool-tasks` 在其整个生命周期内调用 `attachSurface()`;没有附加接口时,`start()` 会在生产方开始执行前失败。该检查发生在启动时而非插件加载时,因为兄弟插件可能并发激活。自定义的非模型接口可以自行附加,无需让注册表了解工具名称。
|
||||
如果生产方加载时没有任何任务控制器,调用方就能启动无法收集或停止的工作。因此,`dsh-tool-tasks` 在其整个生命周期内调用 `attachController()`;没有附加控制器时,`start()` 会在生产方开始执行前失败。该检查发生在启动时而非插件加载时,因为兄弟插件可能并发激活。自定义的非模型控制器可以自行附加,无需让注册表了解工具名称。
|
||||
|
||||
## 面向模型的控制接口
|
||||
## 面向模型的控制器
|
||||
|
||||
`dsh-tool-tasks` 注册三个与 kind 无关的工具,并使用通用 UI 卡片:
|
||||
|
||||
@@ -79,13 +79,13 @@ task id 在运行时全局可见且可预测,因此注册表会授权每次访
|
||||
|
||||
系统提示词要求模型保留 task id、在后台工作运行时继续处理独立工作而非忙轮询或重复启动同一任务、在给出最终答案前收集相关任务,并终止不再重要的工作。完成时,系统会向确切所有者的会话注入一条已记录的 `context/message`;它会成为下一个请求的持久上下文,但不会唤醒空闲的 agent。
|
||||
|
||||
当读取或等待交付终止任务、实时等待方在结算时认领了投递,或模型显式终止任务时,运行时将终止任务标为 `reported`。已报告的任务不会注入冗余的完成通知。监听器失败会独立记录,不会阻止后续监听器,也不会被等待方或资源销毁过程等待。当快照携带 `outputLimitBytes` 时,`dsh-tool-tasks` 会保持 UTF-8 边界,并复用生产方已有的截断标记,而不会重复添加。读取会为状态后缀预留空间并保留输出尾部;完成通知会先为稳定的 `background task <id>` 前缀与 `task_output` 指令预留空间,再截断可变的 kind、label、status、detail,乃至截断标记本身,因此 PTY 的最小上限仍能标识需要收集的任务。任务接口在策略有机会拒绝或短路分发之前,于最先执行的 pre-execute 监听器中解析调用方可见的生产方上限;随后通过任务定义最后一道的 `finalizeContent` 回调应用该上限,使规范化的工具错误、外层流水线失败与单文本策略结果都无法绕过该边界;经特意结构化的多块策略结果仍由策略拥有其形状与大小。
|
||||
当读取或等待交付终止任务、实时等待方在结算时认领了投递,或模型显式终止任务时,运行时将终止任务标为 `reported`。已报告的任务不会注入冗余的完成通知。监听器失败会独立记录,不会阻止后续监听器,也不会被等待方或资源销毁过程等待。当快照携带 `outputLimitBytes` 时,`dsh-tool-tasks` 会保持 UTF-8 边界,并复用生产方已有的截断标记,而不会重复添加。读取会为状态后缀预留空间并保留输出尾部;完成通知会先为稳定的 `background task <id>` 前缀与 `task_output` 指令预留空间,再截断可变的 kind、label、status、detail,乃至截断标记本身,因此 PTY 的最小上限仍能标识需要收集的任务。任务控制器在策略有机会拒绝或短路分发之前,于最先执行的 pre-execute 监听器中解析调用方可见的生产方上限;随后通过任务定义最后一道的 `finalizeContent` 回调应用该上限,使规范化的工具错误、外层流水线失败与单文本策略结果都无法绕过该边界;经特意结构化的多块策略结果仍由策略拥有其形状与大小。
|
||||
|
||||
## 生产方显式启用
|
||||
|
||||
每个生产方通过带默认值的配置,自行决定其 schema 是否暴露 `run_in_background`。`dsh-tool-bash`、`dsh-tool-pty` 和每个 `dsh-tool-subagent` 实例都使用 `enableRunInBackground`,默认值为 true。禁用的实例会省略该参数;由于通用参数校验器允许未声明的键,它还会在执行时拒绝强制传入的后台参数。省略 schema 用于声明能力不可用;执行检查负责强制该约束。
|
||||
|
||||
`ctx.tasks` 不改写生产方 schema。bundle 只转发其所拥有生产方的配置。如果后台调用在没有附加接口的情况下到达 `start()`,运行时防线会在执行前使其失败。
|
||||
`ctx.tasks` 不改写生产方 schema。bundle 只转发其所拥有生产方的配置。如果后台调用在没有附加控制器的情况下到达 `start()`,运行时防线会在执行前使其失败。
|
||||
|
||||
## 生产方集成
|
||||
|
||||
@@ -125,7 +125,7 @@ bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `Bas
|
||||
|
||||
## 测试
|
||||
|
||||
单元覆盖固定预检原子性、按 kind 分配的 id、输出上限的校验与投影、完整结果的 UTF-8 字节上限、流式与最终读取、等待超时与中止竞态、取消、首次结果优先的结算、监听器隔离、通知压制、所有者隔离、陈旧的所有者实例、所有者清理、服务资源销毁和无接口防线。生产方测试覆盖 bash 进程映射、subagent 启动取消、终止映射与释放。快照覆盖固定控制工具 schema 与提示词指导。
|
||||
单元覆盖固定预检原子性、按 kind 分配的 id、输出上限的校验与投影、完整结果的 UTF-8 字节上限、流式与最终读取、等待超时与中止竞态、取消、首次结果优先的结算、监听器隔离、通知压制、所有者隔离、陈旧的所有者实例、所有者清理、服务资源销毁和无控制器防线。生产方测试覆盖 bash 进程映射、subagent 启动取消、终止映射与释放。快照覆盖固定控制工具 schema 与提示词指导。
|
||||
|
||||
## 后果
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md
|
||||
2026-06-21-mandatory-app-attribution-headers.md: 12482ca80d19e5cd1e62b8860865db6cccea61ab
|
||||
2026-06-21-mandatory-app-attribution-headers.zh.md: 43f356887948b88d34ba41d46ed1cd2f1c89a1e2
|
||||
2026-06-21-mandatory-app-attribution-headers.md: 39050a53ec76e8c5a6cac4d8e31fa15b992c406e
|
||||
2026-06-21-mandatory-app-attribution-headers.zh.md: bfd6aa2540022f68cf9f69ccc2c12b3bc0978960
|
||||
|
||||
@@ -32,7 +32,7 @@ The provider-neutral identity is owned by `dsh-llm` (`packages/llm/llm/src/attri
|
||||
|
||||
- product token for `User-Agent`: `deepseek-harness` (continuity with the pre-Agent Note wire value and the repo/org identity)
|
||||
- version: read from the owning package's manifest via `createRequire`, never a hand-copied constant
|
||||
- app URL: `https://github.com/deepseek-ai/deepseek-harness-sdk` - the planned public home, which must exist before release
|
||||
- app URL: `https://github.com/deepseek-ai/deepseek-harness` - the repository home
|
||||
|
||||
The default is mandatory and non-empty. White-label deployments pass their own `AppIdentity` to `attributionHeaders(identity)` - the override hook is the function parameter, with no deployment config plumbing until a consumer needs it - and omission falls back to the harness default rather than suppressing attribution. There is no per-request API for the model, user prompt, session id, cwd, user email, API key owner, or local machine identity to influence these fields.
|
||||
|
||||
@@ -77,8 +77,6 @@ The landed contract:
|
||||
|
||||
**Providers see that traffic comes from the harness.** That is the point, but it means deployments that previously blended into generic SDK traffic become identifiable. Mitigation: send only static public product data and let forks/white-label deployments pass their own `AppIdentity`.
|
||||
|
||||
**The app URL points at a repository that does not exist yet.** `deepseek-ai/deepseek-harness-sdk` is the planned public home; until it is created the URL is a dangling promise that blocks release.
|
||||
|
||||
**Header support differs by client library.** The hand-rolled adapter sets headers directly; the pi-ai-backed adapter depends on pi-ai continuing to honor `StreamOptions.headers` (merged last over provider defaults). The wire-level mock-server tests are the guard: if a pi-ai upgrade stops delivering the header, the suite goes red. This is useful pressure on the abstraction: a provider adapter that cannot set mandatory headers cannot fully implement the harness LLM contract.
|
||||
|
||||
**OpenRouter rankings do not benefit yet.** `User-Agent` is the correct baseline for provider-neutral HTTP identity, but it will not create OpenRouter app pages or rankings because OpenRouter requires `HTTP-Referer` for that product feature. That is deliberate: public app marketplace participation is a separate product decision, not a prerequisite for mandatory request attribution.
|
||||
|
||||
@@ -32,7 +32,7 @@ OpenRouter 应用归属刻意未实现。`HTTP-Referer`、`X-OpenRouter-Title`
|
||||
|
||||
- `User-Agent` 的产品 token:`deepseek-harness`(与 Agent Note 之前的线路值及仓库/组织身份保持连续性)
|
||||
- 版本:通过 `createRequire` 从所属包的 manifest(元数据清单)读取,绝不手动复制常量
|
||||
- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页,且必须在发布前实际存在
|
||||
- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness`——仓库主页
|
||||
|
||||
默认值是强制的且非空。白标部署通过向 `attributionHeaders(identity)` 传入自己的 `AppIdentity` 来覆盖——覆盖钩子就是函数参数,在有消费方需要之前不做部署配置管道——省略时回退到 harness 默认值而非抑制归属。没有逐请求 API 允许模型、用户提示词、会话 id、cwd、用户邮箱、API key 所有者或本地机器身份影响这些字段。
|
||||
|
||||
@@ -77,8 +77,6 @@ OpenRouter 应用归属刻意未实现。`HTTP-Referer`、`X-OpenRouter-Title`
|
||||
|
||||
**提供方看到流量来自 harness。** 这正是目的,但意味着此前混在通用 SDK 流量中的部署变得可识别。缓解措施:仅发送静态公开产品数据,并允许 fork/白标部署传入自己的 `AppIdentity`。
|
||||
|
||||
**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在它创建之前,该 URL 是一个阻塞发布的悬空承诺。
|
||||
|
||||
**不同客户端库的头部支持有差异。** 手写适配器直接设置头部;基于 pi-ai 的适配器依赖 pi-ai 继续尊重 `StreamOptions.headers`(最后合并覆盖提供方默认值)。线路级 mock 服务器测试是守卫:如果 pi-ai 升级后不再投递该头部,套件会变红。这对抽象施加了有益的压力:一个无法设置强制头部的提供方适配器不能完整实现 harness 的 LLM 约定。
|
||||
|
||||
**OpenRouter 排名尚未受益。** `User-Agent` 是提供方无关的 HTTP 身份的正确基线,但它不会创建 OpenRouter 应用页面或排名,因为 OpenRouter 要求 `HTTP-Referer` 来实现该产品功能。这是有意为之:公开应用市场参与是一个独立的产品决策,不是强制请求归属的前提。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md
|
||||
2026-06-24-web-capability-seam.md: ccf04420425555055ce3810d25bf764db17322bb
|
||||
2026-06-24-web-capability-seam.md: 5df68ea1f0c32491f48d6da98a8fbce0e658e102
|
||||
2026-06-24-web-capability-seam.zh.md: 73f7d0d9b82c03d41bdc768f22b22316e1bc907f
|
||||
|
||||
@@ -8,7 +8,7 @@ English | [中文](2026-06-24-web-capability-seam.zh.md)
|
||||
|
||||
The harness needs model-facing web tools without binding the model contract to one vendor's API shape. Search is the immediate pressure point: supporting both Exa search and Perplexity search from the start — two deliberately different provider shapes (Exa returns a flat `results[]` of `{title, url, highlights, publishedDate}`; Perplexity returns a generated answer plus citations) — is what proves the normalized web contract does not just mirror one vendor. Fetch is a separate operation: an anonymous public HTTP(S) fetch backend has transport, security, redirect, decoding, and size-limit concerns that are not the same as provider-backed search.
|
||||
|
||||
The model-facing surface must stay stable while backends change. A search provider swap should not change how the model asks for a query, and a fetch implementation swap should not change how the model asks for a URL. Conversely, a provider package should not expose its own model-facing tool schema just because it has extra provider-specific knobs.
|
||||
The model-facing API must stay stable while backends change. A search provider swap should not change how the model asks for a query, and a fetch implementation swap should not change how the model asks for a URL. Conversely, a provider package should not expose its own model-facing tool schema just because it has extra provider-specific knobs.
|
||||
|
||||
Putting search and fetch directly in `dsh-tool-web` would make the model-facing tool own provider selection, backend request mapping, transport policy, result normalization, prompt guidance, presentation, and schema registration at once. Letting each provider register its own tool has the opposite problem: tool availability, names, descriptions, and parameters would depend on whichever provider packages happen to load, and provider-specific fields would leak into the model contract.
|
||||
|
||||
@@ -74,7 +74,7 @@ Provider packages depend only on `dsh-web` and Cordis. They own credentials, end
|
||||
|
||||
## `ctx.web` contract
|
||||
|
||||
`ctx.web` is a provider registry plus a provider-selecting execution surface. The registry half stays close to `LlmService`: a `Map<id, provider>` per capability kind, `registerSearchProvider` / `registerFetchProvider` methods that return disposers, duplicate ids that throw `WebError`, and execution-time resolution that throws when the selected provider is absent or unusable. The authoritative signatures live in `packages/web/web/src/types.ts`; the seam's shape:
|
||||
`ctx.web` is a provider registry plus a provider-selecting execution API. The registry half stays close to `LlmService`: a `Map<id, provider>` per capability kind, `registerSearchProvider` / `registerFetchProvider` methods that return disposers, duplicate ids that throw `WebError`, and execution-time resolution that throws when the selected provider is absent or unusable. The authoritative signatures live in `packages/web/web/src/types.ts`; the seam's shape:
|
||||
|
||||
```ts
|
||||
import type { WebFetchRequest, WebFetchResult, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web'
|
||||
@@ -191,7 +191,7 @@ interface WebSearchSource {
|
||||
}
|
||||
```
|
||||
|
||||
`content` is optional provider-generated answer text, search context, or summary. `sources[]` is the portable citation surface. A source always has a URL; title, snippet, and `publishedAt` are optional because not every provider returns them. `title` is not required: Perplexity-style citations may provide only URLs, and forcing adapters to invent titles would make the seam lie. `dsh-tool-web` renders a `title ?? hostname(url)`-style fallback label for display. `publishedAt` is an optional publication/crawl timestamp as an ISO-8601 string — Exa returns it as `publishedDate` on each result and Perplexity returns a `date` on search results, so it is real provider data, not derived; the seam carries it as a string and leaves date parsing to the consumer.
|
||||
`content` is optional provider-generated answer text, search context, or summary. `sources[]` is the portable citation shape. A source always has a URL; title, snippet, and `publishedAt` are optional because not every provider returns them. `title` is not required: Perplexity-style citations may provide only URLs, and forcing adapters to invent titles would make the seam lie. `dsh-tool-web` renders a `title ?? hostname(url)`-style fallback label for display. `publishedAt` is an optional publication/crawl timestamp as an ISO-8601 string — Exa returns it as `publishedDate` on each result and Perplexity returns a `date` on search results, so it is real provider data, not derived; the seam carries it as a string and leaves date parsing to the consumer.
|
||||
|
||||
Exa search maps each entry of the provider's flat `results[]` into a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first `highlights[]` entry (an entry with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. Exa returns no provider-generated answer, so `content` is omitted. Perplexity search maps `choices[0].message.content` to `content` and prefers the structured top-level `search_results[]` for `sources[]` — `url` ← `url`, `title` ← `title`, `snippet` ← `snippet` (often empty), `publishedAt` ← `date` — falling back to the URL-only `citations[]` array only when `search_results` is absent (those sources carry just a `url`). If a provider returns fewer structured fields than the seam supports, the adapter omits those optional fields.
|
||||
|
||||
@@ -294,7 +294,7 @@ This resembles OpenCode's local web search: one stable `websearch` tool dispatch
|
||||
|
||||
### Split search and fetch into two seams (`dsh-search`, `dsh-fetch`)
|
||||
|
||||
Tempting because the two halves share no request schema and no business logic, so each would map cleanly onto the bash/fs three-package template, and the `Search`/`Fetch` method-pair duplication on `WebService` would disappear. Rejected because the shared machinery — provider-id registry, registration-order-independent selection policy, abort propagation, the `WebError` taxonomy, and the product-facing "how this harness reaches the web" config surface — is real and would otherwise be duplicated across two near-identical seams. One `ctx.web` middle layer gives the product a single thing to inject and configure and gives provider selection one owner. The price is the parallel `searchX`/`fetchX` method pairs, which is accepted deliberately.
|
||||
Tempting because the two halves share no request schema and no business logic, so each would map cleanly onto the bash/fs three-package template, and the `Search`/`Fetch` method-pair duplication on `WebService` would disappear. Rejected because the shared machinery — provider-id registry, registration-order-independent selection policy, abort propagation, the `WebError` taxonomy, and the product-facing "how this harness reaches the web" configuration API — is real and would otherwise be duplicated across two near-identical seams. One `ctx.web` middle layer gives the product a single thing to inject and configure and gives provider selection one owner. The price is the parallel `searchX`/`fetchX` method pairs, which is accepted deliberately.
|
||||
|
||||
### Choose the first registered provider
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md
|
||||
2026-06-26-file-context-as-event-gate.md: 743c8f150a83f2452cb0ff672860b8462a56f762
|
||||
2026-06-26-file-context-as-event-gate.md: 5f61201c8129b74233222bdc71eaf20443794760
|
||||
2026-06-26-file-context-as-event-gate.zh.md: cd79b44dd3f3b75c5e5addc5be10c02bd1ce0151
|
||||
|
||||
@@ -138,7 +138,7 @@ The tool passes `exec` (the tool-execution context) as the `actor` argument on e
|
||||
|
||||
An observed-state entry is the **prior-observation record**, but its discriminant matters. Successful read/write/edit records present at a version, allowing create-then-edit or edit-then-edit without an intervening read. A read/view that confirms absence replaces any old positive version with absent, allowing only a guarded create; a later successful create replaces it with the new present version. Missing entry alone means unseen and produces `FS_NOT_OBSERVED` for edit. The owner is derived structurally from `{ agent?: { session? } }`; disposal drops all state (HMR safety).
|
||||
|
||||
`dsh-fs-policy` is now a pure policy/recording plugin with no service surface — it influences the world only through the event gate. That is what removes the method coupling from `dsh-tool-fs`.
|
||||
`dsh-fs-policy` is now a pure policy/recording plugin with no service API — it influences the world only through the event gate. That is what removes the method coupling from `dsh-tool-fs`.
|
||||
|
||||
## Bare-provider behavior (no `dsh-fs-policy`)
|
||||
|
||||
|
||||
@@ -1,6 +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-06-30-bash-stdin-env-trusted-plugin-surface.md
|
||||
2026-06-30-bash-stdin-env-trusted-plugin-surface.md: d8193b47ff16da7efb5f6bf1c3f34c0e1251572c
|
||||
2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: 7f5bb62d92f27c49b0f7f262dc64dce39c89f2cd
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md
|
||||
2026-06-30-bash-stdin-env-trusted-plugin-api.md: 2087b2f7a9682ad5f554d4a7a2f521485d164432
|
||||
2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md: d3ed6a86c7f2e356f50a918dddf3fe47ea7ae820
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md)
|
||||
English | [中文](2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -30,4 +30,4 @@ Three deliberate choices:
|
||||
|
||||
## Consequences
|
||||
|
||||
Hook bridges pass JSON payloads and hook-specific variables through the existing bash seam, retaining its process-group, truncation, and spill behavior. The model surface remains unchanged, and the bash tool remains the sole owner of model-call request construction. The vocabulary lives in [the bash data-structure reference](../../../../docs/subsystems/bash.md).
|
||||
Hook bridges pass JSON payloads and hook-specific variables through the existing bash seam, retaining its process-group, truncation, and spill behavior. The model-facing behavior remains unchanged, and the bash tool remains the sole owner of model-call request construction. The vocabulary lives in [the bash data-structure reference](../../../../docs/subsystems/bash.md).
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-06-30-bash-stdin-env-trusted-plugin-surface.md) | 中文
|
||||
[English](2026-06-30-bash-stdin-env-trusted-plugin-api.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md
|
||||
2026-06-30-event-domain-semantics.md: dbb3ef49979b7eeaa9a43b7db4a2d5a5839b5569
|
||||
2026-06-30-event-domain-semantics.md: 70da718b5471ce309a090c8aade3e7290cc949dc
|
||||
2026-06-30-event-domain-semantics.zh.md: 93fe14adeaa47d276219a316ff1250a9982f5f14
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Agent Note: Event-domain semantics — session is the fact log, agent is the live surface
|
||||
# Agent Note: Event-domain semantics — session is the fact log, agent is the live event channel
|
||||
|
||||
Status: implemented
|
||||
|
||||
|
||||
@@ -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-05-reconstructable-requests.md
|
||||
2026-07-05-reconstructable-requests.md: c13e40b5b22e5d6fe19f6b5bd137db3a48350390
|
||||
2026-07-05-reconstructable-requests.zh.md: 746bcd37dabada949873d394776c1ca43eff4ea2
|
||||
2026-07-05-reconstructable-requests.md: e78284964ca85905524d3a0800b2de46c6964094
|
||||
2026-07-05-reconstructable-requests.zh.md: c41e5f89558a9c7b09ccd2bf4fd5b7d9a6cde419
|
||||
|
||||
@@ -14,7 +14,7 @@ The reference shape for the happy path is MiniCode's `LLMClient`: a stateful con
|
||||
|
||||
### The principle
|
||||
|
||||
**Model-visible ⟺ logged.** Anything that reaches a model request must be recorded in the session log. The checkable consequence: **every conversation request the loop sends is a pure function of the session log** — anyone holding the log reconstructs it byte-for-byte. Scope, stated precisely: the guarantee covers the loop-built `GenerateOptions`; provider wire bytes follow from it because both adapters' serialization is a pure per-message function at a pinned code version; direct one-shots (compaction's summarize call) log their envelope scalars (`compact/summary.{provider, model, maxTokens}`) and their input is deterministic code over the logged region — reconstructable from log + code, outside the invariant because only the loop marks request ownership.
|
||||
**Model-visible ⟺ durably referenced.** Anything that reaches a model request must be reconstructable from the session log and the immutable content-addressed objects it references. The checkable consequence: anyone holding the log, its referenced attachment objects, and the pinned code version reconstructs every loop request byte-for-byte. Text-only `GenerateOptions` remain a pure function of the log; image-bearing requests additionally resolve `ImageAttachmentRef` bytes through `ctx.attachments` during adapter serialization, where digest and recorded metadata verification make the object lookup deterministic and fail loud on missing or corrupt data. Direct one-shots (compaction's summarize call) log their envelope scalars (`compact/summary.{provider, model, maxTokens}`), and their input is deterministic code over the logged region plus those referenced objects — outside the invariant because only the loop marks request ownership.
|
||||
|
||||
Prefix-cache stability is corollary #1, not the headline: an append-only log projected by a per-node pure function yields requests that are append-extensions of their predecessors whenever the header is unchanged — stability is emergent, not managed. Byte-exact audit/replay is corollary #2; resume and fork with *attributable* drift is corollary #3.
|
||||
|
||||
@@ -37,7 +37,7 @@ Like MiniCode, the conversation advances append-only and resets only when model-
|
||||
## Alternatives considered
|
||||
|
||||
- **Client as source of truth** (literal MiniCode): a second operative truth beside the log — the two drift and nothing notices; see the section above.
|
||||
- **A stateful transmission client mirroring the log** — duplicates conversation state, needs rollback around listeners, leaves an unlogged edit surface, and still cannot reconstruct request headers. Session-owned caches plus logged headers avoid those split truths.
|
||||
- **A stateful transmission client mirroring the log** — duplicates conversation state, needs rollback around listeners, leaves an unlogged edit path, and still cannot reconstruct request headers. Session-owned caches plus logged headers avoid those split truths.
|
||||
- **Per-call request scalars** (a freely mutable config handed to each `agent/request` dispatch): a listener flips the model per call with zero accounting, silently abandoning the provider cache this design exists to protect. Config is per-conversation logged state; the waterfall proposes, the log records.
|
||||
- **Detect-and-report** (compare consecutive requests, warn on divergence): catches violations after the fact; a violating request is still constructible and ships. Rejected for interface-level unrepresentability.
|
||||
- **Event-driven assembly** (re-render only on change signals): a missed-signal bug class — a tool registered mid-session emits `tools/change`, not `system-prompt/change`, and a third-party provider may emit nothing. Per-step render + value compare is robust with zero signal discipline.
|
||||
|
||||
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
### 原则
|
||||
|
||||
**模型可见 ⟺ 已记录。** 凡到达模型请求的内容都必须记录在会话日志中。可检查的推论:**循环发出的每个对话请求都是会话日志的纯函数**——任何人持有日志即可逐字节重建请求。精确的范围声明:该保证覆盖循环构建的 `GenerateOptions`;提供方协议格式(wire format)字节由此推导而来,因为两个适配器的序列化在固定代码版本下都是逐消息的纯函数;直接的一次性调用(压缩的 summarize 调用)记录其信封标量(`compact/summary.{provider, model, maxTokens}`),其输入是对日志区域的确定性代码运算——可从日志加代码重建,因为只有循环会标记请求归属,所以它们不在不变式内。
|
||||
**模型可见 ⟺ 已持久引用。** 凡到达模型请求的内容都必须能从会话日志及其引用的不可变内容寻址对象中重建。可检查的推论:任何人持有日志、日志引用的附件对象和固定代码版本,即可逐字节重建循环的每个请求。纯文本 `GenerateOptions` 仍是日志的纯函数;含图片请求还会在适配器序列化期间通过 `ctx.attachments` 解析 `ImageAttachmentRef` 字节,其中对内容摘要及已记录元数据的校验使对象查找具有确定性,并在数据缺失或损坏时明确失败。直接的一次性调用(压缩的 summarize 调用)记录其信封标量(`compact/summary.{provider, model, maxTokens}`),其输入是对日志区域及这些引用对象的确定性代码运算——由于只有循环会标记请求归属,因此它们不在不变式内。
|
||||
|
||||
前缀缓存稳定性是推论 #1,而非标题:一个仅追加的日志经逐节点纯函数投影,在 header 不变时自然产出前一请求的追加扩展——稳定性是涌现的,不是管理出来的。字节精确的审计/回放是推论 #2;带*可归因*漂移的恢复与 fork 是推论 #3。
|
||||
|
||||
|
||||
@@ -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-05-windows-jsonl-durable-publish.md
|
||||
2026-07-05-windows-jsonl-durable-publish.md: 38c4adc7a4f85d45e53e70fcac84073ab4e50775
|
||||
2026-07-05-windows-jsonl-durable-publish.md: 546cde6086f3c84e22b4d4de144a48bb3425cd4e
|
||||
2026-07-05-windows-jsonl-durable-publish.zh.md: 205460bcd374bd351cfdf541eb4041c09461229d
|
||||
|
||||
@@ -16,7 +16,7 @@ The JSONL backend forks inside `materialize()` before any namespace mutation. Sh
|
||||
|
||||
POSIX keeps the existing protocol: create the root, project directory, and session directory with parent directory fsyncs, write and fsync a temp file, publish with `link()` so an existing final log is never overwritten, fsync the session directory, then remove the redundant temp hard link.
|
||||
|
||||
Windows creates missing directories through a durable staging publish: create a random sibling directory under the constant `.dsh-mkdir-` prefix, independent of the target basename, then publish it to the final directory name with `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` without `MOVEFILE_REPLACE_EXISTING` or `MOVEFILE_COPY_ALLOWED`. File materialization writes and fsyncs the temp log, then publishes that temp file to the final path with the same write-through `MoveFileExW` call and no replacement. `koffi` is the minimal Win32 bridge for this API surface; its install script is allowed in `pnpm-workspace.yaml` because the package ships the native loader and prebuilt platform modules.
|
||||
Windows creates missing directories through a durable staging publish: create a random sibling directory under the constant `.dsh-mkdir-` prefix, independent of the target basename, then publish it to the final directory name with `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` without `MOVEFILE_REPLACE_EXISTING` or `MOVEFILE_COPY_ALLOWED`. File materialization writes and fsyncs the temp log, then publishes that temp file to the final path with the same write-through `MoveFileExW` call and no replacement. `koffi` is the minimal Win32 bridge for this API; its install script is allowed in `pnpm-workspace.yaml` because the package ships the native loader and prebuilt platform modules.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -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-06-timeout-deadline-library.md
|
||||
2026-07-06-timeout-deadline-library.md: b7c7ac07ce0acec819fa7e88b32b3b6d624f3e6e
|
||||
2026-07-06-timeout-deadline-library.md: 7b252052c27fbf9f10716bd874a371b102abb614
|
||||
2026-07-06-timeout-deadline-library.zh.md: e4ec6b9a9e07b55faf9f44f2b99b765620e626c4
|
||||
|
||||
@@ -18,7 +18,7 @@ Each new external-process or network tool re-derived the same four things — cl
|
||||
|
||||
`@deepseek-ai/dsh-timeout` lives under `packages/util/` (peer to `dsh-brand`) and owns the *timing and classification* half of timeout; the *termination* half — the hard kill — stays in each capability's implementation. It is a library of pure functions, **not** a cordis service or plugin: it takes no `ctx`, registers nothing, holds no cross-call state, and emits no events. There is deliberately no central "timeout service" that would have to know how to stop every capability's work — that knowledge is exactly what a microkernel keeps out of shared layers, and what Codex's exec-only `ExecExpiration` scope demonstrates.
|
||||
|
||||
### The library surface
|
||||
### The library API
|
||||
|
||||
Four functions, one watchdog interface, and one reason type:
|
||||
|
||||
|
||||
@@ -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-06-tool-result-retention-library.md
|
||||
2026-07-06-tool-result-retention-library.md: 5e42660360e5a23b419c75b9c8006bec459bc322
|
||||
2026-07-06-tool-result-retention-library.md: 1736d2dad98cbb8b67d570ce25742a84ea0ede59
|
||||
2026-07-06-tool-result-retention-library.zh.md: 2a523e2eff36daf9100ac10bf5ae569eca30f830
|
||||
|
||||
@@ -142,7 +142,7 @@ The formatter hook is deliberately small: a tool turns a `RetentionNotice` into
|
||||
|
||||
**Boundaries the library holds.** `truncated` means the retainer omitted otherwise-available content because of a budget; it never means the upstream was incomplete. Tool-specific states — `incomplete`, permission failures, provider partial failures, binary skips, bash spill-path recovery, invalid UTF-8 — stay in tool-domain fields, outside the retainer. When a future change migrates a tool, that package's README and tests must prove the model-facing result text is unchanged except for deliberate notice wording.
|
||||
|
||||
**Tradeoffs accepted.** The v1 surface deliberately supports only item `head` retention and text `head` / `tail` / `headTail`; windows, grouped budgets, sort-aware caps, and upstream-stop control wait until a second consumer proves the need. Text retention counts bytes for process/body safety, leaving character- and line-level preview budgets as separate tool-owned concerns.
|
||||
**Tradeoffs accepted.** The v1 API deliberately supports only item `head` retention and text `head` / `tail` / `headTail`; windows, grouped budgets, sort-aware caps, and upstream-stop control wait until a second consumer proves the need. Text retention counts bytes for process/body safety, leaving character- and line-level preview budgets as separate tool-owned concerns.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -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-08-tool-output-spill-files.md
|
||||
2026-07-08-tool-output-spill-files.md: 0c8e5e25fc8a229db8fca36512ba781e548b5256
|
||||
2026-07-08-tool-output-spill-files.md: 3c24bc9ed754726b70e833627a22167c8256162c
|
||||
2026-07-08-tool-output-spill-files.zh.md: 771253335bf5822cb855c63e7dd0bbf0d517bab3
|
||||
|
||||
@@ -10,7 +10,7 @@ Tool outputs need bounded model-facing previews, but some oversized results are
|
||||
|
||||
Before this change the behavior was uneven. `dsh-bash-local` already writes complete stdout/stderr streams to private temp spill files when its in-memory tail overflows, but ordinary text tool results were returned inline unless the tool hand-rolled its own cap. The [tool result retention library](2026-07-06-tool-result-retention-library.md) owns preview mechanics, but it does not own storage or an execution-pipeline policy that applies those mechanics to final tool results.
|
||||
|
||||
The shape matches the timeout policy design: a tool author declares a canonical value plus Native renderer, and a policy plugin enforces the deployment's default context budget on rendered content. Tool-specific early spill remains possible for provider acquisition bounds; tool-owned surface spill may retain a complete acquired canonical value while replacing only presentation. The [canonical tool-output contract](2026-07-20-canonical-tool-output-contract.md) owns that split.
|
||||
The shape matches the timeout policy design: a tool author declares a canonical value plus Native renderer, and a policy plugin enforces the deployment's default context budget on rendered content. Tool-specific early spill remains possible for provider acquisition bounds; tool-owned presentation spill may retain a complete acquired canonical value while replacing only presentation. The [canonical tool-output contract](2026-07-20-canonical-tool-output-contract.md) owns that split.
|
||||
|
||||
## Decision
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.md: a45678c9bb5fcae340ff7134687890879f56c630
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: f1fccc508471356dd6434da0e126ed38f15ed3ba
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.md: 01081f1e0b8027420fedbc599f99c67e67a4639b
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: fadfc8cdcb0651665b965126ab0b25ed4218a533
|
||||
|
||||
@@ -6,7 +6,7 @@ English | [中文](2026-07-10-single-file-executable-sdk-runtime-distribution.zh
|
||||
|
||||
## Problem
|
||||
|
||||
DeepSeek Harness needs a dedicated SDK distribution form for the Python library — no Node installation, runs directly on the target platform: a single-file executable (hereafter "the exe") that exposes a stdio JSON-RPC serving surface (`HarnessSdkServer`, the Python SDK's peer), where the plugins and configuration actually booted are decided entirely by a `cordis.yml` supplied from outside the exe.
|
||||
DeepSeek Harness needs a dedicated SDK distribution form for the Python library — no Node installation, runs directly on the target platform: a single-file executable (hereafter "the exe") that exposes a stdio JSON-RPC serving interface (`HarnessSdkServer`, the Python SDK's peer), where the plugins and configuration actually booted are decided entirely by a `cordis.yml` supplied from outside the exe.
|
||||
|
||||
- The JSONRPC protocol for talking to the Python SDK is already validated
|
||||
- A standardized way for cordis.yml to load every plugin (ESModule) is needed
|
||||
@@ -23,38 +23,38 @@ The exe is packaged with the **`--sea` (enhanced SEA) mode** of [@yao-pkg/pkg](h
|
||||
|
||||
Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's testing-system "snapshot" (ACP replay expected outputs, `$DSH_SNAPSHOT`); this document says "VFS" for the former.
|
||||
|
||||
### The serving surface is a plugin: the two packages ui/jsonrpc + examples/jsonrpc-demo
|
||||
### The serving interface is a plugin: the two packages sdk/server + examples/jsonrpc-demo
|
||||
|
||||
The deterministic protocol implementation (`server.ts` / `transport.ts`) lands as two packages on the existing `acp/acp` + `examples/acp-demo` pattern — the serving surface is itself a plugin:
|
||||
|
||||
- [`packages/scaffold/server`](../../../../packages/scaffold/server/README.md) (`@deepseek-ai/dsh-jsonrpc`): the pure protocol plugin; on apply it mounts `HarnessSdkServer` plus a line-delimited JSON-RPC transport on the process stdio, with disposal through `ctx.effect()`. Whether to serve is decided by `cordis.yml`; a yml that does not mount it is a legitimate process that does not serve. Protocol-level exit belongs to the plugin (after answering and flushing the `shutdown` response it disposes the root runtime so persistence drains, then `exit(0)`; an HMR-style unload only stops the service without exiting the process).
|
||||
- [`packages/sdk/server`](../../../../packages/sdk/server/README.md) (`@deepseek-ai/dsh-jsonrpc`): the pure protocol plugin; on apply it mounts `HarnessSdkServer` plus a line-delimited JSON-RPC transport on the process stdio, with disposal through `ctx.effect()`. Whether to serve is decided by `cordis.yml`; a yml that does not mount it is a legitimate process that does not serve. Protocol-level exit belongs to the plugin (after answering and flushing the `shutdown` response it disposes the root runtime so persistence drains, then `exit(0)`; an HMR-style unload only stops the service without exiting the process).
|
||||
- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md) (`@deepseek-ai/dsh-jsonrpc-demo`): a thin app bin — `installFailLoud` + `loadEnv` + config discovery + `boot()` from [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts), done once boot completes; the server is brought up by the `dsh-jsonrpc` entry in the yml. Its only dependency is app-boot. Process-level exit belongs to the bin (stdin EOF/SIGTERM → dispose then 0, SIGINT → 130).
|
||||
|
||||
Config discovery has two channels and fails loudly when both are missing: the `DSH_CORDIS_CONFIG` environment variable first (the SDK client convention), then an argv positional argument; no default path and no built-in fallback whatsoever — "the plugins actually booted are decided by an external cordis.yml" is a hard semantic.
|
||||
|
||||
### Plugin resolution: the VFS holds a real package tree, the closure manifest IS the deploy root
|
||||
|
||||
Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`); the Loader resolves plugin names through standard dynamic `import()`: bare specifiers resolve upward along `node_modules` from the Loader's position inside the VFS, and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails.
|
||||
Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`). The packaged JSON-RPC entry supplies its installed harness base to app-boot's root Include: relative plugin specifiers resolve from the external configuration directory, while bare package names resolve from the VFS, so a configuration inside another Node project cannot shadow the packaged plugin set. The ordinary development bin leaves bare packages configuration-owned. Bare specifiers in the packaged entry resolve upward along `node_modules` from the entry's position inside the VFS and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails.
|
||||
|
||||
The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) traverses every workspace package covered by that manifest and requires every non-optional workspace peer at the runtime root, reporting the complete referencing-package → missing-peer chain; `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`.
|
||||
|
||||
### Build pipeline and artifacts
|
||||
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg-<platform>-<arch>` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source, so the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory; macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink file tree (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources.
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg-<platform>-<arch>` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source, so the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory; macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry.
|
||||
|
||||
CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly only — `workflow_dispatch`, or the `build-exe` label on a pull request; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached; macOS ad-hoc signing is handled by pkg. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects GLIBC requirements and runs in a manylinux 2.28 container. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts only `python-vX.Y.Z` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal.
|
||||
|
||||
### Python SDK distribution: two carriers, exe for production, node for development
|
||||
|
||||
The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe and optional helper, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions.
|
||||
The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe and optional helper, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions.
|
||||
|
||||
[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative stable `X.Y.Z` from the repository root `package.json` and stages both packages at that version, with the SDK depending exactly on `deepseek-harness-runtime-bin==X.Y.Z`. An optional `python-vX.Y.Z` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, and the macOS wheel also contains its architecture-matched helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or `py3-none-macosx_11_0_arm64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra helpers, and unsupported platforms.
|
||||
[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative stable `X.Y.Z` from the repository root `package.json` and stages both packages at that version, with `deepseek-harness-sdk` depending exactly on `deepseek-harness-runtime-bin==X.Y.Z`. An optional `python-vX.Y.Z` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, and the macOS wheel also contains its architecture-matched helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or `py3-none-macosx_11_0_arm64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra helpers, and unsupported platforms.
|
||||
|
||||
The exe's "must be explicitly configured" hard semantic is unchanged; the zero-config experience is restored by the wrapper: when the caller gave no `cordis`, named no explicit runtime, and the environment has no `DSH_CORDIS_CONFIG`, the client explicitly injects the checked-in default `cordis.yml` (agent-core + preloaded llm-deepseek + JSONL persistence + bash-local + the `dsh-jsonrpc` serving entry, with `!!js` environment-variable fallbacks) via `DSH_CORDIS_CONFIG`.
|
||||
|
||||
### Naming lineage
|
||||
|
||||
`@deepseek-ai/dsh-jsonrpc-demo` (the package) → `dsh-jsonrpc-agent` (the bin) → `dsh-jsonrpc-agent-pkg` (the closure manifest; no scope prefix, deliberately sidestepping the constraints' package-shape rules for `@deepseek-ai/dsh-*`) → `dsh-jsonrpc-agent-pkg-<platform>-<arch>` (the exe artifacts). The wire `serverInfo.name` stays `deepseek-harness-sdk-runtime` (a protocol-stable value); the Python dist names are `deepseek-harness` / `deepseek-harness-runtime-bin`.
|
||||
`@deepseek-ai/dsh-jsonrpc-demo` (the package) → `dsh-jsonrpc-agent` (the bin) → `dsh-jsonrpc-agent-pkg` (the closure manifest; no scope prefix, deliberately sidestepping the constraints' package-shape rules for `@deepseek-ai/dsh-*`) → `dsh-jsonrpc-agent-pkg-<platform>-<arch>` (the exe artifacts). The wire `serverInfo.name` stays `deepseek-harness-sdk-runtime` (a protocol-stable value); the Python distribution names are `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`, while the import modules remain `deepseek_harness` / `deepseek_harness_runtime`.
|
||||
|
||||
## Disposition of worker-style plugins
|
||||
|
||||
@@ -62,7 +62,7 @@ The exe's "must be explicitly configured" hard semantic is unchanged; the zero-c
|
||||
|
||||
## Testing
|
||||
|
||||
The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, and the direct binary protocol, with final text and JSONL checked. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message IDs in the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`.
|
||||
The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message IDs in the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`.
|
||||
|
||||
Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends.
|
||||
|
||||
@@ -80,6 +80,6 @@ Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disp
|
||||
|
||||
## Consequences
|
||||
|
||||
**Bought**: zero-dependency single-file distribution on target platforms; plugin semantics strictly identical to running from source (the same real package tree, no transpilation, no registry); the serving surface, the plugin set, and the configuration all converge on two sources of truth — `cordis.yml` plus one dependency manifest; the exe and node carriers share one tree and one semantics, so development verification never waits for packaging; official Node binaries remove the patched-binary supply-chain concern.
|
||||
**Bought**: zero-dependency single-file distribution on target platforms; plugin semantics strictly identical to running from source (the same real package tree, no transpilation, no registry); the serving interface, the plugin set, and the configuration all converge on two sources of truth — `cordis.yml` plus one dependency manifest; the exe and node carriers share one tree and one semantics, so development verification never waits for packaging; official Node binaries remove the patched-binary supply-chain concern.
|
||||
|
||||
**Paid**: artifacts on the order of 174MB with source entering the blob as-is (no bytecode obfuscation; a closed-source distribution requirement needs a separate evaluation); pkg's VFS/module-hook layer remains community-maintained (the build script pins `@yao-pkg/pkg@6.21.0`; upgrading is an explicit change); `--sea` is one invocation per target (matching CI's one leg per platform; local multi-platform builds are serial).
|
||||
|
||||
@@ -23,38 +23,38 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后
|
||||
|
||||
术语提醒:pkg 的 `/snapshot` VFS 与本仓库测试体系的「快照」(ACP 回放预期输出、`$DSH_SNAPSHOT`)无关,本文用「VFS」指前者。
|
||||
|
||||
### 对外服务接口也是插件:ui/jsonrpc + examples/jsonrpc-demo 两个包
|
||||
### 对外服务接口也是插件:sdk/server + examples/jsonrpc-demo 两个包
|
||||
|
||||
确定性协议实现(`server.ts` / `transport.ts`)按 `acp/acp` + `examples/acp-demo` 的既有模式落为两包——对外服务接口本身也是插件:
|
||||
|
||||
- [`packages/scaffold/server`](../../../../packages/scaffold/server/README.md)(`@deepseek-ai/dsh-jsonrpc`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkServer` 与按行传输的 JSON-RPC 层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答并确保 `shutdown` 响应发送完毕后,对根运行时执行 dispose(资源释放),让待处理的持久化操作完成,再调用 `exit(0)`;HMR 式卸载只停止服务,不退出进程)。
|
||||
- [`packages/sdk/server`](../../../../packages/sdk/server/README.md)(`@deepseek-ai/dsh-jsonrpc`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkServer` 与按行传输的 JSON-RPC 层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答并确保 `shutdown` 响应发送完毕后,对根运行时执行 dispose(资源释放),让待处理的持久化操作完成,再调用 `exit(0)`;HMR 式卸载只停止服务,不退出进程)。
|
||||
- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md)(`@deepseek-ai/dsh-jsonrpc-demo`):轻量应用入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-jsonrpc` 条目启动。它只依赖 `app-boot`。进程级退出归 `bin` 所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。
|
||||
|
||||
配置发现有两个通道,均缺失时立即报错:优先使用 `DSH_CORDIS_CONFIG` 环境变量(SDK 客户端约定),其次使用 argv 位置参数;没有默认路径或内置回退——「实际启动的插件由外部 `cordis.yml` 决定」是硬语义。
|
||||
|
||||
### 插件解析:VFS 装载真实包树,闭包 manifest(元数据清单)就是部署根目录
|
||||
|
||||
exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。loader 通过标准动态 `import()` 解析插件名:裸包名从 VFS 内 loader 所在位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。
|
||||
exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。打包专用 JSON-RPC 入口会向 app-boot 的根 Include 提供自身已安装 harness 的基准位置:相对插件说明符从外部配置目录解析,裸包名则从 VFS 解析,因此位于另一个 Node 项目内的配置无法遮蔽已打包的插件集合。普通开发 bin 仍由配置项目提供裸包。打包入口中的裸包名从该入口在 VFS 内的位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。
|
||||
|
||||
部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列在运行时根目录,并报告“引用包 → 缺失对等依赖”的完整链路;`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。
|
||||
|
||||
### 构建管线与产物
|
||||
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg-<platform>-<arch>` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`,而 `--legacy` 部署会省略该副作用目录,因此构建器会把它从根安装目录复制到暂存闭包;macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 产出无符号链接的文件树(对 pkg VFS 最稳定,并从物理上保证只有一个 Cordis 实例);关闭对等依赖自动安装可避免未发布包名触发注册表解析;`link-workspace-packages` 让闭包指向工作区/vendor 源码。
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg-<platform>-<arch>` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`,而 `--legacy` 部署会省略该副作用目录,因此构建器会把它从根安装目录复制到暂存闭包;macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。
|
||||
|
||||
CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),且只允许显式触发:手动派发 `workflow_dispatch`,或给 PR(Pull Request)添加 `build-exe` 标签。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查 GLIBC 依赖,并在 manylinux 2.28 容器中运行。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-vX.Y.Z` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。
|
||||
|
||||
### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发
|
||||
|
||||
Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 与可选 helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。
|
||||
Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 与可选 helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。
|
||||
|
||||
[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的稳定版本 `X.Y.Z`,以该版本暂存两个包,并让 SDK 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。可选的 `python-vX.Y.Z` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe,macOS wheel 包还包含与其架构匹配的 helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64` 三种标签之一;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、helper 缺失或多余,以及不支持的平台。
|
||||
[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的稳定版本 `X.Y.Z`,以该版本暂存两个包,并让 `deepseek-harness-sdk` 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。可选的 `python-vX.Y.Z` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe,macOS wheel 包还包含与其架构匹配的 helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64` 三种标签之一;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、helper 缺失或多余,以及不支持的平台。
|
||||
|
||||
exe「必须显式配置」的硬语义不变;零配置体验由包装层恢复:调用方没有提供 `cordis`、没有显式指定运行时,且环境中没有 `DSH_CORDIS_CONFIG` 时,客户端将检入的默认 `cordis.yml`(`agent-core` + 预载的 `llm-deepseek` + JSONL 持久化 + `bash-local` + `dsh-jsonrpc` 对外服务条目,并通过 `!!js` 使用环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。
|
||||
|
||||
### 命名血统
|
||||
|
||||
`@deepseek-ai/dsh-jsonrpc-demo`(包)→ `dsh-jsonrpc-agent`(`bin`)→ `dsh-jsonrpc-agent-pkg`(闭包 manifest;没有作用域前缀,刻意避开 `constraints` 对 `@deepseek-ai/dsh-*` 的包形状规则)→ `dsh-jsonrpc-agent-pkg-<platform>-<arch>`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发名为 `deepseek-harness` / `deepseek-harness-runtime-bin`。
|
||||
`@deepseek-ai/dsh-jsonrpc-demo`(包)→ `dsh-jsonrpc-agent`(`bin`)→ `dsh-jsonrpc-agent-pkg`(闭包 manifest;没有作用域前缀,刻意避开 `constraints` 对 `@deepseek-ai/dsh-*` 的包形状规则)→ `dsh-jsonrpc-agent-pkg-<platform>-<arch>`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发包名为 `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`,导入模块名仍为 `deepseek_harness` / `deepseek_harness_runtime`。
|
||||
|
||||
## 工作线程插件
|
||||
|
||||
@@ -62,7 +62,7 @@ exe 内支持 `dsh-workflow-workerthread` 与 `dsh-code-runtime-worker`。两个
|
||||
|
||||
## 测试
|
||||
|
||||
验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化以下各处的不透明消息 ID:SDK 结果与通知流,以及父会话和两个子会话的 JSONL 日志。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。
|
||||
验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化以下各处的不透明消息 ID:SDK 结果与通知流,以及父会话和两个子会话的 JSONL 日志。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。
|
||||
|
||||
手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,短命管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。
|
||||
|
||||
|
||||
@@ -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-12-agent-scope-runtime-design.md
|
||||
2026-07-12-agent-scope-runtime-design.md: 8903a2fefa83dba042f8105b182e590783a9adde
|
||||
2026-07-12-agent-scope-runtime-design.md: 5bee5f3f79be903848f8ecdf1ea84fe6fca60f6f
|
||||
2026-07-12-agent-scope-runtime-design.zh.md: d86ab3d8cb9a051d4664aedac812b10b53521f22
|
||||
|
||||
@@ -25,7 +25,7 @@ The design can be skimmed as seven choices:
|
||||
| Coordinate create/resume | One `AgentCreationTransaction` |
|
||||
| Protect durable, queued, model, or wire data | Materialize once at that boundary |
|
||||
| Pass typed values inside one process | Readonly borrowed contract |
|
||||
| Compose the model-visible prompt and tool surface | One shared tool view plus the authoritative assembly-waterfall result |
|
||||
| Compose the model-visible prompt and tool set | One shared tool view plus the authoritative assembly-waterfall result |
|
||||
| Coordinate subagent, worker, and process shutdown | One cancellation signal plus the independent terminal/quiescence facts of that boundary |
|
||||
|
||||
The rest of this Agent Note expands those choices in dependency order: Cordis mechanics, scope routing, creation and session commit, tools and prompts, subagents and workflows, then executable checks.
|
||||
|
||||
@@ -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-12-scoped-layers-store.md
|
||||
2026-07-12-scoped-layers-store.md: c5186d1652bca617eed62ec02937f2d055ea727c
|
||||
2026-07-12-scoped-layers-store.md: 91df72cf18c3f28d49534793bee85094b299c9a5
|
||||
2026-07-12-scoped-layers-store.zh.md: f5084426beef836d71e55c0898ec1edbdc7725f3
|
||||
|
||||
@@ -111,11 +111,11 @@ All seven facades keep validation and diagnostics in their owning registry and c
|
||||
## Consequences
|
||||
|
||||
- Scope-aware registries express one aggregate layer and reuse the same construction, ownership, rollback, notification, and reclamation choreography. Domain-specific validation, diagnostics, filtering, evaluation, and observer policy remain in each registry.
|
||||
- The public read surface stays narrow: direct table iteration preserves explicitly live behavior, while `merge()` is the one shared materialized shadowing operation. A heterogeneous `ScopeLayer` has no layer-wide `values()` contract.
|
||||
- The public read API stays narrow: direct table iteration preserves explicitly live behavior, while `merge()` is the one shared materialized shadowing operation. A heterogeneous `ScopeLayer` has no layer-wide `values()` contract.
|
||||
- The helper is deliberately synchronous. A future registration that needs asynchronous setup or several independently owned undos must identify its ownership and settlement boundaries before widening this contract.
|
||||
- An action must throw before retaining a contribution or return an undo for everything it retained; the helper cannot repair mutation outside that contract. The provided entry operations are atomic, and migrated registries perform fallible validation before insertion.
|
||||
- A scoped layer remains allocated until every table in its aggregate is empty. Disposing one facade therefore cannot discard sibling contributions owned by the same scope.
|
||||
- The four public symbols become a reusable package contract. Keeping `EntryValues` internal and consumer policy outside the helper limits the compatibility surface.
|
||||
- The four public symbols become a reusable package contract. Keeping `EntryValues` internal and consumer policy outside the helper limits the compatibility API.
|
||||
- The migration changes no public registry behavior and no model-, human-, wire-, persistence-, configuration-, or dependency-graph output.
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -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-14-provider-routed-llm-adapters.md
|
||||
2026-07-14-provider-routed-llm-adapters.md: 8bd34126d05140e486cd170bfc9255f0962dc413
|
||||
2026-07-14-provider-routed-llm-adapters.zh.md: 09d9032da5845d3e416dcfa047fde8e6527025b4
|
||||
2026-07-14-provider-routed-llm-adapters.md: df152a4436226ac5cb1941ce060a35bd4a0d496d
|
||||
2026-07-14-provider-routed-llm-adapters.zh.md: 181323c136a4f953d64928a8698720e164dd5ad1
|
||||
|
||||
@@ -48,7 +48,7 @@ This state is model-visible replay input and therefore follows the existing [rec
|
||||
|
||||
### Propagate the target through every request producer
|
||||
|
||||
Every model-selection surface carries provider and model together: declarative agents, ACP and stdio app config, the JSON-RPC initialize request, subagent overrides and inheritance, workflow child overrides, and direct compaction summarization. Subagents inherit both fields from their parent before applying request overrides. The system-prompt variable set gains `provider` beside `model`.
|
||||
Every model-selection path carries provider and model together: declarative agents, ACP and stdio app config, the JSON-RPC initialize request, subagent overrides and inheritance, workflow child overrides, and direct compaction summarization. Subagents inherit both fields from their parent before applying request overrides. The system-prompt variable set gains `provider` beside `model`.
|
||||
|
||||
Compaction configuration gains `summarizationProvider` beside `summarizationModel`. Both are empty to inherit, or both are non-empty to select an explicit target; a half-configured pair fails load. Inheritance uses the last logged request target when one exists and falls back to the agent's creation options. `compact/summary` records both fields with the existing model-call envelope.
|
||||
|
||||
@@ -66,7 +66,7 @@ The on-disk session format remains the pre-release pinned version `0`, with no c
|
||||
|
||||
**Let `dsh-llm-pi-ai` automatically register every pi-ai provider.** This would claim ambient credentials and provider names the deployment never intended to expose, and would conflict with native adapters such as `dsh-llm-deepseek`. Explicit profiles make capability and credential scope reviewable.
|
||||
|
||||
**Mount one pi-ai plugin instance per provider.** Separate instances isolate config but repeat plugin declarations and cannot make profile registration atomic. One adapter already receives provider on every request, so a validated profile map is the smaller lifecycle surface.
|
||||
**Mount one pi-ai plugin instance per provider.** Separate instances isolate config but repeat plugin declarations and cannot make profile registration atomic. One adapter already receives provider on every request, so a validated profile map is the smaller lifecycle API.
|
||||
|
||||
**Accept arbitrary inline pi-ai model descriptors.** This would support catalog-external private model ids, but it exposes pi-ai's model and compatibility schema as Harness configuration and makes the adapter responsible for validating protocol-specific combinations. The first version supports custom endpoints by overriding `baseURL` on catalog models; custom descriptors require a separate decision after a real catalog-external deployment is identified.
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ pi-ai 回放状态是其成功 `AssistantMessage` 的带版本最小投影,包
|
||||
|
||||
### 在所有请求生产方中传播目标
|
||||
|
||||
每个模型选择接口都同时携带 provider 与 model:声明式 agent、ACP(Agent Client Protocol)和 stdio 应用配置、JSON-RPC initialize 请求、subagent 覆盖与继承、工作流子 agent 覆盖,以及直接压缩摘要。subagent 先从父 agent 继承两个字段,再应用请求覆盖。系统提示词变量集合在 `model` 之外增加 `provider`。
|
||||
每条模型选择路径都同时携带 provider 与 model:声明式 agent、ACP(Agent Client Protocol)和 stdio 应用配置、JSON-RPC initialize 请求、subagent 覆盖与继承、工作流子 agent 覆盖,以及直接压缩摘要。subagent 先从父 agent 继承两个字段,再应用请求覆盖。系统提示词变量集合在 `model` 之外增加 `provider`。
|
||||
|
||||
压缩配置在 `summarizationModel` 之外增加 `summarizationProvider`。两个值均为空时继承,均非空时选择显式目标;只配置其中一个会导致加载失败。继承优先使用最近一次记录的请求目标,没有时回退到 agent 创建选项。`compact/summary` 使用现有模型调用 envelope 记录两个字段。
|
||||
|
||||
@@ -60,7 +60,7 @@ JSON-RPC 运行时显式接收 provider 与 model。仅当 `deepseek` 提供方
|
||||
|
||||
**继续以模型名称作为注册表键,并增加通配适配器。** 通配机制会在精确注册与兜底插件之间引入回退顺序,使重复所有权取决于监听器顺序;若不再增加其他约定,仍无法区分不同提供方中相同的模型 ID。
|
||||
|
||||
**将提供方与模型编码到一个字符串中。** OpenRouter 的 `openai/gpt-*` 等值已经包含类似提供方的前缀和斜杠。分隔符约定会把路由语法泄漏到每个模型选择接口,并需要转义规则;两个显式字段更清晰,也可以分别记录日志。
|
||||
**将提供方与模型编码到一个字符串中。** OpenRouter 的 `openai/gpt-*` 等值已经包含类似提供方的前缀和斜杠。分隔符约定会把路由语法泄漏到每个模型选择器,并需要转义规则;两个显式字段更清晰,也可以分别记录日志。
|
||||
|
||||
**增加 `backend + provider + model`。** backend 键可以让 `dsh-llm-deepseek` 与 pi-ai 的 DeepSeek 实现共存,并按请求切换。最终采用的部署规则是一个提供方对应一个适配器所有者:同一上游的不同实现属于由插件组合选定的替代项。第三个路由维度会增加每个请求与配置的负担,却没有当前消费方。
|
||||
|
||||
|
||||
@@ -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-15-llm-model-catalog-and-acp-selection.md
|
||||
2026-07-15-llm-model-catalog-and-acp-selection.md: 77f8e379e2b07ecf4e67fa7197752543cfedd6dd
|
||||
2026-07-15-llm-model-catalog-and-acp-selection.zh.md: ce4a04a66e345834bc2b16b743be89dc7a0b9424
|
||||
2026-07-15-llm-model-catalog-and-acp-selection.md: bfd17c73b01319c10d5dc03333b3c726db5d6f33
|
||||
2026-07-15-llm-model-catalog-and-acp-selection.zh.md: aeddada5591bb2da2c0861acc368516eff148172
|
||||
|
||||
@@ -26,17 +26,17 @@ Catalog membership is advisory. It drives selectors and diagnostics but never ch
|
||||
|
||||
`dsh-llm-pi-ai` maps the configured provider's installed `getModels(provider)` entries into the neutral catalog. Its existing request-time catalog lookup remains authoritative and still rejects unknown models with `UNKNOWN_MODEL`. `dsh-llm-deepseek` accepts an optional `models` config containing display entries, defaulting to `deepseek-v4-flash` named `DeepSeek-V4-Flash` and `deepseek-v4-pro` named `DeepSeek-V4-Pro`. An explicit list replaces those defaults and an empty list disables discovery. The entries improve selector UX for known public or private models, while every unlisted model id continues to pass through unchanged.
|
||||
|
||||
### Per-session selection in the front door
|
||||
### Per-session selection in the front end
|
||||
|
||||
A selection is owned by the front door that offers it (today the TUI `/model` selector), never by `LlmService` or `AgentOptions`: those are deployment-wide or creation-wide objects, and mutating them would couple concurrent sessions. Each opaque choice carries the full provider/model pair, because the same model id may appear under multiple routes.
|
||||
A selection is owned by the front end that offers it (today the TUI `/model` selector), never by `LlmService` or `AgentOptions`: those are deployment-wide or creation-wide objects, and mutating them would couple concurrent sessions. Each opaque choice carries the full provider/model pair, because the same model id may appear under multiple routes.
|
||||
|
||||
The ACP automation transport is not a catalog consumer. Its deployment config supplies one optional provider/model target for newly created agents, and it advertises no model selector or configuration-option interface.
|
||||
|
||||
### Prompt/request consistency and durability
|
||||
|
||||
`installModelSelection` (in `dsh-agent`) installs scoped `system-prompt/assemble` and `agent/request` listeners for a front-door-owned selection. Prompt assembly snapshots the selected pair once per step, overwrites the assembled `provider` and `model` variables after downstream prompt listeners, and the request listener applies that same snapshot after downstream request listeners. A selection during asynchronous assembly therefore starts on the next step rather than splitting prompt text from routing. Other call-config fields remain untouched.
|
||||
`installModelSelection` (in `dsh-agent`) installs scoped `system-prompt/assemble` and `agent/request` listeners for a front-end-owned selection. Prompt assembly snapshots the selected pair once per step, overwrites the assembled `provider` and `model` variables after downstream prompt listeners, and the request listener applies that same snapshot after downstream request listeners. A selection during asynchronous assembly therefore starts on the next step rather than splitting prompt text from routing. Other call-config fields remain untouched.
|
||||
|
||||
The request header remains the durable source of truth. When a selection is actually used, the existing full `request/header` snapshot records it, and a front door initializes its selection from the folded last request header before falling back to creation options. A selection that is never used by a request is intentionally in-memory only because it never became model-visible state.
|
||||
The request header remains the durable source of truth. When a selection is actually used, the existing full `request/header` snapshot records it, and a front end initializes its selection from the folded last request header before falling back to creation options. A selection that is never used by a request is intentionally in-memory only because it never became model-visible state.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -26,17 +26,17 @@ ACP 选择还必须保留提供方维度。同一个模型 ID 可能存在于多
|
||||
|
||||
`dsh-llm-pi-ai` 将已配置提供方的安装目录 `getModels(provider)` 映射为中立目录。其现有请求时目录查询仍是权威依据,未知模型仍以 `UNKNOWN_MODEL` 失败。`dsh-llm-deepseek` 接受可选的 `models` 配置作为展示条目,默认包含名为 `DeepSeek-V4-Flash` 的 `deepseek-v4-flash` 和名为 `DeepSeek-V4-Pro` 的 `deepseek-v4-pro`。显式列表会替换这些默认值,空列表则关闭发现。这些条目改善已知公开或私有模型的选择体验,而所有未列出的模型 ID 仍会原样透传。
|
||||
|
||||
### 前门内的会话级选择
|
||||
### 前端内的会话级选择
|
||||
|
||||
选择由提供它的前门拥有(今天是 TUI 的 `/model` 选择器),而不由 `LlmService` 或 `AgentOptions` 拥有:它们是部署级或创建级对象,改动它们会把并发会话耦合在一起。每个不透明选项都携带完整的提供方/模型对,因为同一模型 ID 可能出现在多个路由下。
|
||||
选择由提供它的前端拥有(今天是 TUI 的 `/model` 选择器),而不由 `LlmService` 或 `AgentOptions` 拥有:它们是部署级或创建级对象,改动它们会把并发会话耦合在一起。每个不透明选项都携带完整的提供方/模型对,因为同一模型 ID 可能出现在多个路由下。
|
||||
|
||||
ACP 自动化传输层不是目录消费方。它通过部署配置为新创建的 agent 提供一个可选的提供方/模型目标,不展示模型选择器或配置选项接口。
|
||||
|
||||
### 提示词/请求一致性与持久化
|
||||
|
||||
`installModelSelection`(位于 `dsh-agent`)为前门拥有的选择安装 agent 作用域的 `system-prompt/assemble` 与 `agent/request` 监听器。提示词组装在每个步骤对所选组合做一次快照,在下游提示词监听器之后覆写组装出的 `provider` 与 `model` 变量;请求监听器在下游请求监听器之后应用同一快照。因此,发生在异步组装期间的选择会从下一个步骤生效,而不会让提示词文本与路由分裂。其他调用配置字段保持不变。
|
||||
`installModelSelection`(位于 `dsh-agent`)为前端拥有的选择安装 agent 作用域的 `system-prompt/assemble` 与 `agent/request` 监听器。提示词组装在每个步骤对所选组合做一次快照,在下游提示词监听器之后覆写组装出的 `provider` 与 `model` 变量;请求监听器在下游请求监听器之后应用同一快照。因此,发生在异步组装期间的选择会从下一个步骤生效,而不会让提示词文本与路由分裂。其他调用配置字段保持不变。
|
||||
|
||||
请求头仍是持久化的真源。当某个选择真正被使用时,现有的完整 `request/header` 快照会记录它;前门先从折叠后的最后一个请求头初始化其选择,然后才回退到创建选项。从未被请求使用的选择有意只保留在内存中,因为它从未成为模型可见状态。
|
||||
请求头仍是持久化的真源。当某个选择真正被使用时,现有的完整 `request/header` 快照会记录它;前端先从折叠后的最后一个请求头初始化其选择,然后才回退到创建选项。从未被请求使用的选择有意只保留在内存中,因为它从未成为模型可见状态。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -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-15-lsp-capability-seam.md
|
||||
2026-07-15-lsp-capability-seam.md: a461432fe5f218ea6c3ff3e031b52f7ebffbd7a5
|
||||
2026-07-15-lsp-capability-seam.md: c407de5275da0a8323b3c6186e178c8b2fafdc31
|
||||
2026-07-15-lsp-capability-seam.zh.md: bfbd8d04f71b154d2833c86f2a6dc84d7e13fff4
|
||||
|
||||
@@ -137,7 +137,7 @@ Navigation maps `Location` directly and `LocationLink` from `targetUri` plus `ta
|
||||
|
||||
Abort reaches every query phase and sends `$/cancelRequest` once an id exists. An unresponsive server is terminated and awaited without collateral active work because the instance is serialized. Disposal rejects and cancels work, attempts graceful shutdown, escalates through bounded termination, and awaits quiescence.
|
||||
|
||||
## Deliberately deferred surface
|
||||
## Deliberately deferred API
|
||||
|
||||
Symbols are deferred because they need different schemas and overlap read/search; a future workspace-symbol tool must accept a search query. Call hierarchy is deferred because support is uneven, and `prepareCallHierarchy` remains an internal prerequisite rather than a model operation.
|
||||
|
||||
|
||||
@@ -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-16-explicit-turn-cancellation.md
|
||||
2026-07-16-explicit-turn-cancellation.md: 5d7b5856ceebd3dc49564c1800004188da162fd8
|
||||
2026-07-16-explicit-turn-cancellation.md: 86faad9929d3eb5b00e66bb1c46a2e35b135d954
|
||||
2026-07-16-explicit-turn-cancellation.zh.md: ba20b80f3c280087d71688ef4596fb075fa5782b
|
||||
|
||||
@@ -40,7 +40,7 @@ Initiator-scope tests assert that every hook still observes the exact Agent and
|
||||
|
||||
**Persist a free-form string reason.** Strings admit spelling drift, prevent exhaustive switching, and encourage consumers to parse presentation text. The runtime uses a closed discriminated union, while the terminal record needs only the stable aborted outcome.
|
||||
|
||||
**Persist the typed caller cause in `turn/end`.** No production replay, UI, ACP, telemetry, or workflow consumer distinguishes `user` from `parent`. Copying the request source into the terminal result would conflate two facts and add Session-specific validation without a consumer; a future audit surface can record a separate cancellation-request event.
|
||||
**Persist the typed caller cause in `turn/end`.** No production replay, UI, ACP, telemetry, or workflow consumer distinguishes `user` from `parent`. Copying the request source into the terminal result would conflate two facts and add Session-specific validation without a consumer; a future audit trail can record a separate cancellation-request event.
|
||||
|
||||
**Define speculative `superseded`, `timeout`, and `shutdown` variants now.** No current Agent cancellation producer implements those semantics. `shutdown` is already lifecycle disposal, and timeout or supersession should enter the union only with an owning policy and unique terminal meaning.
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md
|
||||
2026-07-19-gui-layering-and-rpc-protocol.md: 7997a682c8745f7b3d0a9721acfa9355603f9b2e
|
||||
2026-07-19-gui-layering-and-rpc-protocol.zh.md: cb36b5cb725128e1c5067e5e69e52aa851668e52
|
||||
2026-07-19-gui-layering-and-rpc-protocol.md: deba5e81c35e1572e2e6dc68b48234414ac9a7d5
|
||||
2026-07-19-gui-layering-and-rpc-protocol.zh.md: e506c6242794b1b065136c8190f5c46a46e0d069
|
||||
|
||||
@@ -8,12 +8,12 @@ English | [中文](2026-07-19-gui-layering-and-rpc-protocol.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
We need a UI integration layer. Beyond the existing ACP/stdio baseline, more product UI shapes are coming — Web (server), Electron, and others. We call these shapes Clients, uniformly, and want the following capabilities:
|
||||
We need a UI integration layer. Beyond the existing ACP/stdio baseline, more product clients are coming — Web (server), Electron, and others. We call them Clients and want the following capabilities:
|
||||
|
||||
- One `dsh` process supporting both `dsh web` (serve) and `dsh run` (headless) — one process, two modes (a design reservation)
|
||||
- Launching inside Electron with the same Web technology shape as `dsh web`
|
||||
- One `dsh` process supporting both `dsh web` (serve) and `dsh --profile headless` (headless) — one process, two modes (a design reservation)
|
||||
- Launching inside Electron with the same Web technologies as `dsh web`
|
||||
|
||||
That demands a stable layered responsibility model in the engineering codebase, so future client shapes plug in cleanly.
|
||||
That demands a stable layered responsibility model in the engineering codebase, so future clients plug in cleanly.
|
||||
|
||||
At the same time the physical channels differ per consumer (browser HTTP/WebSocket, in-process fetch/SSE, IPC later), so we also need a channel-independent message model and a single contract source of truth — "adding a method" and "swapping a carrier" must not entangle each other, and every message on the wire must be type-validatable, observable, and reconcilable.
|
||||
|
||||
@@ -27,21 +27,21 @@ Directories layer as follows:
|
||||
- the unified backend protocol (fetch, HTTP, streaming interfaces…) — definitions and support, see the "Message protocol" sections below
|
||||
- `packages/client/*`: packages provide client-side capability only; every package stays single-sided. Three kinds live here (the axes are owned by the [client plugin loading note](2026-07-23-client-plugin-loading-model.md)):
|
||||
- **Pure libraries** (`ui-slots`, `web-react`, `ui-primitives`, plus the `loader` kernel package): ordinary root-index packages, statically bundled into the shell; the first three are seeded into the module table.
|
||||
- **Static-arrival entry packages** (`connection`, `runtime`, `ui-theme`, `i18n`, `hmr`): no `dshClient` key and no browser bundle — the shell bundles their `src/client/` half and registers it with `ctx.modules`; they are governed as entries of the host-authored graph like everything else.
|
||||
- **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dshClient` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services.
|
||||
- `apps/` holds the externally exported application shapes, assembled from Client / Host mixtures.
|
||||
- `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`.
|
||||
- `apps/cli` (`@deepseek-ai/dsh`) dispatches shapes: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh run` = [a direct core Agent/Session front door](2026-08-09-headless-direct-core-front-door.md), with zero Host, HTTP, or browser layer.
|
||||
- A future Electron shape reuses the same web client packages over an IPC fetch carrier.
|
||||
- **Static-arrival entry packages** (`connection`, `runtime`, `ui-theme`, `i18n`, `hmr`): no `dsh.client` key and no browser bundle — the shell bundles their `src/client/` half and registers it with `ctx.modules`; they are governed as entries of the host-authored graph like everything else.
|
||||
- **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dsh.client` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services.
|
||||
- `apps/` holds the externally exported applications, assembled from Client / Host mixtures.
|
||||
- `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell API exported by `dsh-client-web`.
|
||||
- `apps/cli` (`@deepseek-ai/dsh`) dispatches commands: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh --profile headless` = [a direct core Agent/Session entry point](2026-08-09-headless-direct-core-entry-point.md), with zero Host, HTTP, or browser layer.
|
||||
- A future Electron application reuses the same web client packages over an IPC fetch carrier.
|
||||
|
||||
```
|
||||
apps/* (application shapes: apps/web = vite app, apps/cli = bin dispatch)
|
||||
apps/* (applications: apps/web = vite app, apps/cli = bin dispatch)
|
||||
│ consume
|
||||
▼
|
||||
packages/host/* packages/client/*
|
||||
apiproxy front layer: protocol pure libs: ui-slots / web-react / ui-primitives
|
||||
runtime assembly / host entity dshClient plugins ×8 (node half = empty apply,
|
||||
webserver web-shape HTTP carriage client half = src/client/)
|
||||
runtime assembly / host entity dsh.client plugins ×8 (node half = empty apply,
|
||||
webserver Web HTTP carriage client half = src/client/)
|
||||
│ ctx.plugin(...) ▲ import only apiproxy's /api /client subpaths
|
||||
▼ │ (type-only + the client base class)
|
||||
harness core packages ──────────────────┘ (types reach the browser via import type)
|
||||
@@ -51,35 +51,35 @@ Direction discipline (every rule auditable from package deps):
|
||||
|
||||
- `runtime → apiproxy` is one-way; apiproxy depends only on type definitions.
|
||||
- Client-side packages **never import** host-side package runtime (they consume only the two browser-safe subpaths `/api` and `/client`).
|
||||
- `webserver` does not depend on `runtime`: it provides a `{ fetch }`-shaped implementation — "webserver ← runtime" is a runtime injection relationship, not a package dependency.
|
||||
- `webserver` does not depend on `runtime`: it provides an implementation of the `{ fetch }` interface — "webserver ← runtime" is a runtime injection relationship, not a package dependency.
|
||||
- Cross-package client imports use the `/client` subpath for plugin packages, and between plugin packages they are type-only — a cross-plugin value import is a build error at the tsdown purity gate (value cooperation goes through cordis services; the [client plugin loading note](2026-07-23-client-plugin-loading-model.md) owns the edge rules).
|
||||
|
||||
TypeScript checks in **two aggregate programs** referenced by a solution root (`tsconfig.json` = solution; `tsconfig.host.json` = host side + tests, excluding `packages/client`; `tsconfig.client.json` = client packages and their tests): both sides merge the cordis `Context` interface under the same keys (`sessions`, `loader`) with different services, so one program would see both declaration merges and report a collision. Shared leaves (session/llm/tools/apiproxy…) build once and are referenced by both programs ([topology](../process/2026-07-22-tsconfig-solution-root-two-aggregates.md)).
|
||||
|
||||
On the protocol side: TS interfaces (`packages/host/apiproxy/src/api/`, zero Node dependencies, browser-importable); wire messages unify under a **bidirectional model** — each logical message is shaped by "who initiates × request/response" (two axes, four cells, called the four quadrants below), decoupled from the physical channel; clients all inherit `AbstractApiClient` (protocol invariants live entirely in the base class, platform differences are just the `doFetch` transport aspect).
|
||||
On the protocol side: TS interfaces (`packages/host/apiproxy/src/api/`, zero Node dependencies, browser-importable); wire messages unify under a **bidirectional model** — each logical message is classified by "who initiates × request/response" (two axes, four cells, called the four quadrants below), decoupled from the physical channel; clients all inherit `AbstractApiClient` (protocol invariants live entirely in the base class, platform differences are just the `doFetch` transport aspect).
|
||||
|
||||
#### Layer roles
|
||||
|
||||
| Layer | Package | Responsibility | Key discipline |
|
||||
|---|---|---|---|
|
||||
| Front layer | `dsh-host-apiproxy` | TS/zod definitions (api/) + the fetch abstraction (fetch/: handler + client base class) | Keep it simple — every consumer needs it; importable from Node and browser alike; protocol content in the "Message protocol" sections below; clients must not bypass api through ctx |
|
||||
| Assembly layer | `dsh-host-runtime` | Plugin composition + ApiProxy integration + the web UI plugin mount (in-memory Loader tree over the eight dshClient packages); home of host-level configuration (defaults/persistenceRoot, future user profile) | Which plugins mount and with what defaults is decided only here; shells must not alter the assembly |
|
||||
| Carrier layer | `dsh-host-webserver` | Web-shape HTTP and upgrade: static serving + `/api/*`→handler forwarding + WebSocket upgrade route + close semantics; plugin bundle endpoint + `__DSH_BOOT__` manifest injection (fed by the web plugin registry) | Web (browser access) only; zero workspace dependencies (the registry arrives by structural injection); Electron does not reuse it |
|
||||
| Assembly layer | `dsh-host-runtime` | Plugin composition + ApiProxy integration + the web UI plugin mount (in-memory Loader tree over the eight dsh.client packages); home of host-level configuration (defaults/persistenceRoot, future user profile) | Which plugins mount and with what defaults is decided only here; shells must not alter the assembly |
|
||||
| Carrier layer | `dsh-host-webserver` | Web HTTP and upgrade: static serving + `/api/*`→handler forwarding + WebSocket upgrade route + close semantics; plugin bundle endpoint + `__DSH_BOOT__` manifest injection (fed by the web plugin registry) | Web (browser access) only; zero workspace dependencies (the registry arrives by structural injection); Electron does not reuse it |
|
||||
| Client libraries | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | Slot registry core / ctx↔React glue / pure React atoms | Zero cordis runtime dependency in components; seeded into the loader module table by the shell |
|
||||
| Client plugins | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | Browser-side cordis plugin tree (wire consumer, core services, theme, i18n, layout, sidebar, conversation, trajectory) — see the web client architecture note | Dual entry (node half = empty apply; implementation in `src/client/`); the consumption face goes exclusively through ApiProxy |
|
||||
| Application shape | `@deepseek-ai/dsh` (apps/cli) + `dsh-frontend` (apps/web, the vite application) | Coarse bin dispatch + one assembly module per shape (web.ts / headless.ts); the vite app is a thin main over the `dsh-client-web` shell surface | Shapes dynamic-import so they never load each other; workspace knowledge like dist location stays in the app |
|
||||
| Application | `@deepseek-ai/dsh` (apps/cli) + `dsh-frontend` (apps/web, the vite application) | Coarse bin dispatch + one assembly module per application (web.ts / headless.ts); the vite app is a thin main over the `dsh-client-web` shell surface | Applications use dynamic imports so they never load each other; workspace knowledge like dist location stays in the app |
|
||||
|
||||
#### Naming rule
|
||||
|
||||
Packages under `packages/host/*` and `packages/client/*` **must carry the directory-group prefix in the package name**: host/runtime → `dsh-host-runtime`, client/runtime → `dsh-client-runtime`. The directory name does not repeat the group prefix (host/ already expresses it). The package-name tail therefore ≠ the directory name, so the `dsh-*` wildcard in tsconfig.base.json (which resolves by directory name) misses them — **each package in these two groups needs an explicit paths entry**, including separate entries for the client packages' `/client` subpaths so source-level resolution matches the exports map.
|
||||
|
||||
#### How to integrate a new shape (operational checklist)
|
||||
#### How to integrate a new application (operational checklist)
|
||||
|
||||
1. **Pick a fetch impersonation**: browser same-origin HTTP / in-process `host.handler.fetch` injection / your own transport-aspect subclass (e.g. future Electron IPC, see the "Subclass table" below).
|
||||
2. **Write an assembly module under `apps/`**: `startHost()` + a client subclass + the shape's private signal/print/exit semantics; a mixture never becomes a package — assembly is written in the app.
|
||||
2. **Write an assembly module under `apps/`**: `startHost()` + a client subclass + the application's private signal/print/exit semantics; a mixture never becomes a package — assembly is written in the app.
|
||||
3. **Import `dsh-host-webserver` only if you need HTTP carriage**, otherwise zero ports.
|
||||
|
||||
The two existing shapes preserve the boundary: the Web shape mounts Host, carrier, and browser composition, while `dsh run` mounts a direct core runner with zero Host, HTTP, or ports. ACP-class protocol bridges do not follow the client-carrier checklist: they expose core to the external ecosystem, mount via `ctx.plugin(front-door plugin)` directly, and wear no fetch.
|
||||
The two existing applications preserve the division: the Web application mounts Host, carrier, and browser composition, while `dsh --profile headless` mounts a direct core runner with zero Host, HTTP, or ports. ACP-class protocol bridges do not follow the client-carrier checklist: they expose core to the external ecosystem and mount directly via `ctx.plugin(entry-point plugin)` without fetch.
|
||||
|
||||
## Message protocol
|
||||
|
||||
@@ -116,7 +116,7 @@ Domain interface signatures perceive only the narrow forms: `RpcRequest<P> = { r
|
||||
|
||||
### RpcReceipt: the carrier receipt
|
||||
|
||||
The HTTP response body of a `ClientResponse` is `RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }` — a carrier-layer receipt, **not** an RpcMessage (a response has no response); late/duplicate answers get `not-pending`, and the logical convergence surface is the `*/resolved` frames.
|
||||
The HTTP response body of a `ClientResponse` is `RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }` — a carrier-layer receipt, **not** an RpcMessage (a response has no response); late/duplicate answers get `not-pending`, and the logical convergence point is the `*/resolved` frames.
|
||||
|
||||
## The type system: signatures are the source of truth
|
||||
|
||||
@@ -169,7 +169,7 @@ The remaining methods (`session.create`/`session.history`/`session.rename`/`sess
|
||||
|
||||
### Frames (server→client, named unions)
|
||||
|
||||
Two logical streams: the mux stream (`/api/events.mux`, all-session aggregate) and the host stream (`/api/events.host`, host-level events). The browser consumes one downlink WebSocket per stream, while the in-process fetch carrier retains SSE to preserve the same shape; see the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) for the physical boundary. One example frame row:
|
||||
Two logical streams: the mux stream (`/api/events.mux`, all-session aggregate) and the host stream (`/api/events.host`, host-level events). The browser consumes one downlink WebSocket per stream, while the in-process fetch carrier retains SSE with the same event framing; see the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) for the physical boundary. One example frame row:
|
||||
|
||||
| frame type | payload | when |
|
||||
|---|---|---|
|
||||
@@ -215,8 +215,8 @@ All four quadrant full forms pass through `onEnvelope`; the base implementation
|
||||
|
||||
| Subclass | Package | doFetch | Purpose |
|
||||
|---|---|---|---|
|
||||
| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing; carrier tests and callers can exercise the protocol without opening a port, while product `dsh run` drives core directly |
|
||||
| `WebApiClient` | dsh-client-connection | `globalThis.fetch` uplink + one same-origin WebSocket downlink per logical stream | the browser shape; physical boundary in the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) |
|
||||
| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing; carrier tests and callers can exercise the protocol without opening a port, while product `dsh --profile headless` drives core directly |
|
||||
| `WebApiClient` | dsh-client-connection | `globalThis.fetch` uplink + one same-origin WebSocket downlink per logical stream | the browser client; physical boundary in the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) |
|
||||
| `FixtureApiClient` | dsh-client-connection | unused (protocol-layer override) | serverless UI development (`?fixture`): overrides the `callUnary`/`openMux`/`openHost`/`respond` virtuals and is itself the fake server (frame rpcIds minted by it, semantics self-consistent) |
|
||||
| IPC bridge subclass (hypothetical example — no such shell exists) | an Electron shell | IPC serialization round trip | would swap only doFetch; contract and base class unchanged |
|
||||
|
||||
@@ -234,15 +234,15 @@ All four quadrant full forms pass through `onEnvelope`; the base implementation
|
||||
|
||||
## Consequences
|
||||
|
||||
Every client shape consumes one contract: adding a unary method is a five-step mechanical change radiating from a single signature, swapping a carrier touches only a `doFetch` subclass, and every wire message is zod-validated, observable through the envelope tap, and reconcilable by rpcId. Ordinary unary calls remain bounded, while `host.pickDirectory` and `command.execute` may stay pending until the operation finishes or caller/connection cancellation arrives; this accepts that a non-cooperative user-paced operation can hang its request rather than treating valid operation duration as transport failure. The other accepted costs: two groups of packages need explicit tsconfig paths entries, and the reserved methods (fork/inject/task.list/listModels/hostInstanceId) stay dormant until a real consumer arrives.
|
||||
Every client consumes one contract: adding a unary method is a five-step mechanical change from a single signature, swapping a carrier touches only a `doFetch` subclass, and every wire message is zod-validated, observable through the envelope tap, and reconcilable by rpcId. Ordinary unary calls remain bounded, while `host.pickDirectory` and `command.execute` may stay pending until the operation finishes or caller/connection cancellation arrives; this accepts that a non-cooperative user-paced operation can hang its request rather than treating valid operation duration as transport failure. The other accepted costs: two groups of packages need explicit tsconfig paths entries, and the reserved methods (fork/inject/task.list/listModels/hostInstanceId) stay dormant until a real consumer arrives.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| Rejected | One-line reason |
|
||||
|---|---|
|
||||
| Packaging by "product shape" (a web family, an electron family) | What shapes share is host/client capability, not the shape itself; capability-provider layering means a new shape needs zero new packages |
|
||||
| Packaging by product (a web family, an electron family) | Products share host/client capabilities rather than an application implementation; capability-provider layering means a new application needs zero new packages |
|
||||
| A package per mixture (e.g. a standalone headless package) | A mixture has exactly one consumer (its own app); packaging it is ownerless abstraction, while assembly in the app is readable and disposable |
|
||||
| Consuming clients connecting to ctx directly (skipping the apiproxy layer) | Client shapes require wire validation, observability, and multi-client consistency. Direct headless is a local front door with no client boundary and uses the public Agent/Session seams rather than a client command plane |
|
||||
| Consuming clients connecting to ctx directly (skipping the apiproxy layer) | Clients require wire validation, observability, and multi-client consistency. Direct headless is a local entry point with no client boundary and uses the public Agent/Session seams rather than a client command plane |
|
||||
| webserver depending on runtime (saving the handler injection) | Structural-typing injection keeps webserver reusable by sidecars/tests with zero workspace deps; a package dependency would drag assembly knowledge into the carrier layer |
|
||||
| Package names without the group prefix (continuing dsh-<tail>) | `dsh-runtime`/`dsh-web-ui` lose their belonging in the flat npm namespace; the cost is one explicit paths entry per package |
|
||||
| Reusing the in-repo JSON-RPC 2.0 (dsh-jsonrpc) | Numeric error codes degrade to a single fallback code, contracts get aligned by hand in two copies, and naming drifts without a convention |
|
||||
|
||||
@@ -8,11 +8,11 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
需要提供 UI 对接层,除已有 ACP(Agent Client Protocol)/stdio 基线外,还需要 Web(server)、Electron 等其他产品 UI 形态。我们把这些形态统一称为 Client。希望具备以下能力:
|
||||
- 一个 `dsh` 进程同时支持 `dsh web`(启动)和 `dsh run`(headless),一个进程两种模式(设计预留)
|
||||
- 以与 `dsh web` 同构的 Web 技术形态,在 Electron 中启动
|
||||
需要提供 UI 对接层,除已有 ACP(Agent Client Protocol)/stdio 基线外,还需要 Web(server)、Electron 等其他产品客户端。我们把它们统一称为 Client。希望具备以下能力:
|
||||
- 一个 `dsh` 进程同时支持 `dsh web`(启动)和 `dsh --profile headless`(headless),一个进程两种模式(设计预留)
|
||||
- 在 Electron 中使用与 `dsh web` 相同的 Web 技术启动
|
||||
|
||||
那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client 形态。
|
||||
那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client。
|
||||
|
||||
同时各消费端的物理通道不同(浏览器 HTTP/WebSocket、进程内 fetch/SSE、将来 IPC),还需要一个通道无关的消息模型和单一约定事实源,让「加一个方法」「换一种载体」互不牵连,且 wire 上的每条消息可类型校验、可观测、可对账。
|
||||
|
||||
@@ -25,21 +25,21 @@ Status: implemented
|
||||
- 统一后端协议(fetch、HTTP、流式接口等)定义和支持,见本篇「消息协议」起各节
|
||||
- `packages/client/*`:包只提供 Client 侧能力,每包单边不混。这里住三类包(两条轴归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有):
|
||||
- **纯库**(`ui-slots`、`web-react`、`ui-primitives`,外加内核包 `loader`):普通根入口包,静态打包进壳;前三者播种进模块表。
|
||||
- **静态到达 entry 包**(`connection`、`runtime`、`ui-theme`、`i18n`、`hmr`):无 `dshClient` 键、无浏览器 bundle——壳把它们的 `src/client/` 半边打进自己的 bundle 并向 `ctx.modules` 登记;它们与其余单元一样,作为 host 独家撰写的图里的 entry 受治理。
|
||||
- **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dshClient` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。
|
||||
- `apps/` 作为对外导出的应用形态入口,可以由 Client / Host 混合组装。
|
||||
- `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`。
|
||||
- `apps/cli`(`@deepseek-ai/dsh`)做形态分发:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = [直接使用核心 Agent/Session 的前门](2026-08-09-headless-direct-core-front-door.md),不含 Host、HTTP 或浏览器层。
|
||||
- 将来的 Electron 形态经由 IPC fetch 载体复用同一套 web client 包。
|
||||
- **静态到达 entry 包**(`connection`、`runtime`、`ui-theme`、`i18n`、`hmr`):无 `dsh.client` 键、无浏览器 bundle——壳把它们的 `src/client/` 半边打进自己的 bundle 并向 `ctx.modules` 登记;它们与其余单元一样,作为 host 独家撰写的图里的 entry 受治理。
|
||||
- **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dsh.client` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。
|
||||
- `apps/` 作为对外导出的应用入口,可以由 Client / Host 混合组装。
|
||||
- `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳 API 之上的一层薄 `main.ts`。
|
||||
- `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh --profile headless` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。
|
||||
- 将来的 Electron 应用经由 IPC fetch 载体复用同一套 web client 包。
|
||||
|
||||
```
|
||||
apps/* (application shapes: apps/web = vite app, apps/cli = bin dispatch)
|
||||
apps/* (applications: apps/web = vite app, apps/cli = bin dispatch)
|
||||
│ consume
|
||||
▼
|
||||
packages/host/* packages/client/*
|
||||
apiproxy front layer: protocol pure libs: ui-slots / web-react / ui-primitives
|
||||
runtime assembly / host entity dshClient plugins ×8 (node half = empty apply,
|
||||
webserver web-shape HTTP carriage client half = src/client/)
|
||||
runtime assembly / host entity dsh.client plugins ×8 (node half = empty apply,
|
||||
webserver Web HTTP carriage client half = src/client/)
|
||||
│ ctx.plugin(...) ▲ import only apiproxy's /api /client subpaths
|
||||
▼ │ (type-only + the client base class)
|
||||
harness core packages ──────────────────┘ (types reach the browser via import type)
|
||||
@@ -54,30 +54,30 @@ harness core packages ──────────────────┘
|
||||
|
||||
TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig.json` = solution;`tsconfig.host.json` = host 侧 + 测试,排除 `packages/client`;`tsconfig.client.json` = client 各包及其测试):两侧在相同键(`sessions`、`loader`)下以不同服务合并 cordis `Context` 接口,单一 program 会同时看到两份声明合并而报冲突。共享叶子包(session/llm/tools/apiproxy 等)只构建一次,由两个 program 共同引用([拓扑](../process/2026-07-22-tsconfig-solution-root-two-aggregates.md))。
|
||||
|
||||
协议侧:TS interface(`packages/host/apiproxy/src/api/`,零 Node 依赖,浏览器可 import);wire 消息统一为**双向模型**——每条逻辑消息由「谁发起 × request/response」定形(两轴四格,后文称四象限),与物理通道解耦;客户端统一继承 `AbstractApiClient`(协议不变量全在基类,平台差异只是 `doFetch` 传输切面)。
|
||||
协议侧:TS interface(`packages/host/apiproxy/src/api/`,零 Node 依赖,浏览器可 import);wire 消息统一为**双向模型**——每条逻辑消息按「谁发起 × request/response」分类(两轴四格,后文称四象限),与物理通道解耦;客户端统一继承 `AbstractApiClient`(协议不变量全在基类,平台差异只是 `doFetch` 传输切面)。
|
||||
|
||||
#### 分层角色
|
||||
|
||||
| 层 | 包 | 职责 | 关键纪律 |
|
||||
|---|---|---|---|
|
||||
| 前置层 | `dsh-host-apiproxy` | TS/zod 定义 (api/)+ fetch 抽象 (fetch/:handler + 客户端基类) | 做简单、所有接入方都要;Node/浏览器皆可 import;协议内容见下文「消息协议」起各节;client 不得经 ctx 绕开 api |
|
||||
| 装配层 | `dsh-host-runtime` | 插件组合 + ApiProxy 集成 + web UI 插件挂载(覆盖八个 dshClient 包的内存 Loader 树);host 级配置归属地(defaults/persistenceRoot,将来用户 profile) | 装什么插件、给什么默认值只在这里定;壳不得改装配 |
|
||||
| 承载层 | `dsh-host-webserver` | Web 形态 HTTP 与 upgrade:静态服务 + `/api/*`→handler 转发 + WebSocket upgrade route + close 语义;插件 bundle 端点 + `__DSH_BOOT__` manifest(元数据清单)注入(由 web 插件注册表供给) | Web(浏览器访问)专用;零 workspace 依赖(注册表经结构注入到达);Electron 不复用它 |
|
||||
| 装配层 | `dsh-host-runtime` | 插件组合 + ApiProxy 集成 + web UI 插件挂载(覆盖八个 dsh.client 包的内存 Loader 树);host 级配置归属地(defaults/persistenceRoot,将来用户 profile) | 装什么插件、给什么默认值只在这里定;壳不得改装配 |
|
||||
| 承载层 | `dsh-host-webserver` | Web HTTP 与 upgrade:静态服务 + `/api/*`→handler 转发 + WebSocket upgrade route + close 语义;插件 bundle 端点 + `__DSH_BOOT__` manifest(元数据清单)注入(由 web 插件注册表供给) | Web(浏览器访问)专用;零 workspace 依赖(注册表经结构注入到达);Electron 不复用它 |
|
||||
| client 库 | `dsh-client-ui-slots` / `dsh-client-web-react` / `dsh-client-ui-primitives` | slot 注册表核心 / ctx↔React 胶合 / 纯 React 原子组件 | 组件零 cordis 运行时依赖;由壳播种进 loader 模块表 |
|
||||
| client 插件 | `dsh-client-connection` / `dsh-client-runtime` / `dsh-client-ui-theme` / `dsh-client-i18n` / `dsh-client-ui-layout` / `dsh-client-ui-sidebar` / `dsh-client-ui-conversation` / `dsh-client-ui-trajectory` | 浏览器侧 cordis 插件树(wire 消费者、核心服务、主题、i18n、布局、侧栏、对话、轨迹)——见 Web 客户端架构笔记 | 双入口(node 半边=空 apply;实现在 `src/client/`);消费面唯一经 ApiProxy |
|
||||
| 应用态 | `@deepseek-ai/dsh`(apps/cli)+ `dsh-frontend`(apps/web,vite 应用) | bin 粗分发 + 每形态一个拼装模块(web.ts / headless.ts);vite 应用是 `dsh-client-web` 壳表面之上的薄 main | 形态间动态 import 互不加载;dist 定位等 workspace 知识留在 app |
|
||||
| 应用 | `@deepseek-ai/dsh`(apps/cli)+ `dsh-frontend`(apps/web,vite 应用) | bin 粗分发 + 每个应用一个拼装模块(web.ts / headless.ts);vite 应用是 `dsh-client-web` 壳表面之上的薄 main | 各应用使用动态 import,因此不会互相加载;dist 定位等 workspace 知识留在 app |
|
||||
|
||||
#### 命名规则
|
||||
|
||||
`packages/host/*` 与 `packages/client/*` 下的包名**必须含目录组前缀**:host/runtime → `dsh-host-runtime`、client/runtime → `dsh-client-runtime`。目录名不重复组前缀(host/ 已表达)。因此包名尾段 ≠ 目录名,tsconfig.base.json 的 `dsh-*` 通配(按目录名解析)命不中——**这两组的每包需显式 paths 条目**,且 client 各包的 `/client` 子路径要单列条目,使源码级解析与 exports map 一致。
|
||||
|
||||
#### 怎么接入一个新形态(操作清单)
|
||||
#### 怎么接入一个新应用(操作清单)
|
||||
|
||||
1. **选 fetch 伪造方式**:浏览器同源 HTTP / 进程内 `host.handler.fetch` 注入 / 自写传输切面子类(如将来 Electron IPC,见下文「子类表」)。
|
||||
2. **在 `apps/` 下写拼装模块**:`startHost()` + 客户端子类 + 该形态私有的信号/打印/退出语义;混合体不建包,拼装写在 app 里。
|
||||
2. **在 `apps/` 下写拼装模块**:`startHost()` + 客户端子类 + 该应用私有的信号/打印/退出语义;混合体不建包,拼装写在 app 里。
|
||||
3. **需要 HTTP 承载才 import `dsh-host-webserver`**,否则零端口。
|
||||
|
||||
现有两种形态保持这一边界:Web 形态挂载 Host、载体与浏览器组合,而 `dsh run` 挂载直接使用核心服务的 runner,不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 `ctx.plugin(前门插件)` 挂载,不套 fetch。
|
||||
现有两个应用保持这一区分:Web 应用挂载 Host、载体与浏览器组合,而 `dsh --profile headless` 挂载直接使用核心服务的 runner,不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 `ctx.plugin(入口插件)` 挂载,不使用 fetch。
|
||||
|
||||
## 消息协议
|
||||
|
||||
@@ -114,7 +114,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig.
|
||||
|
||||
### RpcReceipt:载体回执
|
||||
|
||||
`ClientResponse` 的 HTTP 应答体是 `RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }`——载体层回执,**不是** RpcMessage(response 不再有 response);迟到/重复应答收 `not-pending`,逻辑收敛面是 `*/resolved` 帧。
|
||||
`ClientResponse` 的 HTTP 应答体是 `RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }`——载体层回执,**不是** RpcMessage(response 不再有 response);迟到/重复应答收 `not-pending`,逻辑收敛点是 `*/resolved` 帧。
|
||||
|
||||
## 类型体系:函数签名即事实源
|
||||
|
||||
@@ -213,8 +213,8 @@ export type ResponseValue<K> =
|
||||
|
||||
| 子类 | 所在包 | doFetch | 用途 |
|
||||
|---|---|---|---|
|
||||
| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧;载体测试与调用方可以在不打开端口的情况下运行这套协议,而产品 `dsh run` 直接驱动 core |
|
||||
| `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器形态;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md) |
|
||||
| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧;载体测试与调用方可以在不打开端口的情况下运行这套协议,而产品 `dsh --profile headless` 直接驱动 core |
|
||||
| `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器客户端;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md) |
|
||||
| `FixtureApiClient` | dsh-client-connection | 不用(协议层覆写) | 无 server 的 UI 开发(`?fixture`):覆写 `callUnary`/`openMux`/`openHost`/`respond` 虚方法,自己就是假 server(帧 rpcId 由它 mint,语义自洽) |
|
||||
| IPC 桥子类(假想示例——尚无此形态) | Electron 壳 | IPC 序列化往返 | 只需换 doFetch,约定/基类零改 |
|
||||
|
||||
@@ -232,15 +232,15 @@ export type ResponseValue<K> =
|
||||
|
||||
## Consequences
|
||||
|
||||
所有 client 形态消费同一约定:加一个 unary 方法是从单一签名辐射的五步机械改动,换载体只动一个 `doFetch` 子类,wire 上每条消息可 zod 校验、可经 envelope tap 观测、可按 rpcId 对账。普通 unary 调用仍受时限约束,而 `host.pickDirectory` 与 `command.execute` 可保持挂起,直到操作完成或调用方/连接取消到来;若由用户掌控节奏的操作不自行结束,请求可能一直挂起,这是为避免把合理的操作时长视为传输失败而接受的代价。其余接受的代价:两组包需要显式 tsconfig paths 条目;预留方法(fork/inject/task.list/listModels/hostInstanceId)在真实消费者出现前保持休眠。
|
||||
所有 client 使用同一约定:加一个 unary 方法是从单一签名出发的五步机械改动,换载体只动一个 `doFetch` 子类,wire 上每条消息可 zod 校验、可经 envelope tap 观测、可按 rpcId 对账。普通 unary 调用仍受时限约束,而 `host.pickDirectory` 与 `command.execute` 可保持挂起,直到操作完成或调用方/连接取消到来;若由用户掌控节奏的操作不自行结束,请求可能一直挂起,这是为避免把合理的操作时长视为传输失败而接受的代价。其余接受的代价:两组包需要显式 tsconfig paths 条目;预留方法(fork/inject/task.list/listModels/hostInstanceId)在真实消费者出现前保持休眠。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| 放弃项 | 一句话理由 |
|
||||
|---|---|
|
||||
| 按「产品形态」分包(web 一族、electron 一族) | 形态间共享的是 host/client 两侧能力而非形态本身;能力支持方分层让新形态零新包 |
|
||||
| 按产品分包(web 一族、electron 一族) | 产品共享的是 host/client 两侧能力,而不是某个应用实现;能力支持方分层让新应用零新包 |
|
||||
| 混合体建包(如 headless 独立包) | 混合体只有一个消费者(它自己的 app),建包是无主抽象;拼装写在 app 里可读可弃 |
|
||||
| 消费型 client 直连 ctx(省 apiproxy 一层) | client 形态需要 wire 校验、观测与多 client 一致性。直接 headless 是没有 client 边界的本地前门,使用公开的 Agent/Session seam,而不是 client 命令面 |
|
||||
| 消费型 client 直连 ctx(省 apiproxy 一层) | client 需要 wire 校验、观测与多 client 一致性。直接 headless 是没有 client 边界的本地入口,使用公开的 Agent/Session seam,而不是 client 命令面 |
|
||||
| webserver 依赖 runtime(省 handler 注入) | 结构 typing 注入让 webserver 可被 sidecar/测试复用且零 workspace 依赖;包依赖会把装配知识拖进承载层 |
|
||||
| 包名不带组前缀(沿用 dsh-<尾段>) | `dsh-runtime`/`dsh-web-ui` 在扁平 npm 命名空间里失去归属信息;代价只是每包一条显式 paths |
|
||||
| 复用仓内 JSON-RPC 2.0(dsh-jsonrpc) | 数字错误码退化成单码兜底、约定双份人肉对齐、命名无 convention 自然漂移 |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md
|
||||
2026-07-19-gui-web-client-architecture.md: 82b2f85708c423748954644d4991e2d54d42874a
|
||||
2026-07-19-gui-web-client-architecture.zh.md: c37252d1db291cae11db2a615c9e4005ece717da
|
||||
2026-07-19-gui-web-client-architecture.md: 070b857f14007f429826ab83b17b5c8fbd3d3d0b
|
||||
2026-07-19-gui-web-client-architecture.zh.md: 1f5bafe1dff878b5ca5ffcbdb9ed8ca38a863c9f
|
||||
|
||||
@@ -30,7 +30,7 @@ Both ends run cordis. The host is a cordis plugin tree; the browser runs a secon
|
||||
|
||||
## The client cordis tree and the loading chain
|
||||
|
||||
The loading chain — the two package kinds (plain vs dshClient plugin), the module-system/plugin-governor split, the two-phase boot over the host-authored entry graph with revisions, and hot reload — is owned by the [client plugin loading note](2026-07-23-client-plugin-loading-model.md). The load-bearing facts for this document: the browser boots the same vendored `@cordisjs/plugin-loader` as the host with a client module system (`ctx.modules`, `packages/client/modules`) filling its `internal` contract; every unit with product behavior is an entry in the host-authored `__DSH_BOOT__` graph — every production plugin package (infrastructure included) carries the `dshClient` declaration and arrives as a fetched `./client` tsdown closure bundle, `immediately` rows differing only in boot phase-one prefetch, while plain packages (react family, cordis, the not-yet-promoted libraries) stay shell-bundled, seeded, and invisible to the graph; bundles execute `window.__ModuleLoader__.load({ id, factory })` and their `require` is answered from the lazy CJS module table (seed words + registered factories, materialized and memoized on first require — cross-plugin value imports are a build error, cooperation goes through cordis services); plugin CSS is inlined in the bundle and injected as `<style data-plugin="<id>">` at materialization (CSS Modules hashing + ownership tag = isolation, removal on reload); hot reload is live in dev graphs — the webserver stat-polls the bundles it serves and broadcasts `rebuilt` SSE frames, and the `client-hmr` plugin swaps one fiber per frame. The settled flip (`loader.await()` + an all-ACTIVE sweep) still switches the shell from the loading page to the real UI in one pass — settled means every entry is created and every fiber reached ACTIVE, with FAILED/PENDING fibers listed loud; there is no partial-availability mode (progressive rendering is deferred work).
|
||||
The loading chain — the two package kinds (plain vs dsh.client plugin), the module-system/plugin-governor split, the two-phase boot over the host-authored entry graph with revisions, and hot reload — is owned by the [client plugin loading note](2026-07-23-client-plugin-loading-model.md). The load-bearing facts for this document: the browser boots the same vendored `@cordisjs/plugin-loader` as the host with a client module system (`ctx.modules`, `packages/client/modules`) filling its `internal` contract; every unit with product behavior is an entry in the host-authored `__DSH_BOOT__` graph — every production plugin package (infrastructure included) carries the `dsh.client` declaration and arrives as a fetched `./client` tsdown closure bundle, `immediately` rows differing only in boot phase-one prefetch, while plain packages (react family, cordis, the not-yet-promoted libraries) stay shell-bundled, seeded, and invisible to the graph; bundles execute `window.__ModuleLoader__.load({ id, factory })` and their `require` is answered from the lazy CJS module table (seed words + registered factories, materialized and memoized on first require — cross-plugin value imports are a build error, cooperation goes through cordis services); plugin CSS is inlined in the bundle and injected as `<style data-plugin="<id>">` at materialization (CSS Modules hashing + ownership tag = isolation, removal on reload); hot reload is live in dev graphs — the webserver stat-polls the bundles it serves and broadcasts `rebuilt` SSE frames, and the `client-hmr` plugin swaps one fiber per frame. The settled flip (`loader.await()` + an all-ACTIVE sweep) still switches the shell from the loading page to the real UI in one pass — settled means every entry is created and every fiber reached ACTIVE, with FAILED/PENDING fibers listed loud; there is no partial-availability mode (progressive rendering is deferred work).
|
||||
|
||||
Type universes stay split at the aggregate level — `tsconfig.host.json` is the host program and `tsconfig.client.json` the client program, both referenced by the solution root `tsconfig.json` — because both sides merge cordis `Context` under the same keys (`sessions`, `loader`) with different services; client packages consume the wire vocabulary through pure type subpaths (`@deepseek-ai/dsh-session/types` and kin) so no host augmentation rides into the client program.
|
||||
|
||||
@@ -42,7 +42,7 @@ Implementation homes: registry core and the props-share types in `packages/clien
|
||||
|
||||
## Services and scope addressing
|
||||
|
||||
A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer installation contract), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md).
|
||||
A service is a plugin's only API toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer installation contract), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md).
|
||||
|
||||
There is no component registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. Final Chat business Nodes dispatch through the keyed/session `'conversation.chat.node'` slot; ui-tool owns its `tool-call` entry, recursively renders the supplied `subCalls`, and declares the keyed/session `'tool.call.toolview'` child slot. The key space stays runtime-open (SlotMap declares slots, never keys), and roots and descendants dispatch by `entryKey: toolName` with `GenericToolCard` as the fallback. Business packages register atomic views through `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '<tool>' }, Row))`; the declaration is the load and reload dependency ([decision](2026-08-05-slot-declaration-injection.md)). ui-conversation separately delegates the selected call's details body through `'conversation.details.tool'`, so ui-tool's card models remain the single presentation owner without making conversation import Tool components. The target-neutral event and view registries are data assembly seams rather than parallel component registries ([decision](2026-08-09-client-conversation-node-assembly.md)).
|
||||
|
||||
@@ -108,7 +108,7 @@ Domain implementation files never import a sibling domain; shared surfaces route
|
||||
|
||||
## How to develop
|
||||
|
||||
- **A new UI feature** = a new plugin package: declare `dshClient` (+ `inject` topology) in package.json, write the browser half under `src/client/` (apply mounts services/stores and registers slots), keep the node half an empty apply unless there is host logic, build with the shared preset. Add the plugin to the host config; the manifest and loading follow automatically.
|
||||
- **A new UI feature** = a new plugin package: declare `dsh.client` (+ `inject` topology) in package.json, write the browser half under `src/client/` (apply mounts services/stores and registers slots), keep the node half an empty apply unless there is host logic, build with the shared preset. Add the plugin to the host config; the manifest and loading follow automatically.
|
||||
- **A new slot**: see the [slot system standard note](2026-07-22-slot-type-chain-implementation.md) — merge the contract into `SlotMap`, declare it in the parent entry's `children`, render through the auto-injected `renderSlot` prop. Never export components globally.
|
||||
- **Consuming a new frame type**: transport-only session frames → Session's dispatch switch; host-level frames → the Manager routing table; logged conversation business events → a Definition plus a keyed view renderer, without a Session business branch.
|
||||
- **Where does this state live**: business data (events, streaming, pending) → always the object layer; what the parent knows → owner props at the renderSlot site; private to one component (scroll, search text, expansion) → component state; shared across entries or surviving remounts (selection, drafts, panel widths) → an entry-declared store ([slot system standard](2026-07-22-slot-type-chain-implementation.md)).
|
||||
|
||||
@@ -30,7 +30,7 @@ Status: implemented
|
||||
|
||||
## client cordis 树与装载链
|
||||
|
||||
装载链——两类包(普通包 vs dshClient 插件)、模块系统/插件治理器之分、host 独家撰写的带修订号 entry 图之上的双层 boot、热重载——归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有。本篇赖以立足的事实:浏览器启动与 host 相同的 vendored `@cordisjs/plugin-loader`,由 client 模块系统(`ctx.modules`,`packages/client/modules`)填上其 `internal` 约定;凡带产品行为的单元都是 host 独家撰写的 `__DSH_BOOT__` 图里的 entry——每个生产插件包(含基础设施)都携带 `dshClient` 声明、以 fetch 到达的 `./client` tsdown 闭包 bundle 供给,`immediately` 行的差别仅在 boot 第一层预取,而普通包(react 家族、cordis、尚未升格的库)保持打进壳、已播种、对图不可见;bundle 执行 `window.__ModuleLoader__.load({ id, factory })`,其 `require` 由 lazy CJS 模块表应答(种子词条 + 已登记工厂,首次 require 时物化并记忆化——跨插件值 import 是构建错误,协作走 cordis 服务);插件 CSS 内联在 bundle 里、物化时注入为 `<style data-plugin="<id>">`(CSS Modules 哈希 + 归属标记 = 隔离,重载时移除);热重载已在 dev 图落地——webserver 对自己供给的 bundle 做 stat 轮询并广播 `rebuilt` SSE 帧,`client-hmr` 插件每帧换掉一个 fiber。settled 翻转(`loader.await()` + 一次全 ACTIVE 扫描)依旧让壳从 loading 页一次切换到真 UI——settled 意味着每个 entry 已创建、每个 fiber 都到达 ACTIVE,FAILED/PENDING 的 fiber 被大声列出;不存在部分可用模式(渐进渲染为后置工作)。
|
||||
装载链——两类包(普通包 vs dsh.client 插件)、模块系统/插件治理器之分、host 独家撰写的带修订号 entry 图之上的双层 boot、热重载——归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有。本篇赖以立足的事实:浏览器启动与 host 相同的 vendored `@cordisjs/plugin-loader`,由 client 模块系统(`ctx.modules`,`packages/client/modules`)填上其 `internal` 约定;凡带产品行为的单元都是 host 独家撰写的 `__DSH_BOOT__` 图里的 entry——每个生产插件包(含基础设施)都携带 `dsh.client` 声明、以 fetch 到达的 `./client` tsdown 闭包 bundle 供给,`immediately` 行的差别仅在 boot 第一层预取,而普通包(react 家族、cordis、尚未升格的库)保持打进壳、已播种、对图不可见;bundle 执行 `window.__ModuleLoader__.load({ id, factory })`,其 `require` 由 lazy CJS 模块表应答(种子词条 + 已登记工厂,首次 require 时物化并记忆化——跨插件值 import 是构建错误,协作走 cordis 服务);插件 CSS 内联在 bundle 里、物化时注入为 `<style data-plugin="<id>">`(CSS Modules 哈希 + 归属标记 = 隔离,重载时移除);热重载已在 dev 图落地——webserver 对自己供给的 bundle 做 stat 轮询并广播 `rebuilt` SSE 帧,`client-hmr` 插件每帧换掉一个 fiber。settled 翻转(`loader.await()` + 一次全 ACTIVE 扫描)依旧让壳从 loading 页一次切换到真 UI——settled 意味着每个 entry 已创建、每个 fiber 都到达 ACTIVE,FAILED/PENDING 的 fiber 被大声列出;不存在部分可用模式(渐进渲染为后置工作)。
|
||||
|
||||
类型宇宙在聚合层拆分——`tsconfig.host.json` 是 host program、`tsconfig.client.json` 是 client program,二者由 solution 根 `tsconfig.json` 引用,因为两侧都在相同键(`sessions`、`loader`)上对 cordis `Context` 做声明合并且服务不同;client 包经纯类型子路径(`@deepseek-ai/dsh-session/types` 等)消费协议词汇,host 侧的声明合并不会搭车进入 client program。
|
||||
|
||||
@@ -108,7 +108,7 @@ src/client/
|
||||
|
||||
## 怎么开发
|
||||
|
||||
- **新 UI 功能** = 新插件包:package.json 声明 `dshClient`(+ `inject` 拓扑),浏览器半边写在 `src/client/`(apply 挂服务/建 store、注册 slot),无 host 逻辑时 node 半边保持空 apply,用共享预设构建。把插件加进 host 配置;manifest 与装载随之自动跟上。
|
||||
- **新 UI 功能** = 新插件包:package.json 声明 `dsh.client`(+ `inject` 拓扑),浏览器半边写在 `src/client/`(apply 挂服务/建 store、注册 slot),无 host 逻辑时 node 半边保持空 apply,用共享预设构建。把插件加进 host 配置;manifest 与装载随之自动跟上。
|
||||
- **新 slot**:见 [slot 体系标准笔记](2026-07-22-slot-type-chain-implementation.md)——约定合并进 `SlotMap`,在父 entry 的 `children` 里声明,经自动注入的 `renderSlot` prop 渲染。永不全局导出组件。
|
||||
- **消费新帧类型**:纯传输 session frame → Session 分发 switch;host 级 frame → Manager 路由表;已记录的 conversation 业务事件 → Definition 加 keyed view renderer,不增加 Session 业务分支。
|
||||
- **状态住哪**:业务数据(事件、流式、待答)→ 永远对象层;父知道的 → renderSlot 现场的 owner props;单组件私有(滚动、搜索词、展开集)→ 组件状态;跨 entry 共享或跨重挂载存活(选中、草稿、面板宽)→ entry 声明的 store([slot 体系标准](2026-07-22-slot-type-chain-implementation.md))。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md
|
||||
2026-07-19-package-invariant-runtime-contracts.md: c1a5aa1e55965b07b34ce307375d77e75cdeb9be
|
||||
2026-07-19-package-invariant-runtime-contracts.zh.md: 29fc2ecf2937b7557e16d972ad71230c0e304617
|
||||
2026-07-19-package-invariant-runtime-contracts.md: 6e6516c2fa629aa736c178be4acbc8b42fd9a0b9
|
||||
2026-07-19-package-invariant-runtime-contracts.zh.md: a7432ca0320646c57b29fbfd64c679b94c9861af
|
||||
|
||||
@@ -73,6 +73,6 @@ Vitest mounts `InvariantService` with `{ enabled: true }` for every package test
|
||||
|
||||
- Every package has visible ownership and publication wiring, but only packages with a plausible runtime relation add listeners or trace state.
|
||||
- Empty companions remain reviewable decisions with package-specific explanations and fail the gate if the explanation is removed.
|
||||
- Type declarations, Cordis loadability, plugin metadata, service method surfaces, and pure algebra remain covered by their owning compile, load, unit, or integration gates.
|
||||
- Type declarations, Cordis loadability, plugin metadata, service method APIs, and pure algebra remain covered by their owning compile, load, unit, or integration gates.
|
||||
- Runtime failures identify the owning npm package and point to an inconsistent observation rather than restating a required API shape.
|
||||
- The original selection, blocklist precedence, duplicate ownership, rollback, disposal, and HMR service contracts remain unchanged.
|
||||
|
||||
@@ -73,6 +73,6 @@ Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantServi
|
||||
|
||||
- 每个包都有可见的所有权与发布 wiring,但只有具备合理运行时关系的包才会增加 listener 或 trace 状态。
|
||||
- 空 companion 是带包专属说明、可评审的决策;删除说明后门禁会失败。
|
||||
- 类型声明、Cordis 可加载性、插件 metadata、服务方法形状和纯代数继续由所属的编译、加载、单元或集成门禁覆盖。
|
||||
- 类型声明、Cordis 可加载性、插件 metadata、服务方法 API 和纯代数继续由所属的编译、加载、单元或集成门禁覆盖。
|
||||
- 运行时失败会标明所属 npm 包,并指出不一致的观测,而不是复述必要的 API 形状。
|
||||
- 原有 selection、blocklist 优先级、重复所有权、回滚、dispose 和 HMR(热模块替换)服务约定保持不变。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-package-owned-invariant-service.md
|
||||
2026-07-19-package-owned-invariant-service.md: 296c55b21b947d32412acff54715d3687cf9c43b
|
||||
2026-07-19-package-owned-invariant-service.zh.md: 8aa776feabdef84a4007dc317115e6d72f6bbc50
|
||||
2026-07-19-package-owned-invariant-service.md: f1918ec1d31f9d91538b6b92070d98217567c5c5
|
||||
2026-07-19-package-owned-invariant-service.zh.md: 71354b3ceabfc102eae6004399644a308d78c253
|
||||
|
||||
@@ -49,11 +49,11 @@ Blocklist matches override allowlist matches. Each list entry is a case-sensitiv
|
||||
|
||||
The public registration boundary is `ctx.invariants.register(packageName, installer)`. It reserves one active registration per full npm package name even when filters disable installation, and returns the effect disposer. Disposing the companion or service releases the reservation and all contribution state.
|
||||
|
||||
An enabled installer runs in a dedicated child Cordis fiber owned by the service. `InvariantInstaller.inject` declares the child fiber's service surface explicitly; the registry carries no product-specific dependency metadata. The service joins a returned installer promise before registration succeeds, so asynchronous startup checks remain transactional. The installer receives a bound `fail(message)` reporter. Calling it throws an `Error` subclass named `InvariantError` with stable code `INVARIANT` and the registering `packageName`; it does not extend a product-package error base.
|
||||
An enabled installer runs in a dedicated child Cordis fiber owned by the service. `InvariantInstaller.inject` declares the child fiber's service API explicitly; the registry carries no product-specific dependency metadata. The service joins a returned installer promise before registration succeeds, so asynchronous startup checks remain transactional. The installer receives a bound `fail(message)` reporter. Calling it throws an `Error` subclass named `InvariantError` with stable code `INVARIANT` and the registering `packageName`; it does not extend a product-package error base.
|
||||
|
||||
Registration setup is transactional. If an installer fails after registering listeners, the child fiber is disposed completely and the name reservation is released before the failure escapes. Filtered registrations create no child but retain their reservation until disposal. Reloading a companion therefore begins with one clean installer state; stateful contributions rebuild baselines from their owning services.
|
||||
|
||||
The former functional-plugin entrypoint and one-argument `InvariantError` constructor are not retained as compatibility surfaces. The repository is pre-release and all call sites move to the service and package-attributed error together.
|
||||
The former functional-plugin entry point and one-argument `InvariantError` constructor are not retained as compatibility APIs. The repository is pre-release and all call sites move to the service and package-attributed error together.
|
||||
|
||||
### Initial stateful companions and exhaustive ownership
|
||||
|
||||
@@ -76,7 +76,7 @@ The generated scoped-event subject resolver lives in `dsh-scope`, beside the con
|
||||
|
||||
The example agent spine mounts the service and all four stateful companion subpaths, forwarding `enabled`, `package_allowlist`, and `package_blocklist` to the service. Generated SDK Cordis composition emits the same entries. A subpath entry adds its installable root npm package rather than treating the subpath as a package name. The shipped `dsh` TUI and Web config trees omit the service and companions under the [shipped-config decision](../simplification/2026-08-03-omit-invariants-from-shipped-config.md).
|
||||
|
||||
Workspace constraints recognize the separate invariant bundle, and package exports, project references, build configuration, dependency declarations, and the lockfile describe the same publication surface. Generated config catalogs, module graphs, and API documentation derive from those sources.
|
||||
Workspace constraints recognize the separate invariant bundle, and package exports, project references, build configuration, dependency declarations, and the lockfile describe the same publication metadata. Generated config catalogs, module graphs, and API documentation derive from those sources.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -49,11 +49,11 @@ blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写
|
||||
|
||||
公开注册边界是 `ctx.invariants.register(packageName, installer)`。即使过滤器禁止安装,它也会为每个完整 npm 包名保留唯一的活跃注册,并返回 effect disposer。卸载伴随插件或服务都会释放注册名及全部贡献状态。
|
||||
|
||||
启用的 installer 在服务拥有的独立 Cordis 子 fiber 中运行。`InvariantInstaller.inject` 显式声明该子 fiber 的服务表面;注册服务不携带产品专用依赖元数据。服务会在注册成功前等待 installer 返回的 promise,因此异步启动检查仍具有事务性。installer 接收绑定后的 `fail(message)` 报告器。调用它会抛出名为 `InvariantError` 的 `Error` 子类,保留稳定代码 `INVARIANT` 并记录注册方 `packageName`;该错误不继承产品包中的错误基类。
|
||||
启用的 installer 在服务拥有的独立 Cordis 子 fiber 中运行。`InvariantInstaller.inject` 显式声明该子 fiber 的服务 API;注册服务不携带产品专用依赖元数据。服务会在注册成功前等待 installer 返回的 promise,因此异步启动检查仍具有事务性。installer 接收绑定后的 `fail(message)` 报告器。调用它会抛出名为 `InvariantError` 的 `Error` 子类,保留稳定代码 `INVARIANT` 并记录注册方 `packageName`;该错误不继承产品包中的错误基类。
|
||||
|
||||
注册启动是事务性的。如果 installer 在注册监听器后失败,子 fiber 会完整释放,并在失败向外传播前解除包名占用。被过滤的注册不创建子 fiber,但会保留占用直到 dispose。伴随插件重载时总会从干净的 installer 状态开始;有状态贡献从其所属服务重建基线。
|
||||
|
||||
原有函数式插件入口与单参数 `InvariantError` 构造函数不作为兼容表面保留。仓库尚未发布,所有调用方会一起迁移到服务和带包归属的错误。
|
||||
原有函数式插件入口与单参数 `InvariantError` 构造函数不作为兼容 API 保留。仓库尚未发布,所有调用方会一起迁移到服务和带包归属的错误。
|
||||
|
||||
### 首批有状态伴随插件与完整所有权
|
||||
|
||||
@@ -76,7 +76,7 @@ blocklist 匹配优先于 allowlist 匹配。每个条目都是区分大小写
|
||||
|
||||
示例 agent spine 会挂载服务和四个有状态伴随子路径,并把 `enabled`、`package_allowlist` 与 `package_blocklist` 转发给服务。生成的 SDK Cordis 组合输出相同条目。子路径条目添加可安装的根 npm 包,而不会把子路径误当成包名。根据[交付配置决策](../simplification/2026-08-03-omit-invariants-from-shipped-config.md),交付的 `dsh` TUI 与 Web 配置树会省略该服务及其伴随插件。
|
||||
|
||||
Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、构建配置、依赖声明和 lockfile 描述同一发布表面。生成的配置目录、模块图和 API 文档都从这些源派生。
|
||||
Workspace 约束识别独立的不变式 bundle;包 exports、项目引用、构建配置、依赖声明和 lockfile 描述同一份发布元数据。生成的配置目录、模块图和 API 文档都从这些源派生。
|
||||
|
||||
## 测试
|
||||
|
||||
|
||||
@@ -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-22-slot-type-chain-implementation.md
|
||||
2026-07-22-slot-type-chain-implementation.md: 9930aaa62bd4b1f37dac9736517524b7e01b1746
|
||||
2026-07-22-slot-type-chain-implementation.zh.md: 2778d223cd89b4394bd629661dbb58105e6655cb
|
||||
2026-07-22-slot-type-chain-implementation.md: 41a5c4592b22cc66d2f717794534305972c89eb3
|
||||
2026-07-22-slot-type-chain-implementation.zh.md: 16b923376c7e6de63cb556cb666a0e35bfd9f080
|
||||
|
||||
@@ -78,7 +78,7 @@ export function createChatStore() {
|
||||
|
||||
One factory, three consumption points: (a) `register` — pass the factory for an exclusive store, or call it once in `apply` and pass the same handle to several registers to share the instance (cross-plugin sharing is constructively impossible: the handle never leaves the package); (b) `PropsStore<ReturnType<typeof createChatStore>>` derives the component's store share with zero hand-written members; (c) tests call the factory and `.create()` a real engine instance, feeding `useSelector`/`actions` straight in as props — production outlets run the very same `create` path, so there is no second machinery.
|
||||
|
||||
Store scope is **derived from the mounting entry's scope** (session slot → one instance per session, living and dying with the session; root slot → one per entry). Read = `props.useStore`; write = `props.actions.*` only — the raw instance (with `update`/`set`) never reaches a component, so the declared actions are the complete, auditable mutation surface. Production code never calls the factory or `create` outside `apply`.
|
||||
Store scope is **derived from the mounting entry's scope** (session slot → one instance per session, living and dying with the session; root slot → one per entry). Read = `props.useStore`; write = `props.actions.*` only — the raw instance (with `update`/`set`) never reaches a component, so the declared actions are the complete, auditable mutation API. Production code never calls the factory or `create` outside `apply`.
|
||||
|
||||
### inject: the registrant's business face, on its own ctx
|
||||
|
||||
@@ -103,19 +103,19 @@ Two hardening decisions in the register signature exist because the obvious alte
|
||||
|
||||
## Consequences
|
||||
|
||||
Render authority is enforceable rather than conventional: who renders what is a load-time fact, and auditing the UI structure = reading the register calls; for chain slots, WHO renders is additionally a render-time fact, but the deciding selectors are register-site declarations, so the audit surface stays the register calls. Every props surface is statically derived from one source (SlotMap entry, children keys, store factory, inject return), so a schema change propagates by compiler rather than by grep. Plugins carry no subscription machinery of their own — store lifecycle (per-session instances, disposal, persistence) is framework semantics keyed to the entry axis. Costs: registration options are dense (children spec objects); the framework carries real inference machinery (`defineStore`'s init/actions same-round inference may need a curried fallback); and the compile-time double locks mean prototype-stage drift is a hard error, not a warning.
|
||||
Render authority is enforceable rather than conventional: who renders what is a load-time fact, and auditing the UI structure = reading the register calls; for chain slots, WHO renders is additionally a render-time fact, but the deciding selectors are register-site declarations, so the audit scope stays the register calls. Every props API is statically derived from one source (SlotMap entry, children keys, store factory, inject return), so a schema change propagates by compiler rather than by grep. Plugins carry no subscription machinery of their own — store lifecycle (per-session instances, disposal, persistence) is framework semantics keyed to the entry axis. Costs: registration options are dense (children spec objects); the framework carries real inference machinery (`defineStore`'s init/actions same-round inference may need a curried fallback); and the compile-time double locks mean prototype-stage drift is a hard error, not a warning.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| Rejected | One-line reason |
|
||||
|---|---|
|
||||
| Separate define/register two-step API | The split leaves render authority unenforced and invites ordering bugs; children-in-register settles declaration, authorization, and spec in one visible place |
|
||||
| Whitelist face objects (`ScopedSlots` + narrowing helpers) | With the whitelist already in the component's props type, the face is derivable by machinery; a mintable face object is a third authority surface with runtime-only checks |
|
||||
| Whitelist face objects (`ScopedSlots` + narrowing helpers) | With the whitelist already in the component's props type, the face is derivable by machinery; a mintable face object is a third authority API with runtime-only checks |
|
||||
| Assembly handles carrying root ctx into inject | Bypasses declared inject topology — every factory could reach every service, so package.json dependency declarations stop meaning anything |
|
||||
| `children` as a key array | kind/scope are runtime dispatch data; SlotMap is erased, so an array forces a second spec-registration API — a definition API reborn |
|
||||
| Business hand-made hooks / raw observables in component props | Every plugin becomes its own subscription machine; the inject `hooks` compartment carries the same facts through the one audited binding machinery |
|
||||
| Module-level store handles | A module-scope handle is a singleton across plugin reloads and test cases; the factory form scopes identity to apply/test invocation |
|
||||
| Components receiving the store instance | `update`/`set` in render code makes the mutation surface unauditable; declared actions keep "what can change" a register-site fact |
|
||||
| Components receiving the store instance | `update`/`set` in render code makes the mutation API unauditable; declared actions keep "what can change" a register-site fact |
|
||||
| `FC` at the register position / inferring `I` from the component | FC statics generate covariant noise that rejects valid components; component-side inference absorbs props drift silently (see rulings above) |
|
||||
| Keyed dispatch with owner-side routing for takeover slots | The owner accumulates per-entry contracts and a hardcoded routing table (`find` + `entryKey` per takeover); the chain currency keeps new takeover registrations at zero owner edits |
|
||||
| Components declining by rendering null | Declining requires mounting first — hooks and effects run for nothing, and mount/unmount churn breaks memoization and key semantics; a pure selector decides without a component instance |
|
||||
|
||||
@@ -110,12 +110,12 @@ register 签名里的两条硬化裁定之所以存在,是因为显然的替
|
||||
| Rejected | One-line reason |
|
||||
|---|---|
|
||||
| 独立的 define/register 两步式 API | 拆分让渲染权威无从强制、招来时序 bug;children 进 register 让声明、授权、spec 在同一个可见位置结清 |
|
||||
| 白名单面对象(`ScopedSlots` + 收窄辅助件) | 白名单已在组件的 props 类型里,面可由机械推导;可铸造的面对象是第三个权威面,且只有运行时校验 |
|
||||
| 白名单面对象(`ScopedSlots` + 收窄辅助件) | 白名单已在组件的 props 类型里,该对象可由机械推导;可铸造的面对象是第三套权威 API,且只有运行时校验 |
|
||||
| 装配句柄把 root ctx 带进 inject | 绕开声明的 inject 拓扑——每个工厂都摸得到每个服务,package.json 的依赖声明就此失去意义 |
|
||||
| `children` 用键数组形 | kind/scope 是运行时分派数据;SlotMap 已被擦除,数组形必然逼出第二个 spec 注册 API——定义 API 复活 |
|
||||
| 业务手造 hook / 组件 props 里递裸 observable | 每个插件都变成自己的订阅机械;inject `hooks` 格让同样的事实走那一台受审计的绑定机械 |
|
||||
| 模块级 store 句柄 | 模块级句柄是跨插件重载与跨测试用例的单例;工厂形把身份圈定在单次 apply/测试调用内 |
|
||||
| 组件直收 store 实例 | 渲染代码里能用 `update`/`set`,变更面就无从审计;声明的 actions 让「什么能变」保持为 register 现场的事实 |
|
||||
| 组件直收 store 实例 | 渲染代码里能用 `update`/`set`,变更 API 就无从审计;声明的 actions 让「什么能变」保持为 register 现场的事实 |
|
||||
| 注册位用 `FC` / 从组件推断 `I` | FC 静态位产生协变噪音、拒绝合法组件;组件侧推断静默吸收 props 漂移(见上文裁定) |
|
||||
| 接管 slot 用 keyed 分派 + owner 侧路由 | owner 会不断攒下逐 entry 约定与硬编码路由表(每种接管一份 `find` + `entryKey`);chain 货币让新增接管注册保持 owner 零改动 |
|
||||
| 组件靠渲染 null 表示不接 | 不接也得先挂载——hook 与 effect 白跑,挂载/卸载抖动破坏 memo 化与 key 语义;纯选择器无需组件实例即可裁决 |
|
||||
|
||||
@@ -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-23-client-plugin-loading-model.md
|
||||
2026-07-23-client-plugin-loading-model.md: fff96f65a21d9527c8fa49589b178c490bacdd5a
|
||||
2026-07-23-client-plugin-loading-model.zh.md: 0c0c95ba7ebffca33c2c2d1dec13f745c4316f43
|
||||
2026-07-23-client-plugin-loading-model.md: 3347bdac95eb8e06be3e7c20d24319103f738149
|
||||
2026-07-23-client-plugin-loading-model.zh.md: d52409e167b536162a8efb9a9ecc3092f6e5d1d2
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Agent Note: Client plugin loading — plain packages, dshClient plugins, and the two-phase boot
|
||||
# Agent Note: Client plugin loading — plain packages, dsh.client plugins, and the two-phase boot
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -18,22 +18,22 @@ The lower layer supplies four capabilities: externals (the platform list), remot
|
||||
|
||||
Plugin bundles are built independently outside Vite's module graph. Feeding response text into an inline script leaves the browser with a dynamic source execution: no standard source-map chain connects the network resource, generated bundle, and TypeScript/TSX source, so performance profiles and stacks stop at generated `client.js`; the module system must also buffer the complete source and split one arrival responsibility across fetch and execute transport boundaries.
|
||||
|
||||
On top of that, client and host plugins register and load consistently: a package declares `dshClient` once, the host scans the declaration into the boot graph, and the same Loader semantics govern entries on both sides.
|
||||
On top of that, client and host plugins register and load consistently: a package declares `dsh.client` once, the host scans the declaration into the boot graph, and the same Loader semantics govern entries on both sides.
|
||||
|
||||
The first-generation client loader (`createClientLoader`) hand-wrote both layers in one function. The fusion left no unload/reload path (loads were one-shot, style tags never removed), hand-copied dependency lists that had already drifted across three files, and a module-table backdoor for cross-plugin imports that duplicated cordis's service mechanism while making load order a correctness constraint. The structure below replaced it.
|
||||
|
||||
## Decision
|
||||
|
||||
### Two package kinds; `dshClient` means plugin, period
|
||||
### Two package kinds; `dsh.client` means plugin, period
|
||||
|
||||
What makes a package a plugin? One rule: **a package is a plugin package once its consumption is cordis dependency injection; until then it is a plain package.** How code reaches the page is not part of the taxonomy — arrival follows from the kind instead of defining it.
|
||||
|
||||
- **Plain packages** are the absolute base the module system itself needs, plus libraries not yet converted to DI: the react family, cordis, `@deepseek-ai/dsh-client-modules` (the module system itself — it can never be a plugin, because modules precede all modules), the web shell kernel, and — for now — ui-slots, web-react, ui-primitives. Plain packages are shell-bundled, seeded into the module table, and invisible to the host graph.
|
||||
- **Plugin packages** are everything else. Each one carries a `dshClient` manifest declaration (`{ platform, inject, immediately? }`) and one uniform shape: the shared tsdown preset emits `lib/client.js`, and `exports["./client"]` points at that bundle. Each is a governed entry of the host-authored graph. The current set is connection, runtime, ui-theme, i18n, hmr (dev graphs only), ui-layout, ui-sidebar, ui-conversation, ui-model-selector, ui-question, and ui-trajectory.
|
||||
- **Plugin packages** are everything else. Each one carries a `dsh.client` manifest declaration (`{ platform, inject, immediately? }`) and one uniform shape: the shared tsdown preset emits `lib/client.js`, and `exports["./client"]` points at that bundle. Each is a governed entry of the host-authored graph. The current set is connection, runtime, ui-theme, i18n, hmr (dev graphs only), ui-layout, ui-sidebar, ui-conversation, ui-model-selector, ui-question, and ui-trajectory.
|
||||
|
||||
The manifest owns the package's loading contract: its `inject` dependency edges, plus the optional `immediately` prefetch mark (absent means lazy). The composing app owns only the roster and the `--dev` switch.
|
||||
The manifest owns the package's loading contract: its `inject` dependency edges, plus the optional `immediately` prefetch mark (absent means lazy). The composing app owns only the roster.
|
||||
|
||||
To add a plugin package: declare `dshClient`, emit the `./client` bundle through the shared preset, add the name to the composing app's roster. Nothing else changes hands.
|
||||
To add a plugin package: declare `dsh.client`, emit the `./client` bundle through the shared preset, add the name to the composing app's roster. Nothing else changes hands.
|
||||
|
||||
When does a plain package become a plugin? The upgrade law, recorded so the migration path stays honest: **a plain package becomes a plugin package when its consumers switch to cordis DI, not before.** Three promotions are queued: ui-slots (the slots machinery now living in runtime — SlotsService, the renderer contract, the root slot), web-react (the renderer install moving into its own `apply`), and ui-primitives (once components are served through slots/services). Until then they stay plain, and their symbol exports stay ordinary static imports.
|
||||
|
||||
@@ -46,7 +46,7 @@ Four edge rules govern imports across the two kinds. None of them depends on any
|
||||
|
||||
### One module system, one plugin governor
|
||||
|
||||
The browser mirrors the host's division of labor. `dsh-client-modules` (`ClientModuleSystem`) takes the module-system seat that Node's internal ESM loader holds host-side; the same vendored `@cordisjs/plugin-loader` keeps the governance seat on both sides. The line between them in one sentence: **the module system owns module identity and bytes — how code arrives, registers, and becomes an export surface; the Loader owns plugin lifecycle — when a plugin mounts, what it waits for, and how it is torn down.**
|
||||
The browser mirrors the host's division of labor. `dsh-client-modules` (`ClientModuleSystem`) takes the module-system seat that Node's internal ESM loader holds host-side; the same vendored `@cordisjs/plugin-loader` keeps the governance seat on both sides. The line between them in one sentence: **the module system owns module identity and bytes — how code arrives, registers, and becomes an exports; the Loader owns plugin lifecycle — when a plugin mounts, what it waits for, and how it is torn down.**
|
||||
|
||||
`ClientModuleSystem` is a lazy CJS table. Executing a bundle only **registers** its factory — the bundle calls `window.__ModuleLoader__.load({ id, factory })` and nothing else happens. Every module body side effect, CSS injection included, lives inside the factory closure and runs at materialization: the first `require`/import of that id, memoized after that. A factory that requires a registered-but-unmaterialized sibling materializes it recursively, so no sort order exists anywhere. When asked to import an id, the table resolves through a fixed branch order: seed word → memoized record → static registration (shell-own modules, e.g. app-shell) → registered factory → graph-row external classic-script load → loud throw. That final throw is the runtime mirror of the build-time purity gate. The system also keeps per-module bookkeeping — owned `<style data-plugin>` tag ids, observed require edges — and exposes the two verbs HMR needs: `prefetch(id)` (load the script and register its factory; concurrent calls share one in-flight task) and `invalidate(id)` (drop the factory and record so the next arrival reloads it).
|
||||
|
||||
@@ -66,11 +66,11 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
|
||||
|
||||
**Host side — compose the graph.**
|
||||
|
||||
1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the host activation audit so the same check covers it. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)).
|
||||
2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dshClient` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the host audit reports either error from the FAILED fiber.
|
||||
1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, including the always-mounted `client-hmr` row. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)).
|
||||
2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dsh.client` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the host audit reports either error from the FAILED fiber.
|
||||
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Each bundle's content hash is its `rev` (cache busting + HMR diff anchor), the row set hashes into `graph.rev`, and every row is served as a script resource at `/plugins/<id>/client.js?rev=…`, with its source map at the same path plus `.map`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph (it is a plain route-registration plugin; modules registers the bundle route and taps the index render itself).
|
||||
|
||||
Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a dshClient package existing in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.
|
||||
Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a package declaring `dsh.client` in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.
|
||||
|
||||
**Phase one — the module face.** The shell builds the module system over the graph, then prefetches every `immediately` row in parallel. Prefetch loads the external script and registers its factory only. A single row's prefetch failure is swallowed here: phase two's import retries the load and owns the loud failure, so one bad row cannot mask the others. `immediately` is a prefetch mark — not a barrier, not an identity. The package declares it, the registry carries it into the row. The infrastructure plugins (connection, runtime, ui-theme, i18n, plus hmr) declare it; UI plugins simply arrive on demand.
|
||||
|
||||
@@ -84,9 +84,9 @@ Why is the roster yml rows and not a scan? Because which plugins compose into a
|
||||
|
||||
### Hot reload: one driver plugin, self-watched bundles
|
||||
|
||||
Whether hot reload is active is a composition decision: dev compositions mount the `client-hmr` row (a normal plugin package, appended by `--dev`) whose node half brings the bundle watch and the SSE channel; prod compositions mount nothing and have neither.
|
||||
Hot reload is a composition decision: the web bundle mounts the `client-hmr` row (a normal plugin package) unconditionally; its node half brings the bundle watch and the SSE channel, and the chain stays idle until a rebuild watcher rewrites client bundles. A composition that must not expose it disables the row.
|
||||
|
||||
How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. It reads bundle paths from `ctx.clientModuleHost.clientPath(id)`, and one HMR-owned interval stat-polls every current graph row. Adding a row is ordered as synchronous stat baseline, then immediate `clientModuleHost.rebuilt(id)`: a write after the module host's graph hash but before that baseline is caught by the immediate re-hash, while a write after the baseline leaves a stat delta for the next poll. This avoids `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a bundle missing at poll time keeps its row dirty so reappearance forces a re-hash even with identical metadata. On a mtime/size delta or dirty row, `clientModuleHost.rebuilt(id)` is the single re-hash entry point; when the `rev` actually changed, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding bundles is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, its package list dshClient-discovered by scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read self-heals: stats keep changing while the write completes, so the next poll re-hashes and broadcasts the final rev.
|
||||
How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. It reads bundle paths from `ctx.clientModuleHost.clientPath(id)`, and one HMR-owned interval stat-polls every current graph row. Adding a row is ordered as synchronous stat baseline, then immediate `clientModuleHost.rebuilt(id)`: a write after the module host's graph hash but before that baseline is caught by the immediate re-hash, while a write after the baseline leaves a stat delta for the next poll. This avoids `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a bundle missing at poll time keeps its row dirty so reappearance forces a re-hash even with identical metadata. On a mtime/size delta or dirty row, `clientModuleHost.rebuilt(id)` is the single re-hash entry point; when the `rev` actually changed, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding bundles is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read self-heals: stats keep changing while the write completes, so the next poll re-hashes and broadcasts the final rev.
|
||||
|
||||
On the browser side, the driver reloads one plugin per frame, serialized:
|
||||
|
||||
@@ -113,18 +113,18 @@ The support boundary, stated honestly. Reload is coarse by design: fresh fiber,
|
||||
| `dsh-client-ui-slots` | slot registry core | plain, seeded | promote to plugin; receive runtime's slots machinery |
|
||||
| `dsh-client-web-react` | ctx↔React glue | plain, seeded | promote to plugin; renderer install moves into its apply |
|
||||
| `dsh-client-ui-primitives` | base components | plain, seeded | promote to plugin (components via slots/services) |
|
||||
| `dsh-client-connection` | wire layer | plugin (dshClient + bundle), declares `immediately` | transport swap (Electron IPC carrier) |
|
||||
| `dsh-client-connection` | wire layer | plugin (`dsh.client` + bundle), declares `immediately` | transport swap (Electron IPC carrier) |
|
||||
| `dsh-client-runtime` | session object layer + slots service + store engine | plugin, declares `immediately` | keeps shrinking toward a pure session object layer |
|
||||
| `dsh-client-ui-theme` | theme tokens/service | plugin, declares `immediately`, plus the `./styles/*` source channel | Theme Registry (separate ruling) |
|
||||
| `dsh-client-i18n` | I18nService | plugin, declares `immediately` | per-deployment locale composition |
|
||||
| `dsh-client-hmr` | hot reload driver | plugin, declares `immediately`; dev graphs only | rollback; reconnect handshake |
|
||||
| `dsh-client-hmr` | hot reload driver | plugin, declares `immediately` | rollback; reconnect handshake |
|
||||
| ui-layout / ui-sidebar / ui-conversation / ui-trajectory | UI features | plugins, on-demand | conversation domain split; trajectory real implementation |
|
||||
|
||||
## Consequences
|
||||
|
||||
One governance implementation runs on both sides of the wire; the browser-specific surface is one module system plus one reload plugin. Plugin packages have one shape, so the purity gate covers them all. Dependency edges and the boot tier live with their owners — the manifests — while the composing app holds only the roster and the `--dev` switch. The drift classes stay structurally closed: share-list hand-sync, load-order coupling, cross-plugin imports, roster/tier double bookkeeping. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps only one replaceable `loadBundle` hook.
|
||||
One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Plugin packages have one shape, so the purity gate covers them all. Dependency edges and the boot tier live with their owners — the manifests — while the composing app holds only the roster. The drift classes stay structurally closed: share-list hand-sync, load-order coupling, cross-plugin imports, roster/tier double bookkeeping. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps only one replaceable `loadBundle` hook.
|
||||
|
||||
Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows are informational — activation truth is service-level — so a mismatch surfaces at the settled sweep, not at graph validation; the three not-yet-promoted libraries keep their static-import export surface until their DI conversions land; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch.
|
||||
Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows are informational — activation truth is service-level — so a mismatch appears at the settled sweep, not at graph validation; the three not-yet-promoted libraries keep their static-import exports until their DI conversions land; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch.
|
||||
|
||||
Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordis.patch.yml`); `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer moved from a webserver-side registry into the `dsh-client-modules` node half (the package upgraded to dual-face per this note's promotion rule — its consumer now reaches it through cordis DI), and the transport split landed alongside: the webserver became a plain route-registration plugin, `/api/*` binding moved to the connection node half over the upgraded `api-gateway` plugin (`dsh-host-apiproxy` providing `ctx.apiProxy`), and the dev bundle watch + SSE channel moved to the hmr node half.
|
||||
|
||||
@@ -132,7 +132,7 @@ Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordi
|
||||
|
||||
| Rejected | One-line reason |
|
||||
|---|---|
|
||||
| Two-axis taxonomy (entry × arrival) with non-dshClient infrastructure packages | Erased manifest dependency edges (inject leaked to the composer), split the plugin shape in two, blinded the purity gate to half the plugins |
|
||||
| Two-axis taxonomy (entry × arrival) with infrastructure packages lacking `dsh.client` | Erased manifest dependency edges (inject leaked to the composer), split the plugin shape in two, blinded the purity gate to half the plugins |
|
||||
| Keep evolving the hand-written loader into a governor | Re-implements entry/fiber lifecycle the vendored Loader owns; HMR would have no shared skeleton with the host side |
|
||||
| Reuse `@cordisjs/plugin-hmr` in the browser | ~80% solves problems the browser doesn't have (fs watching, deep graph coloring, Node's dual caches); the reload skeleton is copied as a shape |
|
||||
| Module federation | Independently built remote bundles are exactly the form vite federation does not support |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Agent Note: client 插件装载——普通包、dshClient 插件与双阶段 boot
|
||||
# Agent Note: client 插件装载——普通包、dsh.client 插件与双阶段 boot
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -18,22 +18,22 @@ host 侧,cordis 插件装载站在 Node 的模块机制之上——require cac
|
||||
|
||||
插件 bundle 独立构建在 Vite 模块图之外。若把响应文本塞进内联 script,浏览器只能看到一次动态源码执行:网络资源、生成 bundle、TypeScript/TSX 源码之间没有标准 sourcemap 链,性能 profile 与 stack 只能落到生成后的 `client.js`;模块系统还要持有整份源码文本,并把同一项到达职责拆成 fetch 与 execute 两道传输边界。
|
||||
|
||||
在此之上,client 与 host 插件以一致的方式注册与装载:包声明一次 `dshClient`,host 把声明扫描进 boot 图,同一套 Loader 语义在两侧治理 entry。
|
||||
在此之上,client 与 host 插件以一致的方式注册与装载:包声明一次 `dsh.client`,host 把声明扫描进 boot 图,同一套 Loader 语义在两侧治理 entry。
|
||||
|
||||
第一代 client loader(`createClientLoader`)把这两层手写进了同一个函数。这一融合留下的是:没有卸载/重载路径(装载一次性,style 标签从不移除)、在三个文件间人肉抄写且早已漂移的依赖清单、一条供跨插件 import 走的模块表后门——既复制了 cordis 的服务机制,又把装载顺序变成正确性约束。下文的结构取代了它。
|
||||
|
||||
## Decision
|
||||
|
||||
### 两类包;`dshClient` 即插件,别无他义
|
||||
### 两类包;`dsh.client` 即插件,别无他义
|
||||
|
||||
什么让一个包成为插件?只有一条规则:**一个包的消费方式一旦是 cordis 依赖注入,它就是插件包;在此之前它是普通包。**代码怎么到达页面不属于分类体系——到达方式由包的类别推得,而不是反过来定义类别。
|
||||
|
||||
- **普通包**是模块系统自身所需的绝对基座,加上尚未转成 DI 的库:react 家族、cordis、`@deepseek-ai/dsh-client-modules`(模块系统本身——它永远不可能是插件,因为模块先于一切模块)、web 壳内核,以及——暂时——ui-slots、web-react、ui-primitives。普通包打进壳 bundle、播种进模块表、对 host 图不可见。
|
||||
- **插件包**是其余一切。每个都携带 `dshClient` manifest(元数据清单)声明(`{ platform, inject, immediately? }`)和同一种统一形态:共享 tsdown 预设产出 `lib/client.js`,`exports["./client"]` 指向该 bundle。每个都是 host 独家撰写的图里受治理的 entry。当前包括:connection、runtime、ui-theme、i18n、hmr(仅进 dev 图)、ui-layout、ui-sidebar、ui-conversation、ui-model-selector、ui-question、ui-trajectory。
|
||||
- **插件包**是其余一切。每个都携带 `dsh.client` manifest(元数据清单)声明(`{ platform, inject, immediately? }`)和同一种统一形态:共享 tsdown 预设产出 `lib/client.js`,`exports["./client"]` 指向该 bundle。每个都是 host 独家撰写的图里受治理的 entry。当前包括:connection、runtime、ui-theme、i18n、hmr(仅进 dev 图)、ui-layout、ui-sidebar、ui-conversation、ui-model-selector、ui-question、ui-trajectory。
|
||||
|
||||
manifest 拥有包的装载约定:它的 `inject` 依赖边,加可选的 `immediately` 预取标记(缺省即 lazy)。负责组合的 app 只拥有名册与 `--dev` 开关。
|
||||
manifest 拥有包的装载约定:它的 `inject` 依赖边,加可选的 `immediately` 预取标记(缺省即 lazy)。负责组合的 app 只拥有名册。
|
||||
|
||||
新增一个插件包:声明 `dshClient`,经共享预设产出 `./client` bundle,把包名加进负责组合的 app 的名册。除此之外无需任何交接。
|
||||
新增一个插件包:声明 `dsh.client`,经共享预设产出 `./client` bundle,把包名加进负责组合的 app 的名册。除此之外无需任何交接。
|
||||
|
||||
普通包何时升格为插件?升级法则,记录在案让迁移路径保持诚实:**普通包在其消费方改用 cordis DI 之时升格为插件包,绝不提前。**三项升格在排队:ui-slots(现居 runtime 的 slots 机件——SlotsService、渲染器约定、root slot)、web-react(渲染器安装移入自己的 `apply`)、ui-primitives(组件经 slot/服务供给之时)。在那之前它们保持普通包身份,符号导出保持普通的静态 import。
|
||||
|
||||
@@ -46,7 +46,7 @@ manifest 拥有包的装载约定:它的 `inject` 依赖边,加可选的 `im
|
||||
|
||||
### 一套模块系统,一个插件治理器
|
||||
|
||||
浏览器复刻 host 侧的分工。`dsh-client-modules`(`ClientModuleSystem`)坐上 host 侧由 Node 内部 ESM loader 占据的模块系统席位;同一份 vendored `@cordisjs/plugin-loader` 在两侧都坐治理席。二者的分界线一句话说尽:**模块系统拥有模块身份与字节——代码怎么到达、怎么登记、怎么变成导出面;Loader 拥有插件生命周期——插件何时挂载、等待什么、如何拆除。**
|
||||
浏览器复刻 host 侧的分工。`dsh-client-modules`(`ClientModuleSystem`)坐上 host 侧由 Node 内部 ESM loader 占据的模块系统席位;同一份 vendored `@cordisjs/plugin-loader` 在两侧都坐治理席。二者的分界线一句话说尽:**模块系统拥有模块身份与字节——代码怎么到达、怎么登记、怎么变成导出内容;Loader 拥有插件生命周期——插件何时挂载、等待什么、如何拆除。**
|
||||
|
||||
`ClientModuleSystem` 是一张 lazy CJS 表。执行 bundle 只**登记**其工厂——bundle 调用 `window.__ModuleLoader__.load({ id, factory })`,此外什么都不发生。模块体的一切副作用(包括 CSS 注入)都住在工厂闭包里,在物化时运行:物化即该 id 的首次 `require`/import,此后记忆化。工厂若 require 一个已登记未物化的同伴,就递归物化它,因此任何地方都不存在排序。被要求 import 一个 id 时,表按固定分支顺序解析:种子词条 → 记忆化的记录 → 静态登记(壳自有模块,如 app-shell)→ 已登记的工厂 → 图行外部 classic script 加载 → 大声抛错。最后这一抛是构建期纯度门禁在运行期的镜像。系统还保管逐模块的簿记——名下 `<style data-plugin>` 标签 id、观测到的 require 边——并暴露 HMR(热模块替换)需要的两个动词:`prefetch(id)`(加载脚本、只登记工厂;并发调用共享同一在途任务)与 `invalidate(id)`(丢弃工厂与记录,下次到达即重新加载)。
|
||||
|
||||
@@ -66,11 +66,11 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
|
||||
|
||||
**host 侧——组合这张图。**
|
||||
|
||||
1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 host 激活检查之前追加 `client-hmr` 行,使同一项检查覆盖它。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack([host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.md))。
|
||||
2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dshClient` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,host 检查会从 FAILED fiber 报告这两类错误。
|
||||
1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack([host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.md))。
|
||||
2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dsh.client` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,host 检查会从 FAILED fiber 报告这两类错误。
|
||||
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。每个 bundle 的内容哈希是其 `rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`,每一行都作为脚本资源供给:`/plugins/<id>/client.js?rev=…`,对应 sourcemap 位于同一路径加 `.map`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知(它是朴素路由注册插件;bundle 路由和 index 渲染 tap 都由 modules 自己注册)。
|
||||
|
||||
为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个 dshClient 包存在于仓库里,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。
|
||||
为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个 dsh.client 包存在于仓库里,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。
|
||||
|
||||
**第一阶段——模块面。**壳在图之上建起模块系统,然后并行预取每个 `immediately` 行。预取即加载外部脚本,只登记工厂。单行预取失败在这里被吞下:第二阶段 import 时会重试加载并拥有那次大声失败,因此一个坏行藏不住其他行。`immediately` 是预取标记——不是屏障,不是身份。包声明它,注册表把它带进图行。基础设施插件(connection、runtime、ui-theme、i18n,外加 hmr)声明它;UI 插件则径直按需到达。
|
||||
|
||||
@@ -84,9 +84,9 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
|
||||
|
||||
### 热重载:一个驱动插件,自行监视的 bundle
|
||||
|
||||
热重载是否启用是一项组合决策:dev 组合挂载 `client-hmr` 行(一个常规的插件包,由 `--dev` 追加),其 node 半带来 bundle 监视与 SSE(Server-Sent Events)通道;prod 组合不挂载,两者皆无。
|
||||
热重载是一项组合决策:web 组合包无条件挂载 `client-hmr` 行(一个常规的插件包),其 node 半带来 bundle 监视与 SSE(Server-Sent Events)通道;没有重建 watcher 改写客户端 bundle 时链路保持空闲。不得暴露它的组合可在 patch 层禁用该行。
|
||||
|
||||
重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。它从 `ctx.clientModuleHost.clientPath(id)` 读取图上各行的 bundle 路径,由 HMR 自持的单个定时器对当前图上的每一行做 stat 轮询。新增图行时,顺序固定为先同步取得 stat 基线,再立即调用 `clientModuleHost.rebuilt(id)`:在模块 host 算出图哈希之后、取得基线之前发生的写入会被这次立即重哈希捕获;取得基线之后发生的写入则会留下 stat 差异,供下一次轮询捕获。这避开了 `fs.watchFile`:它以异步首次 stat 建立基线,可能把构造期间的重建静默吸收进基线。监视集合的成员随 `onGraphChanged` 更新;消失的行撤下监视,轮询时缺失的 bundle 则让对应行保持标脏状态,文件重现时即使元数据相同也强制重哈希。mtime/size 变化或行处于标脏状态时,`clientModuleHost.rebuilt(id)` 是重哈希的唯一入口;当 `rev` 真的变了,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建 bundle 则是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dshClient 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
|
||||
重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。它从 `ctx.clientModuleHost.clientPath(id)` 读取图上各行的 bundle 路径,由 HMR 自持的单个定时器对当前图上的每一行做 stat 轮询。新增图行时,顺序固定为先同步取得 stat 基线,再立即调用 `clientModuleHost.rebuilt(id)`:在模块 host 算出图哈希之后、取得基线之前发生的写入会被这次立即重哈希捕获;取得基线之后发生的写入则会留下 stat 差异,供下一次轮询捕获。这避开了 `fs.watchFile`:它以异步首次 stat 建立基线,可能把构造期间的重建静默吸收进基线。监视集合的成员随 `onGraphChanged` 更新;消失的行撤下监视,轮询时缺失的 bundle 则让对应行保持标脏状态,文件重现时即使元数据相同也强制重哈希。mtime/size 变化或行处于标脏状态时,`clientModuleHost.rebuilt(id)` 是重哈希的唯一入口;当 `rev` 真的变了,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建 bundle 则是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
|
||||
|
||||
浏览器侧,驱动插件每帧重载一个插件,串行执行:
|
||||
|
||||
@@ -113,16 +113,16 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
|
||||
| `dsh-client-ui-slots` | slot 注册表核心 | 普通包,已播种 | 升格为插件;接收 runtime 的 slots 机件 |
|
||||
| `dsh-client-web-react` | ctx↔React 胶水 | 普通包,已播种 | 升格为插件;渲染器安装移入其 apply |
|
||||
| `dsh-client-ui-primitives` | 基础组件 | 普通包,已播种 | 升格为插件(组件经 slot/服务供给) |
|
||||
| `dsh-client-connection` | wire 层 | 插件(dshClient + bundle),声明 `immediately` | 传输替换(Electron IPC 载体) |
|
||||
| `dsh-client-connection` | wire 层 | 插件(dsh.client + bundle),声明 `immediately` | 传输替换(Electron IPC 载体) |
|
||||
| `dsh-client-runtime` | 会话对象层 + slots 服务 + store 引擎 | 插件,声明 `immediately` | 持续缩向纯会话对象层 |
|
||||
| `dsh-client-ui-theme` | 主题 token/服务 | 插件,声明 `immediately`,外加 `./styles/*` 源码通道 | Theme Registry(另行裁定) |
|
||||
| `dsh-client-i18n` | I18nService | 插件,声明 `immediately` | 按部署组合语言包 |
|
||||
| `dsh-client-hmr` | 热重载驱动 | 插件,声明 `immediately`;仅进 dev 图 | 回滚;重连握手 |
|
||||
| `dsh-client-hmr` | 热重载驱动 | 插件,声明 `immediately` | 回滚;重连握手 |
|
||||
| ui-layout / ui-sidebar / ui-conversation / ui-trajectory | UI 功能 | 插件,按需到达 | conversation 域拆分;trajectory 真实现 |
|
||||
|
||||
## Consequences
|
||||
|
||||
wire 两侧跑着同一份治理实现;浏览器特有的表面只是一套模块系统加一个重载插件。插件包只有一种形态,纯度门禁因此覆盖全部插件。依赖边与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册与 `--dev` 开关。各漂移缺陷类被结构性关死:共享清单人肉同步、装载顺序耦合、跨插件 import、名册/档位双重记账。浏览器原生脚本装载使插件网络资源、生成 bundle 与 TypeScript/TSX 源码保持标准映射,模块系统也只保留一个可替换的 `loadBundle` 钩子。
|
||||
wire 两侧跑着同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。插件包只有一种形态,纯度门禁因此覆盖全部插件。依赖边与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。各漂移缺陷类被结构性关死:共享清单人肉同步、装载顺序耦合、跨插件 import、名册/档位双重记账。浏览器原生脚本装载使插件网络资源、生成 bundle 与 TypeScript/TSX 源码保持标准映射,模块系统也只保留一个可替换的 `loadBundle` 钩子。
|
||||
|
||||
接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;图中 `inject` 行仅是信息性说明——激活的真相在服务层——因此不匹配会在 settled 扫描时浮出,而不是在图校验时被拦下;三个尚未升格的库在各自的 DI 转换落地之前保持静态 import 的导出面;每个 bundle 多出一份 sourcemap 产物,外部脚本失败也只能给出粗粒度的 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。
|
||||
|
||||
@@ -132,7 +132,7 @@ wire 两侧跑着同一份治理实现;浏览器特有的表面只是一套模
|
||||
|
||||
| Rejected | One-line reason |
|
||||
|---|---|
|
||||
| 两轴分类体系(entry × 到达),基础设施包不带 dshClient | 抹掉了 manifest 依赖边(inject 泄漏给组合方)、把插件形态拆成两种、让纯度门禁对一半插件失明 |
|
||||
| 两轴分类体系(entry × 到达),基础设施包不带 dsh.client | 抹掉了 manifest 依赖边(inject 泄漏给组合方)、把插件形态拆成两种、让纯度门禁对一半插件失明 |
|
||||
| 继续把手写 loader 演化成治理器 | 重新实现 vendored Loader 已拥有的 entry/fiber 生命周期;HMR 将与 host 侧毫无共享骨架 |
|
||||
| 在浏览器复用 `@cordisjs/plugin-hmr` | 约 80% 在解决浏览器没有的问题(fs 监听、深度图着色、Node 的双缓存);只按形状抄用其重载骨架 |
|
||||
| 模块联邦(module federation) | 独立构建的远端 bundle 恰是 vite 联邦不支持的形态 |
|
||||
|
||||
@@ -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-23-toolview-dissolution.md
|
||||
2026-07-23-toolview-dissolution.md: be1bd9d161714194855988d76a632c667fae8c84
|
||||
2026-07-23-toolview-dissolution.zh.md: c3f301002aac4c443dedc76fa747d8b45be6577a
|
||||
2026-07-23-toolview-dissolution.md: 173af01ff6cd5dc74f2f0916fd49ad278636d1bb
|
||||
2026-07-23-toolview-dissolution.zh.md: 8a4048fbb6ca22acd0b790e0855e4dd63d0b7006
|
||||
|
||||
@@ -26,7 +26,7 @@ Four behavioral deltas were accepted deliberately, not overlooked. Cross-view ap
|
||||
|
||||
**Promote `renderToolView` into the standard kit and move the registry into the runtime package.** Rejected: Tool presentation is Client UI vocabulary; hoisting it into runtime would leak presentation into the data object layer and still leave two registration models.
|
||||
|
||||
**Derive slot declarations from subscription refCounts** (declare the slot implicitly when the first registrant subscribes). Rejected for implicit coupling and debounce complexity; noted as a possible revisit only if a genuinely multi-viewer surface appears.
|
||||
**Derive slot declarations from subscription refCounts** (declare the slot implicitly when the first registrant subscribes). Rejected for implicit coupling and debounce complexity; noted as a possible revisit only if a genuinely multi-viewer UI appears.
|
||||
|
||||
**A thin `registerToolView` facade over slots.register.** Deferred, not rejected: after dissolution the facade would carry only compile-time sugar (slot-name literal narrowing, tool→key vocabulary, props pre-composition) with zero runtime. Per "enforce at the operation boundary" (a facade is not an enforcement point), it stays unbuilt; the useful type composition ships as the exported Tool view props alias. A later facade can be added without disturbing direct registration if repeated registration ceremony justifies it.
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ Status: implemented
|
||||
|
||||
**把 `renderToolView` 提进标配 kit、注册表迁入 runtime 包。** 拒绝:Tool 展示是 Client UI 词汇;上提进 runtime 会把展示概念泄漏进数据对象层,且依然留着两套注册模型。
|
||||
|
||||
**以订阅 refCount 推导槽声明**(首个注册方订阅时隐式声明槽)。拒绝:隐式耦合加去抖复杂度;记为将来真出现多观看面时的备选。
|
||||
**以订阅 refCount 推导槽声明**(首个注册方订阅时隐式声明槽)。拒绝:隐式耦合加去抖复杂度;记为将来真出现多视图 UI 时的备选。
|
||||
|
||||
**slots.register 之上的薄 `registerToolView` 门面。** 缓建而非拒绝:溶解后该门面只剩编译期语法糖(slot 名字面量收窄、tool→key 词汇翻译、props 预组合),运行时为零。按「enforce at the operation boundary」(门面不是强制点)保持不建;有用的类型组合以导出的 Tool view props 别名兑现。若重复注册仪式今后足以证明其价值,可在不扰动直接注册的前提下补充门面。
|
||||
|
||||
|
||||
@@ -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-24-single-harness-home-resolver.md
|
||||
2026-07-24-single-harness-home-resolver.md: 1e128911dce0b50dc95722c6edd82cb322d18f3d
|
||||
2026-07-24-single-harness-home-resolver.zh.md: 258a16e6ca139da191db564aae40d23d05ca46b7
|
||||
2026-07-24-single-harness-home-resolver.md: 27db7e23b1ae3d2c9a05259bfb3047591589627d
|
||||
2026-07-24-single-harness-home-resolver.zh.md: 5eec850c9c7ecc70b2327eacca422662ec39a9cc
|
||||
|
||||
@@ -6,13 +6,12 @@ English | [中文](2026-07-24-single-harness-home-resolver.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The harness had three inconsistent conventions for "where does DeepSeek Harness user data live":
|
||||
The harness had two inconsistent conventions for "where does DeepSeek Harness user data live":
|
||||
|
||||
- `@deepseek-ai/dsh-home` resolved `configured ?? $DSH_HOME ?? ~/.dsh`.
|
||||
- `@deepseek-ai/dsh-paths` shipped a **second** `resolveDshHome` with the same precedence plus tilde expansion — a near-duplicate of `dsh-home` that no gate flagged because the two lived in different packages and had already drifted (only one expanded tildes).
|
||||
- `@deepseek-ai/dsh-telemetry`'s `globalConfigDir` used a *different* policy entirely: `DSH_CONFIG_HOME > $XDG_CONFIG_HOME/deepseek-harness > %APPDATA%/deepseek-harness > ~/.config/deepseek-harness`.
|
||||
|
||||
So most of the product parked everything under one `~/.dsh` root while telemetry alone stored its anonymous id elsewhere, under a `deepseek-harness` namespace that contradicts the repo-wide `dsh` shorthand (`DSH_HOME`, `@deepseek-ai/dsh-*`, `~/.dsh`). Two resolvers plus a divergent third policy means no single home fact.
|
||||
Two resolvers for the same cross-cutting fact meant there was no single home policy.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -22,20 +21,18 @@ One resolver owns the harness home, in `@deepseek-ai/dsh-paths`, single-root:
|
||||
explicit configured path > $DSH_HOME > ~/.dsh
|
||||
```
|
||||
|
||||
An empty or whitespace-only `$DSH_HOME` is treated as unset, matching the guard telemetry's old resolver carried: without it `resolve('')` would silently place the home at the current working directory. The harness keeps all user data under one root; there is no XDG config/data/cache split. `dshHomePath(...segments)` joins deployment-owned children onto that root, and `dsh-app-boot` exposes it to Loader `!!js` config expressions before mounting entries, so shipped compositions derive `sessions` and `storages` without copying the resolver. `dshHomeDisplay()` names a resolved root symbolically for user-facing paths — `~/.dsh` for the default home, `$DSH_HOME` for any configured home — so the user-global `AGENTS.md` label never leaks an absolute machine path. It replaces workspace-context's bespoke default-vs-`$DSH_HOME` check.
|
||||
An empty or whitespace-only `$DSH_HOME` is treated as unset; otherwise `resolve('')` would silently place the home at the current working directory. The harness keeps all user data under one root; there is no XDG config/data/cache split. `dshHomePath(...segments)` joins deployment-owned children onto that root, and `dsh-app-boot` exposes it to Loader `!!js` config expressions before mounting entries, so shipped compositions derive `sessions` and `storages` without copying the resolver. `dshHomeDisplay()` names a resolved root symbolically for user-facing paths — `~/.dsh` for the default home, `$DSH_HOME` for any configured home — so the user-global `AGENTS.md` label never leaks an absolute machine path. It replaces workspace-context's bespoke default-vs-`$DSH_HOME` check.
|
||||
|
||||
`@deepseek-ai/dsh-home` is deleted. Its three importers (`dsh-tool-bash`, `dsh-skill-local`, `dsh-agent-spine-demo`) now import `resolveDshHome` from `dsh-paths`. `dsh-telemetry`'s `globalConfigDir` delegates to `resolveDshHome`, dropping its second resolver, the `DSH_CONFIG_HOME` override, the XDG/`%APPDATA%` branches, and the `deepseek-harness` namespace; the anonymous id now lives directly under the harness home.
|
||||
`@deepseek-ai/dsh-home` is deleted. Its three importers (`dsh-tool-bash`, `dsh-skill-local`, `dsh-agent-spine-demo`) import `resolveDshHome` from `dsh-paths`.
|
||||
|
||||
`dsh-telemetry` and its separate home policy are absent under the [SDK project toolchain removal](../simplification/2026-08-11-remove-sdk-project-toolchain.md), leaving this resolver as the sole home policy.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Leave the two `resolveDshHome` copies in place.** They had already drifted (one expands tildes, one didn't) and encode the same cross-cutting fact twice. Consolidation is the point of the `util/` layer; a duplicate resolver is a latent divergence bug.
|
||||
|
||||
**Adopt XDG (honor `$XDG_CONFIG_HOME`, or split config/data/cache into separate trees).** Considered and dropped in favor of one obvious root. A single `$DSH_HOME || ~/.dsh` ground truth matches `~/.claude` / `~/.aws`, needs no per-kind reclassification of every `~/.dsh` consumer, and leaves no resolver asymmetry to reconcile. Telemetry aligning onto the same root — rather than keeping its own XDG path — is precisely the divergence this removes.
|
||||
|
||||
**Keep telemetry's own config dir.** Its `deepseek-harness` namespace and separate XDG policy were the lone exception to the `dsh`/`~/.dsh` convention. Folding it onto the shared resolver is what makes "one home fact" true. The cost is that the anonymous id becomes scoped to `$DSH_HOME` rather than the machine: a project that points `DSH_HOME` at a repo-local path (or a command that loads a project `.env` before telemetry) gets a home-local id, so the id counts harness homes, not machines. This is accepted as the intended meaning of single-root — a relocated `$DSH_HOME` moves *all* harness state, telemetry identity included — and the module contract is stated as per-harness-home rather than per-machine. A machine-global identity that ignored `$DSH_HOME` would reintroduce exactly the second home policy this decision removes.
|
||||
**Adopt XDG (honor `$XDG_CONFIG_HOME`, or split config/data/cache into separate trees).** Considered and dropped in favor of one obvious root. A single `$DSH_HOME || ~/.dsh` ground truth matches `~/.claude` / `~/.aws`, needs no per-kind reclassification of every `~/.dsh` consumer, and leaves no resolver asymmetry to reconcile.
|
||||
|
||||
## Consequences
|
||||
|
||||
- One home fact, one resolver. `dsh-paths` is the sole owner; the `util/` group loses the `home` package.
|
||||
- Telemetry's anonymous id moves from `~/.config/deepseek-harness/telemetry.json` to the harness home (`~/.dsh/telemetry.json` by default). Under the pre-release "backends reject old formats" stance this needs no migration: an orphaned old id simply regenerates once, and the id is anonymous by construction.
|
||||
- Telemetry drops Windows `%APPDATA%` handling. `resolveDshHome` uses `os.homedir()`, which is correct on Windows; the harness does not special-case `%APPDATA%` for its single root.
|
||||
|
||||
@@ -6,13 +6,12 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
对于"DeepSeek Harness 用户数据存放在哪里",harness 里存在三套互不一致的约定:
|
||||
对于"DeepSeek Harness 用户数据存放在哪里",harness 里存在两套互不一致的约定:
|
||||
|
||||
- `@deepseek-ai/dsh-home` 按 `configured ?? $DSH_HOME ?? ~/.dsh` 解析。
|
||||
- `@deepseek-ai/dsh-paths` 又提供了**第二个** `resolveDshHome`,优先级相同但额外做了波浪号展开——它几乎是 `dsh-home` 的重复实现,却没有任何门禁发现,因为两者分属不同的包,而且早已漂移(只有一个会展开波浪号)。
|
||||
- `@deepseek-ai/dsh-telemetry` 的 `globalConfigDir` 采用了*完全不同*的策略:`DSH_CONFIG_HOME > $XDG_CONFIG_HOME/deepseek-harness > %APPDATA%/deepseek-harness > ~/.config/deepseek-harness`。
|
||||
|
||||
于是产品的大部分内容都停放在同一个 `~/.dsh` 根目录下,唯独 telemetry 把匿名 id 存到别处,落在一个 `deepseek-harness` 命名空间里,这与全仓库通行的 `dsh` 简写(`DSH_HOME`、`@deepseek-ai/dsh-*`、`~/.dsh`)相冲突。两个解析器再加上一个各行其是的第三套策略,意味着不存在单一的 home 事实。
|
||||
同一条横切事实有两个解析器,意味着不存在单一的 home 策略。
|
||||
|
||||
## 决策
|
||||
|
||||
@@ -22,20 +21,18 @@ Status: implemented
|
||||
explicit configured path > $DSH_HOME > ~/.dsh
|
||||
```
|
||||
|
||||
空或仅含空白的 `$DSH_HOME` 被当作未设置处理,这与 telemetry 旧解析器所带的保护一致:若无此保护,`resolve('')` 会悄悄把 home 落在当前工作目录。harness 把所有用户数据都放在同一个根目录下;不存在 XDG 的 config/data/cache 拆分。`dshHomePath(...segments)` 将部署负责的子路径拼接到该根目录下,`dsh-app-boot` 在挂载条目前向 Loader `!!js` 配置表达式暴露它,因此出厂组合无需复制解析器即可派生 `sessions` 和 `storages`。`dshHomeDisplay()` 为面向用户的路径以符号形式命名已解析的根目录——默认 home 显示为 `~/.dsh`,任何已配置的 home 显示为 `$DSH_HOME`——这样面向用户全局的 `AGENTS.md` 标签就绝不会泄露机器上的绝对路径。它取代了 workspace-context 中自定义的"默认值 vs `$DSH_HOME`"判断。
|
||||
空或仅含空白的 `$DSH_HOME` 被当作未设置处理;否则,`resolve('')` 会悄悄把 home 落在当前工作目录。harness 把所有用户数据都放在同一个根目录下;不存在 XDG 的 config/data/cache 拆分。`dshHomePath(...segments)` 将部署负责的子路径拼接到该根目录下,`dsh-app-boot` 在挂载条目前向 Loader `!!js` 配置表达式暴露它,因此出厂组合无需复制解析器即可派生 `sessions` 和 `storages`。`dshHomeDisplay()` 为面向用户的路径以符号形式命名已解析的根目录——默认 home 显示为 `~/.dsh`,任何已配置的 home 显示为 `$DSH_HOME`——这样面向用户全局的 `AGENTS.md` 标签就绝不会泄露机器上的绝对路径。它取代了 workspace-context 中自定义的"默认值 vs `$DSH_HOME`"判断。
|
||||
|
||||
`@deepseek-ai/dsh-home` 被删除。它的三个引用方(`dsh-tool-bash`、`dsh-skill-local`、`dsh-agent-spine-demo`)现在从 `dsh-paths` 导入 `resolveDshHome`。`dsh-telemetry` 的 `globalConfigDir` 转而委托给 `resolveDshHome`,去掉了它的第二个解析器、`DSH_CONFIG_HOME` 覆盖项、XDG/`%APPDATA%` 分支以及 `deepseek-harness` 命名空间;匿名 id 现在直接存放在 harness home 之下。
|
||||
`@deepseek-ai/dsh-home` 被删除。它的三个引用方(`dsh-tool-bash`、`dsh-skill-local`、`dsh-agent-spine-demo`)从 `dsh-paths` 导入 `resolveDshHome`。
|
||||
|
||||
`dsh-telemetry` 及其独立 home 策略已随 [SDK 项目工具链移除](../simplification/2026-08-11-remove-sdk-project-toolchain.md)一并消失,因此该解析器是唯一的 home 策略。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**保留两份 `resolveDshHome` 副本。** 它们早已漂移(一个展开波浪号,一个不展开),并把同一条横切事实编码了两遍。`util/` 层的意义正是在于合并,重复的解析器是一个潜在的分歧 bug。
|
||||
|
||||
**采用 XDG(遵从 `$XDG_CONFIG_HOME`,或把 config/data/cache 拆分到各自的目录树)。** 经过考虑后放弃,转而采用一个显而易见的根目录。单一的 `$DSH_HOME || ~/.dsh` 基准事实与 `~/.claude` / `~/.aws` 一致,无需对每个 `~/.dsh` 消费方按类别重新归类,也不留下任何需要协调的解析器不对称。telemetry 对齐到同一根目录——而不是保留自己的 XDG 路径——正是本决策所要消除的那种分歧。
|
||||
|
||||
**保留 telemetry 自己的 config 目录。** 它的 `deepseek-harness` 命名空间和独立的 XDG 策略是唯一违背 `dsh`/`~/.dsh` 约定的例外。把它折叠到共享解析器上,才让"单一 home 事实"成真。代价是匿名 id 的作用域从机器变成了 `$DSH_HOME`:若某个项目把 `DSH_HOME` 指向仓库本地路径(或某条命令在 telemetry 之前加载了项目的 `.env`),得到的就是 home 本地的 id,因此该 id 统计的是 harness home,而非机器。这被接受为单一根目录的应有含义——重定位 `$DSH_HOME` 会移动*全部* harness 状态,telemetry 身份也在其中——模块约定据此表述为 per-harness-home 而非 per-machine。一个忽略 `$DSH_HOME` 的机器级全局身份,恰恰会重新引入本决策所要消除的那第二套 home 策略。
|
||||
**采用 XDG(遵从 `$XDG_CONFIG_HOME`,或把 config/data/cache 拆分到各自的目录树)。** 经过考虑后放弃,转而采用一个显而易见的根目录。单一的 `$DSH_HOME || ~/.dsh` 基准事实与 `~/.claude` / `~/.aws` 一致,无需对每个 `~/.dsh` 消费方按类别重新归类,也不留下任何需要协调的解析器不对称。
|
||||
|
||||
## 影响
|
||||
|
||||
- 单一 home 事实,单一解析器。`dsh-paths` 是唯一归属方;`util/` 组失去了 `home` 包。
|
||||
- telemetry 的匿名 id 从 `~/.config/deepseek-harness/telemetry.json` 移到 harness home(默认为 `~/.dsh/telemetry.json`)。在预发布的"后端拒绝旧格式"立场下,这无需迁移:一个遗留的旧 id 只会重新生成一次,而且该 id 本就是匿名构造的。
|
||||
- telemetry 去掉了 Windows `%APPDATA%` 处理。`resolveDshHome` 使用 `os.homedir()`,这在 Windows 上是正确的;harness 不会为它的单一根目录对 `%APPDATA%` 做特殊处理。
|
||||
|
||||
@@ -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-24-web-config-tree-boot-and-transport-layering.md
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.md: 496499a691dbca012e5e953cbb6eb1d0bf25b635
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: c4bed7b730cf37e1d90750f5c93fbccb920ced21
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.md: be0a75d98c3d0004e49ad574e7e0e37ba35259bb
|
||||
2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 316cb0bc2ef23b440f52b35774c38a47f1e0570b
|
||||
|
||||
@@ -12,30 +12,30 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)
|
||||
|
||||
## Decision
|
||||
|
||||
**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle audit — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits only failed fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; before throwing, it marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations.
|
||||
**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dsh.client` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). The `dsh-client-hmr` row is an ordinary always-on bundle row (originally appended in code by `--dev`; the flag is retired). Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits only failed fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; before throwing, it marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations.
|
||||
|
||||
**Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep.
|
||||
|
||||
**Config sources have one declaration place each.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless front door](2026-08-09-headless-direct-core-front-door.md) and the Web gateway consume the same state.
|
||||
**Config sources have one declaration place each.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless entry point](2026-08-09-headless-direct-core-entry-point.md) and the Web gateway consume the same state.
|
||||
|
||||
**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{workspaceRoot?}`, consumes the base layer's front-door-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns incremental package scanning, the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route.
|
||||
**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{nativeOpen?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns incremental package scanning, the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route.
|
||||
|
||||
**Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Recomposing a web deployment is a yml/patch edit; the retired pieces (`mountWebPlugins`, `CLIENT_PACKAGES`, `createHostWebPluginRegistry`, `startWebServer`, the webserver's graph/SSE/api knowledge) are deleted.
|
||||
- [Headless is a direct core front door](2026-08-09-headless-direct-core-front-door.md): its shipped profile contains the shared base Agent capabilities and omits Host, HTTP, Web, and browser layers. The transport split in this note is the browser surface's contract.
|
||||
- [Headless is a direct core entry point](2026-08-09-headless-direct-core-entry-point.md): its shipped profile contains the shared base Agent capabilities and omits Host, HTTP, Web, and browser layers. The transport split in this note is the browser surface's contract.
|
||||
- A TypeScript pitfall worth remembering: a `declare module 'cordis'` augmentation in a file with **no cordis import** is demoted to a standalone module declaration and silently shatters the program-wide `Context` merge (`ctx.on`/`ctx.effect` vanish across the program). Anchor with `import type {} from 'cordis'`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
| Rejected | One-line reason |
|
||||
|---|---|
|
||||
| Dedicated `dsh-host-profile` receiver package | User model state belongs to the Settings-backed `ctx.agentDefaultModel`; an extra Host receiver would duplicate ownership and exclude direct front doors |
|
||||
| Dedicated `dsh-host-profile` receiver package | User model state belongs to the Settings-backed `ctx.agentDefaultModel`; an extra Host receiver would duplicate ownership and exclude direct entry points |
|
||||
| Runtime `assembly` shim plugin providing an `apiHandler` service | Existed only because `createApiProxy` lived in runtime; moving it into apiproxy made the gateway self-hosting, and `toFetchHandler` is a pure function the binding side calls |
|
||||
| Full-rescan + incremental scan coexisting | Two implementations, two semantics; the single per-package path covers the activation pass too |
|
||||
| A bespoke `./impl` export on the modules package | Non-uniform export surface; the standard `./client` carries the whole browser half |
|
||||
| A bespoke `./impl` export on the modules package | Non-uniform exports; the standard `./client` carries the whole browser half |
|
||||
| dev overlay / `cordis.dev.yml` | One yml; `!!js` cannot conditionalize row existence, and `--dev` appending one row is the entire difference |
|
||||
| env vars in the mapping table | The same field would gain env/json double sourcing and need an invented precedence |
|
||||
| Unbarriered create-after-prefetch (`arrive()` dedup as safety) | Disproved by a 10–25% boot race: in-flight dedup covers same-package double-fetch, not cross-package synchronous require edges |
|
||||
|
||||
@@ -12,27 +12,27 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host 运行时(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、仅等待失败的 fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现。
|
||||
**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host 运行时(32 行)、`api-gateway` 行、`webserver` 行、`dsh.client` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`dsh-client-hmr` 行是普通的常开组合包行(最初由 `--dev` 在代码中追加;该旗标已废除)。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、仅等待失败的 fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现。
|
||||
|
||||
**boot 胶水由两个类组成。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有那些必须独立于 cordis、提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest(元数据清单)、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐一创建图行、settle、sweep。
|
||||
|
||||
**每个配置源有唯一声明位置。** 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 前门](2026-08-09-headless-direct-core-front-door.md)与 Web 网关消费同一份状态。
|
||||
**每个配置源有唯一声明位置。** 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 入口](2026-08-09-headless-direct-core-entry-point.md)与 Web 网关消费同一份状态。
|
||||
|
||||
**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{workspaceRoot?}`,消费 base 层不偏向特定前门的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有单包增量扫描、bundle 路由、index tap 与 `onRebuilt`/`onGraphChanged` 通知。HMR node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。
|
||||
**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{nativeOpen?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有单包增量扫描、bundle 路由、index tap 与 `onRebuilt`/`onGraphChanged` 通知。HMR node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。
|
||||
|
||||
**包出口纪律。** modules 包只暴露 `.`(node 半)与 `./client`(完整浏览器半:`ClientModuleSystem`、`parseBootManifest`、收编插件面)——不设专用子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__`;`./client` 的 apply 读取该槽位(缺少时显式抛错)并 provide `ctx.modules`。
|
||||
|
||||
## 后果
|
||||
|
||||
- 重组一个 web 部署 = 改 yml/patch;退役件(`mountWebPlugins`、`CLIENT_PACKAGES`、`createHostWebPluginRegistry`、`startWebServer`、webserver 的图/SSE/api 知识)全部删除。
|
||||
- [Headless 是直接 core 前门](2026-08-09-headless-direct-core-front-door.md):其随附 profile 包含共享的 base Agent 能力,并省去 Host、HTTP、Web 与浏览器层。本笔记的传输划分是浏览器 surface 的约定。
|
||||
- [Headless 是直接 core 入口](2026-08-09-headless-direct-core-entry-point.md):其随附 profile 包含共享的 base Agent 能力,并省去 Host、HTTP、Web 与浏览器层。本笔记的传输划分是浏览器 surface 的约定。
|
||||
- 一个值得记住的 TypeScript 坑:`declare module 'cordis'` augmentation 所在文件若**没有任何 cordis import**,会被降级成独立 module declaration,无声打散全程序的 `Context` merge(`ctx.on`/`ctx.effect` 全程序消失)。用 `import type {} from 'cordis'` 锚定。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
| 弃案 | 一行理由 |
|
||||
|---|---|
|
||||
| 专门的 `dsh-host-profile` 受体包 | 用户模型状态归 Settings 支撑的 `ctx.agentDefaultModel` 所有;额外的 Host 受体会重复归属,并排除直接前门 |
|
||||
| 专门的 `dsh-host-profile` 受体包 | 用户模型状态归 Settings 支撑的 `ctx.agentDefaultModel` 所有;额外的 Host 受体会重复归属,并排除直接入口 |
|
||||
| 运行时里的 `assembly` 垫层插件(provide `apiHandler`) | 它的存在只因 `createApiProxy` 住运行时;本体迁入 apiproxy 后网关自持插件身份,且 `toFetchHandler` 是绑定方自己调的纯函数 |
|
||||
| 全量重扫与增量扫描并存 | 两条实现两份语义;单包路径足以覆盖激活初扫 |
|
||||
| modules 包特设 `./impl` 出口 | 出口面不统一;标准 `./client` 承载完整浏览器半 |
|
||||
|
||||
@@ -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: ff411e1b387dcd83b04f8c02d5dde3c70a815932
|
||||
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 593e194654bb3552ad3cac4534847fba11cf3694
|
||||
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
|
||||
|
||||
@@ -93,7 +93,7 @@ Slot scope is the closed set `root | session-maybe | session`:
|
||||
- `session-maybe` follows the current session with ADOPTION identity (the only behavior — there is no hold-identity-forever mode): an incarnation born session-less keeps its React instance across the arrival of the FIRST session (the blank shell adopts it — no remount, the DOM survives), and from then on behaves exactly like a strict session entry — switching to a different session remounts, and dropping back to no-session remounts into a fresh blank incarnation that will adopt again. Component-local per-session state therefore clears by construction; state that must survive a switch belongs in session-bound sources (machine, store, hooks). With no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates by subscribing to the runtime's atomic `currentProvide` projection — selection moves and provider-roster changes publish through the same source, so a roster change under a stable current id republishes the mounted bundle instead of stranding entries on an obsolete hook/prop schema — while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session; the per-entry adoption bookkeeping (incarnation-counter key) lives in the renderer's `SessionMaybeEntry`.
|
||||
- `session` guarantees that `sessionId`, every hook source, and every prop exist; each strict entry's error boundary is keyed by `sessionId`, so switching sessions recreates that entry and its session store.
|
||||
|
||||
`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the root-owned scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no session it renders inert (machine faces absent, `disabled` owner prop), and the same instance — textarea included — goes live when a session appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip.
|
||||
`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the root-owned scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no session its machine faces and message actions are inert, while the whole dashed card opens the existing Workspace picker by pointer and its read-only textarea does the same through Enter or Space. The same instance — textarea included — goes live when a session appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip.
|
||||
|
||||
- The runtime's first built-in entry: the `'session'` hook — `useSession` itself rides the same mechanism, no special-casing.
|
||||
- Concurrent discipline: the render plane reads only from the hooks compartment (uSES consistency guarantee); props-compartment callbacks are used only in event-handler space; descriptor resolution is render-safe (idempotent caching, with prune reaping residue from abandoned renders).
|
||||
@@ -122,7 +122,7 @@ Slot scope is the closed set `root | session-maybe | session`:
|
||||
| A `scopeTarget` carrier + fused dispatcher (mirroring the host `agentEvents`) | The host wrapper layer guards the business Agent subject against drifting from the scope key; client events have no subject to guard — the filter on the actx plus cordis primitives covers every need |
|
||||
| Sessions not holding a ctx (a cordis-free object layer) | A red line born only so the filtering unit tests avoid importing cordis, at the cost of two-hop contribute callbacks plus mutable public fields; the host Agent already holds loopCtx |
|
||||
| Resident Session instances (resident-instance) | The host session log is the durable truth; residency is mere identity convenience, and its misalignment with the scope lifecycle is a source of complexity |
|
||||
| Components receiving wiring-callback bundles (two-layer inject→props pass-down) | The standard-kit channel lets components fetch their own; the public surface converges to hooks + stable props |
|
||||
| Components receiving wiring-callback bundles (two-layer inject→props pass-down) | The standard-kit channel lets components fetch their own; the public API converges to hooks + stable props |
|
||||
| Swapping the no-session Hero view for the entire session Conversation | Even with the outer layout unchanged, the Hero, picker, and composer subtrees would remount together, making the whole UI region jump |
|
||||
| Making InputBar itself `session-maybe` | The input state machine, keyboard command surface, and actions would all have to accept absent values; replacing only the disabled input body keeps optionality at the shell boundary |
|
||||
| A dedicated conversion frame | `session-status(running:true)` semantically implies conversion (a blank session never runs); adding a frame buys zero information for one more wire type |
|
||||
@@ -132,5 +132,5 @@ Slot scope is the closed set `root | session-maybe | session`:
|
||||
- Plugins gain session context isomorphic to the host's: per-session state hangs on the actx and mounts/tears down in one piece with the scope fiber, making leaks structurally impossible; two-session isolation is structurally guaranteed by the scope filter.
|
||||
- The client object layer converges to a wire mirror: session identity, lifecycle, and capability adjudication all defer to the host entity — the input system (the next layer) always faces a session with a real Agent, and providers like slash/skill uniformly address by sessionId directly.
|
||||
- Blank-session governance takes zero dedicated mechanisms: state rides one derived bit, visibility rides the unified list projection (only the current blank shows, as `New Session`), reclamation rides lazy persistence's existing contract (evaporation on restart), and the ordinary ceiling rides same-Workspace reuse.
|
||||
- The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests; fully disabled input while no workspace is picked is an experience cost the product surface accepts (the price of the single state axis).
|
||||
- The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests. The single state axis still withholds machine faces until a Session exists; the resident card routes activation to the Workspace picker during that interval ([decision](../feature/2026-08-07-workspace-picker-composer-entry.md)).
|
||||
- Known gaps: approval/question recovery across prune (TODO); model selection returns in live-mutation shape (the host `selectModel` trio is ready-made, its client consumer not yet built).
|
||||
|
||||
@@ -93,7 +93,7 @@ slot scope 是闭集 `root | session-maybe | session`:
|
||||
- `session-maybe` 以**收养(adoption)身份语义**跟随 current session(唯一行为——不存在「永久保持实例」模式):空态出生的化身在**第一个** session 到来时保持 React 实例(空壳收养它——不重挂,DOM 存活);此后行为与严格 session entry 完全一致——切到不同 session 重挂,跌回无 session 也重挂为崭新的空态化身(之后再次收养)。因此组件本地的 per-session 状态**由构造保证**随切换清零;需要活过切换的状态必须住 session 绑定的源(machine、store、hooks)。无 session 时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 通过订阅 runtime 的原子 `currentProvide` 投影驱动这条更新——选择移动和提供方名册变化经同一 source 发布,current id 不变时的名册变化也会重发已挂载 bundle,而不是把 entry 困在过期的钩子/prop 形状上——`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整钩子/prop 形状;逐 entry 的收养记账(化身计数 key)住在 renderer 的 `SessionMaybeEntry`。
|
||||
- `session` 保证 `sessionId`、所有钩子 source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key,切换 session 会重建该 entry 及其 session store。
|
||||
|
||||
`conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、root 持有的 scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无 session → blank session 的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时以惰性态渲染(machine face 缺席、`disabled` owner prop),session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不分发任何条目。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。
|
||||
`conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、root 持有的 scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无 session → blank session 的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时,其 machine face 和消息操作保持惰性,整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不分发任何条目。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。
|
||||
|
||||
- 运行时内建第一条:`'session'` 钩子——`useSession` 本身走同一机制,无特判。
|
||||
- Concurrent 纪律:渲染平面只从 hooks 格读(uSES 一致性保证);props 格回调只在事件 handler 空间用;描述符解析 render-safe(幂等缓存、废弃渲染残留由 prune 收尸)。
|
||||
@@ -132,5 +132,5 @@ slot scope 是闭集 `root | session-maybe | session`:
|
||||
- 插件获得与 host 同构的会话上下文:逐会话状态挂 actx、随 scope fiber 一次拆装,泄漏结构性不可能;双会话隔离由 scope filter 结构性保证。
|
||||
- client 对象层收敛为 wire 镜像:会话身份、生命周期、能力判别全部以 host 实体为准——输入体系(下一层)面对的永远是「有真 Agent 的会话」,slash/skill 等提供方一律以 sessionId 直接寻址。
|
||||
- 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),回收靠 lazy persistence 的既有约定(重启蒸发),常规上限靠同 Workspace 复用。
|
||||
- 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住;「未选 workspace」期间输入全禁是产品面接受的体验代价(单一状态轴换来的)。
|
||||
- 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住。单一状态轴仍会在 Session 存在前隐藏 machine face;这段时间内,常驻卡片会把激活操作转到 Workspace picker([决策](../feature/2026-08-07-workspace-picker-composer-entry.md))。
|
||||
- 已知欠账:approval/question 跨 prune 恢复(TODO);模型选择以 live-mutation 形状回归(host `selectModel` 三件套现成,其 client 消费方尚未构建)。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-26-subprocess-seam.md
|
||||
2026-07-26-subprocess-seam.md: 7899af56259d39a4e3f924bcba673c8efa99c682
|
||||
2026-07-26-subprocess-seam.zh.md: d9cbc0ebf33bc785984b0a9bd58fa8ebc6aab436
|
||||
2026-07-26-subprocess-seam.md: f43b55b0b760c2aabe317abacdb800b755de059b
|
||||
2026-07-26-subprocess-seam.zh.md: df8c3ddab80933882bf3587a9f01e019eb721a87
|
||||
|
||||
@@ -17,11 +17,11 @@ A new `subprocess/` capability family owns "run and manage a process"; the bash
|
||||
- **`dsh-bash-local` (Consumer)** — `inject: ['subprocess']`; maps each resolved `BashExecSpec` onto a `SubprocessSpawnSpec` (`['bash', '-c', command]`), keeps its config, `resolve()` defaulting, fused-deadline `timedOut`/`aborted` classification, the `[stderr]`-marked background read merge with its consuming cursor, and the `onProcessDone` subclass hook. `dsh-bash-sandbox` is unchanged apart from redeclaring the inherited inject; it still wraps at the command-string level and re-enters the inherited spawn path.
|
||||
- **`dsh-bash` (Service Definition)** — re-exports the moved vocabulary from `dsh-subprocess`, so no bash Consumer changes an import; `BashExecRequest`/`BashExecSpec`/`BashProcess` and the sandbox facts remain bash-owned.
|
||||
|
||||
Every composition that loads a bash executor now also loads `@deepseek-ai/dsh-subprocess-local` (CLI, examples, python bundled runtime, create-sdk's bash feature resources, inline test configs).
|
||||
Every composition that loads a bash executor also loads `@deepseek-ai/dsh-subprocess-local` (CLI, examples, the Python bundled runtime, and inline test configs).
|
||||
|
||||
Background-process lifetime moved from the executor to the subprocess service: the executor no longer retains a live-process set, so an executor reload leaves background work running and readable, and composition teardown (the service's disposal) remains the kill-and-join boundary. One behavioral contract shifted with it: a background spawn failure can no longer be buffered as fake stderr inside the plumbing (the service rejects `done` and buffers nothing for a process that never ran), so the executor injects the `spawn failed: …` note into exactly one `readOutput()` delta.
|
||||
|
||||
Observed stream and lifecycle needs then moved the eligible process consumers onto the seam: LSP uses piped protocol streams plus a collected stderr tail; the ACP backend uses piped ndjson, inherited stderr, and a consumer-owned stdin-EOF disposal ladder; PTY uses `spawnTerminal()` while keeping readiness and terminal policy. `dsh-subagent-subprocess` and the private LSP tree helpers were deleted. MCP transport spawning, the SDK package-manager runner, synchronous TUI Git probing, and dependency-light test-support launchers remain outside by ownership or execution shape; their production callers share the scrub where applicable.
|
||||
Observed stream and lifecycle needs then moved the eligible process consumers onto the seam: LSP uses piped protocol streams plus a collected stderr tail; the ACP backend uses piped ndjson, inherited stderr, and a consumer-owned stdin-EOF disposal ladder; PTY uses `spawnTerminal()` while keeping readiness and terminal policy. `dsh-subagent-subprocess` and the private LSP tree helpers were deleted. MCP transport spawning and dependency-light test-support launchers remain outside by ownership or execution shape; their production callers share the scrub where applicable.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -31,7 +31,7 @@ Observed stream and lifecycle needs then moved the eligible process consumers on
|
||||
|
||||
**Use one `stdio: 'pipe' | 'inherit' | 'collect'` mode for all streams.** Rejected because real consumers mix modes per stream: LSP uses pipe/pipe/collect, ACP uses pipe/pipe/inherit, and Bash uses data/collect/collect.
|
||||
|
||||
**Route every process launch through `ctx.subprocess`.** Rejected because the MCP SDK owns its transport spawn, the SDK wizard has no Cordis context and needs inherited redirection, the TUI probe is synchronous, and support launchers deliberately stay independent of product seams. PTY allocation did move behind `spawnTerminal()` because the provider, not the consumer, owns that substrate-specific primitive.
|
||||
**Route every process launch through `ctx.subprocess`.** Rejected because the MCP SDK owns its transport spawn and support launchers deliberately stay independent of product seams. PTY allocation did move behind `spawnTerminal()` because the provider, not the consumer, owns that substrate-specific primitive.
|
||||
|
||||
**Put `run_in_background`/task semantics into the subprocess capability seam instead.** Rejected: that boundary already exists — `ctx.tasks` owns ids, ownership, and notices, and the bash tool adapts a `BashProcess` into task hooks. The subprocess seam sits *below* the bash executor, not beside the task registry.
|
||||
|
||||
|
||||
@@ -17,11 +17,11 @@ Status: implemented
|
||||
- **`dsh-bash-local`(Consumer)**——`inject: ['subprocess']`;把每个解析后的 `BashExecSpec` 映射为一个 `SubprocessSpawnSpec`(`['bash', '-c', command]`),并保留自身配置、`resolve()` 默认值补全、基于融合 deadline 的 `timedOut`/`aborted` 分类、带 `[stderr]` 标记的后台读取合并及其消费游标,以及 `onProcessDone` 子类钩子。`dsh-bash-sandbox` 除了重新声明继承来的 inject 之外没有变化;它仍在命令字符串层面做包装,并重新进入继承的 spawn 路径。
|
||||
- **`dsh-bash`(Service Definition)**——把迁走的词汇从 `dsh-subprocess` 重导出,因此没有任何 bash Consumer 需要改动导入;`BashExecRequest`/`BashExecSpec`/`BashProcess` 与沙箱事实仍归 bash 所有。
|
||||
|
||||
如今,每个加载 bash 执行器的组合都同时加载 `@deepseek-ai/dsh-subprocess-local`:CLI(命令行界面)、各示例、Python 捆绑运行时、create-sdk 的 bash 功能资源,以及各内联测试配置。
|
||||
每个加载 bash 执行器的组合都同时加载 `@deepseek-ai/dsh-subprocess-local`:CLI(命令行界面)、各示例、Python 捆绑运行时以及各内联测试配置。
|
||||
|
||||
后台进程的存续期从执行器移到了管理器:执行器不再保有存活进程集合,于是重载执行器后,后台工作会继续运行且仍可读取,而组合拆除(管理器的 dispose)仍是先终止再等待退出的边界。一条行为约定随之挪动:后台 spawn 失败不再能在管道内部被缓冲成伪造的 stderr(对一个从未真正运行的进程,管理器会 reject `done`,且不缓冲任何内容),因此执行器把 `spawn failed: …` 提示注入恰好一个 `readOutput()` 增量。
|
||||
|
||||
基于已观察到的流与生命周期需求,具备条件的进程消费方随后迁到该 seam:LSP 使用管道化协议流加收集式 stderr 尾部;ACP(Agent Client Protocol)后端使用管道化 ndjson、继承式 stderr 和消费方拥有的 stdin-EOF dispose 阶梯;PTY 使用 `spawnTerminal()`,同时保留就绪与终端策略。`dsh-subagent-subprocess` 与 LSP 私有进程树辅助函数均被删除。MCP 传输 spawn、SDK 包管理器运行器、同步 TUI Git 探测和刻意保持轻依赖的 test-support 启动器因所有权或执行形状仍留在外部;适用的生产调用方共享凭据清除。
|
||||
基于已观察到的流与生命周期需求,具备条件的进程消费方随后迁到该 seam:LSP 使用管道化协议流加收集式 stderr 尾部;ACP(Agent Client Protocol)后端使用管道化 ndjson、继承式 stderr 和消费方拥有的 stdin-EOF dispose 阶梯;PTY 使用 `spawnTerminal()`,同时保留就绪与终端策略。`dsh-subagent-subprocess` 与 LSP 私有进程树辅助函数均被删除。MCP 传输 spawn 和刻意保持轻依赖的 test-support 启动器因所有权或执行形状仍留在外部;适用的生产调用方共享凭据清除。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
@@ -31,7 +31,7 @@ Status: implemented
|
||||
|
||||
**用单个 `stdio: 'pipe' | 'inherit' | 'collect'` 模式统一全部流。**否决:真实消费方按流混用模式——LSP 使用 pipe/pipe/collect,ACP 使用 pipe/pipe/inherit,Bash 使用 data/collect/collect。
|
||||
|
||||
**把每一次进程启动都路由到 `ctx.subprocess`。**否决:MCP SDK 拥有其传输 spawn,SDK 向导没有 Cordis 上下文且需要继承式重定向,TUI 探测是同步的,support 启动器则刻意独立于产品 seam。PTY 分配迁到 `spawnTerminal()`,因为这项底层专用原语归提供方而非消费方所有。
|
||||
**把每一次进程启动都路由到 `ctx.subprocess`。**否决:MCP SDK 拥有其传输 spawn,support 启动器则刻意独立于产品 seam。PTY 分配迁到 `spawnTerminal()`,因为这项底层专用原语归提供方而非消费方所有。
|
||||
|
||||
**改把 `run_in_background`/任务语义放进 subprocess 能力 seam。**否决:那条边界已经存在。`ctx.tasks` 拥有 id、所有权与通知,bash 工具则把 `BashProcess` 适配成任务钩子。subprocess seam 位于 bash 执行器*之下*,而不是与任务注册表并列。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-26-task-registry-seam.md
|
||||
2026-07-26-task-registry-seam.md: 45801505f1729ec6094900acf94b4c17ed92c3b1
|
||||
2026-07-26-task-registry-seam.zh.md: 8dd90b34d2da1d22caba13fe8c388dab4a29be0d
|
||||
2026-07-26-task-registry-seam.md: d5864d86577839c77ab27d70c9dc1c6a79685d56
|
||||
2026-07-26-task-registry-seam.zh.md: 096cf41c614d1e9e15b6421412e9dc8160e38099
|
||||
|
||||
@@ -6,23 +6,23 @@ English | [中文](2026-07-26-task-registry-seam.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [background-task runtime](2026-06-20-generic-long-running-tool-runtime.md) shipped `TaskService` as one concrete package: `@deepseek-ai/dsh-tasks` owned both the `ctx.tasks` contract every producer and control surface programs against and the process-local provider (the in-memory store, settlement bookkeeping, owner-cleanup effects, teardown). That bundling recouples the two rates of change the repository's [capability-seam rule](2026-06-13-capability-seams.md) separates: swapping the registry's storage or lifecycle backend would churn the same package whose types and `ctx.tasks` surface producers (`dsh-tool-bash`, `dsh-tool-pty`, `dsh-tool-subagent`), the control surface (`dsh-tool-tasks`), and `TaskKindMap` extenders import. Every other swappable capability in the harness — bash, pty, fs, skill, subagent, web, session persistence — already carries the Service Definition / Service provider / Consumer split; the task registry was the remaining `core`-mode exception, guarded only by a `TODO(task-service-backend)` comment.
|
||||
The [background-task runtime](2026-06-20-generic-long-running-tool-runtime.md) shipped `TaskService` as one concrete package: `@deepseek-ai/dsh-tasks` owned both the `ctx.tasks` contract every producer and controller programs against and the process-local provider (the in-memory store, settlement bookkeeping, owner-cleanup effects, teardown). That bundling recouples the two rates of change the repository's [capability-seam rule](2026-06-13-capability-seams.md) separates: swapping the registry's storage or lifecycle backend would churn the same package whose types and `ctx.tasks` API producers (`dsh-tool-bash`, `dsh-tool-pty`, `dsh-tool-subagent`), the controller (`dsh-tool-tasks`), and `TaskKindMap` extenders import. Every other swappable capability in the harness — bash, pty, fs, skill, subagent, web, session persistence — already carries the Service Definition / Service provider / Consumer split; the task registry was the remaining `core`-mode exception, guarded only by a `TODO(task-service-backend)` comment.
|
||||
|
||||
## Decision
|
||||
|
||||
`tasks/` is now a three-package capability family in the bash-trio shape:
|
||||
|
||||
- **`@deepseek-ai/dsh-tasks` (Service Definition)** — the abstract `TaskService extends Service` owning `ctx.tasks`, the eight-method contract (`start`, `list`, `get`, `read`, `kill`, `wait`, `onTaskDone`, `attachSurface`), all vocabulary types (`TaskId`, `TaskKindMap`, `TaskStart`, `TaskHooks`, `TaskOutcome`, `TaskSnapshot`, `TaskRead`, `TaskDoneListener`), and the snapshot invariant companion. The class-level JSDoc states the semantics every Service provider owes: registrations outlive producer and surface fibers, owned access is session-fenced, settlement is first-wins with contained listeners, and `start` refuses work while no control surface is attached.
|
||||
- **`@deepseek-ai/dsh-tasks` (Service Definition)** — the abstract `TaskService extends Service` owning `ctx.tasks`, the eight-method contract (`start`, `list`, `get`, `read`, `kill`, `wait`, `onTaskDone`, `attachController`), all vocabulary types (`TaskId`, `TaskKindMap`, `TaskStart`, `TaskHooks`, `TaskOutcome`, `TaskSnapshot`, `TaskRead`, `TaskDoneListener`), and the snapshot invariant companion. The class-level JSDoc states the semantics every Service provider owes: registrations outlive producer and controller fibers, owned access is session-fenced, settlement is first-wins with contained listeners, and `start` refuses work while no attached task controller serves the spec's owner (controllers and listeners are scope-layered, so one process-wide registry answers both questions per owner).
|
||||
- **`@deepseek-ai/dsh-tasks-local` (Service provider)** — `LocalTaskService`, the process-local registry moved verbatim: the in-memory store, per-kind counters, waiter bookkeeping, `TASK_WAIT_TIMEOUT` deadline code, owner-cleanup effects, and force-fail teardown. The `dsh-timeout` dependency moves here with it; the Service Definition package has no provider dependencies.
|
||||
- **`@deepseek-ai/dsh-tool-tasks` (Consumer)** — unchanged; it injects `'tasks'` and never imports provider types.
|
||||
|
||||
Compositions load `dsh-tasks-local` where they previously loaded `dsh-tasks` (the CLI cordis.yml row, `agent-spine-demo`, test harnesses, the tool-catalog generator boot). Producer misconfiguration diagnostics ("background tasks unavailable: load …") name `dsh-tasks` — the Service Definition package that declares the absent `ctx.tasks` service — and the Service Definition package's own surfaces (its README and the direct-mount fence) point at Service providers, so the producer message stays correct when another backend becomes the recommended default. Producers, `TaskKindMap` declaration merges, and the control surface keep importing `@deepseek-ai/dsh-tasks` only.
|
||||
Compositions load `dsh-tasks-local` where they previously loaded `dsh-tasks` (the CLI cordis.yml row, `agent-spine-demo`, test harnesses, the tool-catalog generator boot). Producer misconfiguration diagnostics ("background tasks unavailable: load …") name `dsh-tasks` — the Service Definition package that declares the absent `ctx.tasks` service — and the Service Definition package's own APIs (its README and the direct-mount fence) point at Service providers, so the producer message stays correct when another backend becomes the recommended default. Producers, `TaskKindMap` declaration merges, and the controller keep importing `@deepseek-ai/dsh-tasks` only.
|
||||
|
||||
The seam keeps the in-process contract semantics unchanged: `TaskStart.run()` still passes callbacks and exact `Agent` objects, so a durable or cross-process backend still has design work to do before it can satisfy this Service Definition (identity, restart, ownership, observation). The split moves that future work out of every Consumer's dependency graph; it does not pre-design the backend.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the concrete service until a second backend exists (status quo).** This was the original runtime note's position: extracting a Service Definition before a second provider risks freezing the wrong boundary. It lost because the boundary is no longer speculative — the eight service methods and their semantics have been stable across every producer integration since introduction, they are exactly the surface `dsh-tool-tasks` and the producers already program against, and the repository convention treats swappable capabilities as three packages by default. The residual risk (a durable backend needing contract changes) is unchanged by the split: those changes would land in the Service Definition package either way, and today they would also churn every Consumer's provider dependency.
|
||||
**Keep the concrete service until a second backend exists (status quo).** This was the original runtime note's position: extracting a Service Definition before a second provider risks freezing the wrong boundary. It lost because the boundary is no longer speculative — the eight service methods and their semantics have been stable across every producer integration since introduction, they are exactly the API `dsh-tool-tasks` and the producers already program against, and the repository convention treats swappable capabilities as three packages by default. The residual risk (a durable backend needing contract changes) is unchanged by the split: those changes would land in the Service Definition package either way, and today they would also churn every Consumer's provider dependency.
|
||||
|
||||
**Service-Definition-only extraction inside one package (export an abstract class beside the concrete one).** Rejected because it separates nothing operationally: Consumers still depend on the package that carries the provider and its dependencies, and a replacement backend still cannot ship without the local one in its graph. The package boundary is the unit of independent evolution here.
|
||||
|
||||
@@ -30,6 +30,6 @@ The seam keeps the in-process contract semantics unchanged: `TaskStart.run()` st
|
||||
|
||||
## Consequences
|
||||
|
||||
Bought: the task registry now matches the repository-wide seam shape; a durable, remote, or instrumented registry is a sibling Service provider implementing eight abstract methods, and no producer, control surface, or `TaskKindMap` extender changes when one lands. The Service Definition README states the contract; the provider README owns the lifecycle bookkeeping facts. The registry behavior suite (owner cleanup, settlement, waits, teardown) lives with `dsh-tasks-local`; the Service Definition package keeps a stub-subclass test pinning registration under `ctx.tasks` and single-service duplication behavior, plus the probe-based invariant suite.
|
||||
Bought: the task registry now matches the repository-wide seam shape; a durable, remote, or instrumented registry is a sibling Service provider implementing eight abstract methods, and no producer, controller, or `TaskKindMap` extender changes when one lands. The Service Definition README states the contract; the provider README owns the lifecycle bookkeeping facts. The registry behavior suite (owner cleanup, settlement, waits, teardown) lives with `dsh-tasks-local`; the Service Definition package keeps a stub-subclass test pinning registration under `ctx.tasks` and single-service duplication behavior, plus the probe-based invariant suite.
|
||||
|
||||
Cost: one more package (manifest, tsconfig, README, invariant companion), and compositions must name the Service provider package. `abstract` erases at runtime and this package name used to be the mountable registry, so the Service Definition constructor fails loudly when mounted directly — a stale composition row gets "load a Service provider such as @deepseek-ai/dsh-tasks-local" at load time instead of a half-registered `ctx.tasks` failing far from the misconfiguration.
|
||||
|
||||
@@ -6,17 +6,17 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
[后台任务运行时](2026-06-20-generic-long-running-tool-runtime.md)交付时把 `TaskService` 做成了单个具体包:`@deepseek-ai/dsh-tasks` 既拥有每个生产方和控制接口面向编程的 `ctx.tasks` 约定,也拥有进程内 Service provider(内存存储、结算簿记、所有者清理 effect、拆除)。这种捆绑重新耦合了仓库[能力 seam 规则](2026-06-13-capability-seams.md)本要分离的两种变化速率:一旦替换注册表的存储或生命周期后端,被搅动的就是同一个包,而生产方(`dsh-tool-bash`、`dsh-tool-pty`、`dsh-tool-subagent`)、控制接口(`dsh-tool-tasks`)和 `TaskKindMap` 扩展方正是从这个包导入类型与 `ctx.tasks` 接口。harness 中其余每项可替换能力——bash、pty、fs、skill(技能)、subagent、web、会话持久化——都已具备 Service Definition / Service provider / Consumer 三分;任务注册表曾是仅剩的 `core` 模式例外,仅由一条 `TODO(task-service-backend)` 注释把守。
|
||||
[后台任务运行时](2026-06-20-generic-long-running-tool-runtime.md)交付时把 `TaskService` 做成了单个具体包:`@deepseek-ai/dsh-tasks` 既拥有每个生产方和控制器面向编程的 `ctx.tasks` 约定,也拥有进程内 Service provider(内存存储、结算簿记、所有者清理 effect、拆除)。这种捆绑重新耦合了仓库[能力 seam 规则](2026-06-13-capability-seams.md)本要分离的两种变化速率:一旦替换注册表的存储或生命周期后端,被搅动的就是同一个包,而生产方(`dsh-tool-bash`、`dsh-tool-pty`、`dsh-tool-subagent`)、控制器(`dsh-tool-tasks`)和 `TaskKindMap` 扩展方正是从这个包导入类型与 `ctx.tasks` API。harness 中其余每项可替换能力——bash、pty、fs、skill(技能)、subagent、web、会话持久化——都已具备 Service Definition / Service provider / Consumer 三分;任务注册表曾是仅剩的 `core` 模式例外,仅由一条 `TODO(task-service-backend)` 注释把守。
|
||||
|
||||
## 决策
|
||||
|
||||
`tasks/` 如今是一个 bash 三件套形态的三包能力家族:
|
||||
|
||||
- **`@deepseek-ai/dsh-tasks`(Service Definition)**——抽象的 `TaskService extends Service`,拥有 `ctx.tasks`、八个方法的约定(`start`、`list`、`get`、`read`、`kill`、`wait`、`onTaskDone`、`attachSurface`)、全部词汇类型(`TaskId`、`TaskKindMap`、`TaskStart`、`TaskHooks`、`TaskOutcome`、`TaskSnapshot`、`TaskRead`、`TaskDoneListener`),以及快照不变式配套插件。类级 JSDoc 陈述了每个 Service provider 都必须兑现的语义:注册的存续期长于生产方与控制接口的 fiber,有所有者的访问以会话为界,结算遵循首次结果优先且监听器错误被隔离,并且在没有附加任何控制接口时 `start` 拒绝启动工作。
|
||||
- **`@deepseek-ai/dsh-tasks`(Service Definition)**——抽象的 `TaskService extends Service`,拥有 `ctx.tasks`、八个方法的约定(`start`、`list`、`get`、`read`、`kill`、`wait`、`onTaskDone`、`attachController`)、全部词汇类型(`TaskId`、`TaskKindMap`、`TaskStart`、`TaskHooks`、`TaskOutcome`、`TaskSnapshot`、`TaskRead`、`TaskDoneListener`),以及快照不变式配套插件。类级 JSDoc 陈述了每个 Service provider 都必须兑现的语义:注册的存续期长于生产方与控制器的 fiber,有所有者的访问以会话为界,结算遵循首次结果优先且监听器错误被隔离,并且当没有任何已附加的任务控制器服务于 spec 的所有者时 `start` 拒绝启动工作(控制器与监听器按 scope 分层,因此一个进程级注册表能逐所有者地回答这两个问题)。
|
||||
- **`@deepseek-ai/dsh-tasks-local`(Service provider)**——`LocalTaskService`,即原样迁移的进程内注册表:内存存储、按 kind 划分的计数器、等待方簿记、`TASK_WAIT_TIMEOUT` deadline 代码、所有者清理 effect,以及强制失败的拆除。`dsh-timeout` 依赖随之迁入此包;Service Definition 包不含任何提供方依赖。
|
||||
- **`@deepseek-ai/dsh-tool-tasks`(Consumer)**——保持不变;它注入 `'tasks'`,从不导入提供方类型。
|
||||
|
||||
各组合在原先加载 `dsh-tasks` 的位置改为加载 `dsh-tasks-local`:CLI(命令行界面)的 cordis.yml 配置项、`agent-spine-demo`、各测试 harness,以及工具目录生成器的启动流程。生产方的配置错误诊断信息(「background tasks unavailable: load …」)点名 `dsh-tasks`——即声明缺失的 `ctx.tasks` 服务的 Service Definition 包;Service Definition 包自身的对外呈现(其 README 与直接挂载防线)会指向各 Service provider,因此当另一个后端日后成为推荐默认时,生产方的消息依旧正确。生产方、`TaskKindMap` 声明合并和控制接口仍然只导入 `@deepseek-ai/dsh-tasks`。
|
||||
各组合在原先加载 `dsh-tasks` 的位置改为加载 `dsh-tasks-local`:CLI(命令行界面)的 cordis.yml 配置项、`agent-spine-demo`、各测试 harness,以及工具目录生成器的启动流程。生产方的配置错误诊断信息(「background tasks unavailable: load …」)点名 `dsh-tasks`——即声明缺失的 `ctx.tasks` 服务的 Service Definition 包;Service Definition 包自身的 API(其 README 与直接挂载防线)会指向各 Service provider,因此当另一个后端日后成为推荐默认时,生产方的消息依旧正确。生产方、`TaskKindMap` 声明合并和控制器仍然只导入 `@deepseek-ai/dsh-tasks`。
|
||||
|
||||
该 seam 保持进程内约定语义不变:`TaskStart.run()` 仍然传入回调和确切的 `Agent` 对象,因此持久化或跨进程后端在能满足此 Service Definition 之前仍有设计工作要做(身份、重启、所有权、观察)。这次拆分把该项未来工作移出了每个 Consumer 的依赖图;它并不预先设计后端。
|
||||
|
||||
@@ -30,6 +30,6 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
换来的是:任务注册表如今与全仓库通行的 seam 形态一致;持久化、远程或带插桩的注册表将是一个实现八个抽象方法的同级 Service provider,这样的注册表落地时,任何生产方、控制接口或 `TaskKindMap` 扩展方都无需改动。Service Definition 的 README 陈述约定;生命周期簿记方面的事实归 Service provider 的 README 所有。注册表行为测试套件(所有者清理、结算、等待、拆除)随 `dsh-tasks-local` 存放;Service Definition 包保留一个桩子类(stub subclass)测试,固定 `ctx.tasks` 下的注册行为与单一服务的重复注册行为,外加基于探针的不变式测试套件。
|
||||
换来的是:任务注册表如今与全仓库通行的 seam 形态一致;持久化、远程或带插桩的注册表将是一个实现八个抽象方法的同级 Service provider,这样的注册表落地时,任何生产方、控制器或 `TaskKindMap` 扩展方都无需改动。Service Definition 的 README 陈述约定;生命周期簿记方面的事实归 Service provider 的 README 所有。注册表行为测试套件(所有者清理、结算、等待、拆除)随 `dsh-tasks-local` 存放;Service Definition 包保留一个桩子类(stub subclass)测试,固定 `ctx.tasks` 下的注册行为与单一服务的重复注册行为,外加基于探针的不变式测试套件。
|
||||
|
||||
代价是:多出一个包,即多一份 manifest(元数据清单)、tsconfig、README 与不变式配套插件;同时各组合必须点名 Service provider 包。`abstract` 在运行时会被擦除,而这个包名过去正是可挂载的具体注册表,因此直接挂载 Service Definition 时,其构造函数会明确报错——一条陈旧的组合配置行会在加载时得到「load a Service provider such as @deepseek-ai/dsh-tasks-local」,而不是一个未完整注册的 `ctx.tasks` 在远离错误配置处才失败。
|
||||
|
||||
@@ -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-28-api-browser-trust-boundary.md
|
||||
2026-07-28-api-browser-trust-boundary.md: 7c1615fedf0f18f5e2c341b2246415057a6ee172
|
||||
2026-07-28-api-browser-trust-boundary.md: f4d14201d052299bb6063553e962a5d7c74fdb4b
|
||||
2026-07-28-api-browser-trust-boundary.zh.md: fc210921918f2e790f3f1fe36edd66e73f19d74c
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Agent Note: One carrier-level browser-trust boundary for the whole /api surface
|
||||
# Agent Note: One carrier-level browser-trust boundary for all `/api` routes
|
||||
|
||||
Status: implemented
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# Agent Note: Experimental and internal package group
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-28-experimental-plugin-package-group.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [package hierarchy](../../../../packages/README.md) groups plugins by product role, but it cannot distinguish release packages from prototypes or internal-only packages. The team needs an obvious shared place for useful work that is not part of the official release.
|
||||
|
||||
## Decision
|
||||
|
||||
The subtree rules in [`packages/experimental/AGENTS.md`](../../../../packages/experimental/AGENTS.md) make `packages/experimental/<pkg>/` the required home for Cordis plugin packages whose whole public contract is experimental or internal-only. Package names remain `@deepseek-ai/dsh-<pkg>`.
|
||||
|
||||
The group is the team's in-repository place to share engineering and product-manager prototypes: members can discover, run, review, and extend one another's work against the real plugin graph without implying product support.
|
||||
|
||||
Official releases exclude this directory. A package enters a release only after moving to its product-role group; release packages cannot take runtime dependencies on packages here. Examples may use them, while any other runtime dependent also belongs here. Tests may use them as development dependencies.
|
||||
|
||||
Experimental packages carry no stability, compatibility, migration, or support promise: they may change APIs, configuration, or data, or disappear without deprecation or migration. Internal-only packages may define narrower internal contracts but make no public release promise. Neither status relaxes engineering, security, documentation, lifecycle, testing, or snapshot requirements.
|
||||
|
||||
The pending `@deepseek-ai/dsh-tui-session-changes` `/diff` viewer and `/btw` plugin are examples governed by this rule. Promotion into an official release requires explicit review of the public contract, limitations, test evidence, and a named owner accepting stable-package obligations.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep experimental and internal-only packages in product-role groups with README labels.** Labels are easy to miss and cannot enforce dependency boundaries.
|
||||
|
||||
**Treat every package as experimental until the first tagged release.** This provides no durable incubation boundary.
|
||||
|
||||
**Develop prototypes and internal packages elsewhere.** This loses the real plugin graph, examples, snapshots, and lifecycle checks needed to evaluate them.
|
||||
|
||||
## Consequences
|
||||
|
||||
The path makes release exclusion and dependency blast radius visible while retaining the real plugin graph for team sharing. It gives up product-role colocation and creates path churn on promotion, while the npm name remains stable. The subtree rules, repository [current-owner/current-need rule](../../../../packages/AGENTS.md), and unchanged engineering gates limit junk-drawer growth. Because official release tooling does not yet exist, contributor policy enforces the exclusion; when such tooling is added, the directory is its required exclusion boundary.
|
||||
@@ -1,33 +0,0 @@
|
||||
# Agent Note: 实验性与内部专用包分组
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-28-experimental-plugin-package-group.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[包层级结构](../../../../packages/README.md)按产品角色对插件分组,但无法区分发布包、原型和内部专用包。团队需要一个明确的共享位置,存放不属于官方发布版本的有价值成果。
|
||||
|
||||
## 决策
|
||||
|
||||
[`packages/experimental/AGENTS.md`](../../../../packages/experimental/AGENTS.md) 中的子树规则要求所有公开约定整体处于实验状态或仅限内部使用的 Cordis 插件包位于 `packages/experimental/<pkg>/`。包名仍为 `@deepseek-ai/dsh-<pkg>`。
|
||||
|
||||
该分组供团队在仓库内共享工程人员和产品经理制作的原型:成员可以基于真实插件图发现、运行、评审并扩展彼此的原型,但这不代表产品会提供支持。
|
||||
|
||||
官方发布版本不包含此目录。包只有移入对应的产品角色分组后才会纳入发布版本;发布包不得在运行时依赖此处的包。示例可以使用这些包;其他任何运行时依赖方也必须位于此处。测试可以将它们用作开发依赖。
|
||||
|
||||
实验性包不提供稳定性、兼容性、迁移或支持保证:其 API、配置或数据可以变更,包也可以移除,均不提供弃用期或迁移路径。内部专用包可以定义范围更窄的内部约定,但不作公开发布承诺。无论哪种状态,都不降低仓库对工程、安全、文档、生命周期、测试或快照的要求。
|
||||
|
||||
尚待完成的 `@deepseek-ai/dsh-tui-session-changes` `/diff` 查看器和 `/btw` 插件都受这项规则约束。将包提升为稳定包并纳入官方发布版本,需要明确评审其公开约定、限制和测试证据,并指定一名愿意承担稳定包义务的负责人。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**将实验性和内部专用包留在产品角色分组中,并用 README 标注。** 标注容易被忽略,也无法强制执行依赖边界。
|
||||
|
||||
**首个带标签的版本发布前,将所有包都视为实验性。** 这无法提供持久的孵化边界。
|
||||
|
||||
**在其他位置开发原型和内部专用包。** 这会失去评估它们所需的真实插件图、示例、快照和生命周期检查。
|
||||
|
||||
## 后果
|
||||
|
||||
该路径明确标示不纳入发布版本的包及其依赖影响范围,同时保留供团队共享成果的真实插件图。代价是这些包无法与同产品角色的包共置,提升并纳入发布版本时还会产生路径变动,但 npm 包名保持稳定。子树规则、仓库已有的[「必须有当前负责人和实际需求」规则](../../../../packages/AGENTS.md)以及保持不变的工程门禁,可限制该分组无序膨胀。由于官方发布工具尚不存在,目前由贡献者政策执行这项排除规则;添加发布工具后,必须以该目录为排除边界。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md
|
||||
2026-07-29-dsh-source-launch-tsx-esm.md: ed22e51d59a25db130b3760ce484c116bade4348
|
||||
2026-07-29-dsh-source-launch-tsx-esm.zh.md: bdd549092eb30f7749c8f7561068daafe3548b28
|
||||
2026-07-29-dsh-source-launch-tsx-esm.md: b2428602a780f2880f0f803ba59b16a76b39790e
|
||||
2026-07-29-dsh-source-launch-tsx-esm.zh.md: 866bc8886725788e6e619f9507f5c8ccab63f82d
|
||||
|
||||
@@ -14,11 +14,11 @@ Startup latency also mattered: the off-thread `module.register()` hooks worker s
|
||||
|
||||
## Decision
|
||||
|
||||
The `dsh` TUI, Web, and headless source launches run `node --import tsx/esm`: tsx's ESM-only hook owns both TypeScript transformation and tsconfig `paths` projection. `bin/dsh`, the root `dsh`/`demo:tui`/`demo:web` scripts, and the Code Mode TUI overlay use the same vector; `bin/dsh` references the hook and tsconfig by absolute checkout paths (bare `tsx/esm` does not resolve from an arbitrary cwd) and pins `TSX_TSCONFIG_PATH` to the root tsconfig. The CJS hook stays off because the CLI source graph is ESM-only; measured TUI time-to-banner is ~0.7s versus ~1.1s under the full tsx default and ~0.75s under the removed native chain.
|
||||
The `dsh` TUI, Web, and headless source launches run `node --import tsx/esm`: tsx's ESM-only hook owns both TypeScript transformation and tsconfig `paths` projection. The root `dsh` script completes the repository build, then uses that vector from the repository root. The CJS hook stays off because the CLI source graph is ESM-only; measured runtime launch to the TUI banner is ~0.7s versus ~1.1s under the full tsx default and ~0.75s under the removed native chain.
|
||||
|
||||
`scripts/tspath-loader.ts` and `apps/cli/src/tsconfig-paths-loader.ts` are deleted. With them went the loader's runtime rule of mapping a workspace import only for declared runtime dependencies — tsx applies the `paths` map unconditionally. Declaration completeness now rests on the static gates alone: `verify-cordis-config` for configured bare plugins, and workspace constraints for manifests. (That runtime rule found real bugs: `dsh-plan-mode` and `dsh-tool-tasks` imported `@deepseek-ai/dsh-llm` while declaring it only in devDependencies; since fixed.)
|
||||
|
||||
The node-compat CI matrix (Node 22.19 and 26) gains `dsh-source-launch-smoke` (`apps/cli/tests/source-launch.compat.spec.ts`): a keyless piped-stdio launch of the exact production vector asserting the non-zero-exit TTY refusal. Any future Node change to module hooks or TypeScript handling turns this gate red instead of breaking developers' `pnpm dsh`.
|
||||
The node-compat CI matrix (Node 22.19 and 26) gains `dsh-source-launch-smoke` (`apps/cli/tests/source-launch.compat.spec.ts`): a keyless piped-stdio launch of the exact production runtime vector asserting the non-zero-exit TTY refusal. Any future Node change to module hooks or TypeScript handling turns this gate red instead of breaking developers' `pnpm dsh`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -35,4 +35,4 @@ The node-compat CI matrix (Node 22.19 and 26) gains `dsh-source-launch-smoke` (`
|
||||
- One launch vector across the whole engines range, including future Node lines that change native TypeScript support; the smoke gate enforces it per matrix line.
|
||||
- TypeScript transformation is delegated to tsx/esbuild again, reversing the prior note's goal of proving Node-native transformation; that goal is unreachable while vendored sources use non-erasable syntax and Node ships no transform mode.
|
||||
- The runtime declared-dependency enforcement in source launches is gone; undeclared workspace imports now surface only through static gates or built-mode resolution failures.
|
||||
- Startup improves ~0.4s over the full tsx default (`demo:headless` now aliases the same `dsh run` source launch; ACP keeps `--import tsx` because its graph was not audited for CJS-hook dependence and its launch latency is not on the interactive path).
|
||||
- Runtime launch improves ~0.4s over the full tsx default; ACP keeps `--import tsx` because its graph was not audited for CJS-hook dependence and its launch latency is not on the interactive path.
|
||||
|
||||
@@ -14,11 +14,11 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh` 的 TUI、Web 与无头源码启动运行 `node --import tsx/esm`:由 tsx 的 ESM-only 钩子同时负责 TypeScript 转换与 tsconfig `paths` 投影。`bin/dsh`、根目录的 `dsh`/`demo:tui`/`demo:web` 脚本以及 Code Mode TUI overlay 使用同一向量;`bin/dsh` 以 checkout 的绝对路径引用钩子与 tsconfig(裸的 `tsx/esm` 无法从任意 cwd 解析),并将 `TSX_TSCONFIG_PATH` 固定到根 tsconfig。CJS 钩子保持关闭,因为 CLI 源码图是纯 ESM;实测 TUI 到 banner 约 0.7s,对比完整 tsx 默认形态约 1.1s、已移除的原生链约 0.75s。
|
||||
`dsh` 的 TUI、Web 与无头源码启动运行 `node --import tsx/esm`:由 tsx 的 ESM-only 钩子同时负责 TypeScript 转换与 tsconfig `paths` 投影。根目录的 `dsh` 脚本先完成仓库构建,然后从仓库根目录使用同一启动方式。CJS 钩子保持关闭,因为 CLI(命令行界面)源码图是纯 ESM;实测运行时启动至 TUI banner 耗时约 0.7s,对比完整 tsx 默认形态约 1.1s、已移除的原生链约 0.75s。
|
||||
|
||||
`scripts/tspath-loader.ts` 与 `apps/cli/src/tsconfig-paths-loader.ts` 已删除。随之消失的还有该 loader「仅为已声明运行时依赖映射 workspace import」的运行时规则——tsx 无条件应用 `paths` 映射。声明完整性现在仅由静态门禁保障:配置的裸插件走 `verify-cordis-config`,manifest(元数据清单)走 workspace constraints。(该运行时规则确实发现过真实缺陷:`dsh-plan-mode` 与 `dsh-tool-tasks` 导入 `@deepseek-ai/dsh-llm` 却只声明在 devDependencies;后已修复。)
|
||||
|
||||
node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(`apps/cli/tests/source-launch.compat.spec.ts`):以精确的生产启动向量做 keyless 管道 stdio 启动,断言非零退出的 TTY 拒绝。未来 Node 对模块钩子或 TypeScript 处理的任何改动都会让该门禁变红,而不是破坏开发者的 `pnpm dsh`。
|
||||
node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(`apps/cli/tests/source-launch.compat.spec.ts`):以精确的生产运行时启动向量做 keyless 管道 stdio 启动,断言非零退出的 TTY 拒绝。未来 Node 对模块钩子或 TypeScript 处理的任何改动都会让该门禁变红,而不是破坏开发者的 `pnpm dsh`。
|
||||
|
||||
## 备选方案
|
||||
|
||||
@@ -35,4 +35,4 @@ node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(`
|
||||
- 整个 engines 范围(包括未来改变原生 TypeScript 支持的 Node 版本线)只有一个启动向量;冒烟门禁按矩阵行强制执行。
|
||||
- TypeScript 转换重新委托给 tsx/esbuild,逆转了前一篇 Agent Note「证明 Node 原生转换可用」的目标;在 vendor 源码使用不可擦除语法且 Node 不再提供 transform 模式的情况下,该目标不可达。
|
||||
- 源码启动中的运行时依赖声明强制不复存在;未声明的 workspace import 现在只能通过静态门禁或构建模式的解析失败暴露。
|
||||
- 启动相比完整 tsx 默认形态快约 0.4s(`demo:headless` 现为同一条 `dsh run` 源码启动命令的别名;ACP 保留 `--import tsx`,因为它的依赖图尚未就 CJS 钩子依赖性做审计,且其启动延迟不在交互路径上)。
|
||||
- 运行时启动相比完整 tsx 默认形态快约 0.4s;ACP(Agent Client Protocol)保留 `--import tsx`,因为它的依赖图尚未就 CJS 钩子依赖性做审计,且其启动延迟不在交互路径上。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-package-regrouping.md
|
||||
2026-07-29-package-regrouping.md: 3c37bce05bacd6af800a76ac93fb691b896a6772
|
||||
2026-07-29-package-regrouping.zh.md: 68903ff1fad6a975c4445fe8971c8fe0dd40117f
|
||||
2026-07-29-package-regrouping.md: 6b85fafaebd75b051d239966a30bb81bdaf5522f
|
||||
2026-07-29-package-regrouping.zh.md: 06eb210e318833d9651af2c4f0e4d792799a2018
|
||||
|
||||
@@ -10,29 +10,26 @@ The two-level `packages/<group>/<pkg>` hierarchy ([original decision](../../arch
|
||||
|
||||
- `ui/` mixed four unrelated planes: the human terminal channel (`tui`), the SDK's JSON-RPC server half (`jsonrpc`, whose peer dependency on `dsh-sdk-protocol` binds it to the SDK wire stack), the human-interaction seams (`user-interaction`, `user-approval`, `permission`, `tool-ask-user`, `commands`), and channel-neutral boot glue (`app-boot`). Its own README narrated the mixture instead of stating a role.
|
||||
- The session family was fragmented across five groups — `session-persistence/`, `session-projection/`, `session-query/`, `session-title/`, and `telemetry/` — although the measured dependency edges tie them together (query → persistence, title → projection, projection → persistence; see [docs/module-graph.md](../../../../docs/module-graph.md)).
|
||||
- Two group names collided with unrelated packages: `telemetry/` (session reporting) vs `dsh-telemetry` (launcher-side SDK telemetry), and `timeout/` (a tool-call guard) vs `util/timeout` (the generic promise utility).
|
||||
- The `timeout/` group for a tool-call guard collided with `util/timeout`, the generic promise utility.
|
||||
- `cordis/` named its group after the framework every package is built on, so the name discriminated nothing; its single package `tool-cordis` is the runtime self-modification toolset.
|
||||
- The old `sdk/` folder names were inconsistent: `sdk/sdk-client` and `sdk/sdk-protocol` repeated the group name while `sdk/telemetry`, `sdk/helper`, and `sdk/scripts` did not.
|
||||
|
||||
The north star for the regrouping: **closely clustered packages share a group.** A cluster is measured — peer-dependency edges and co-change — not thematic. An isolated seam family may stand alone as a small group; the failure mode to avoid is the grab-bag whose name describes no single role.
|
||||
|
||||
## Decision
|
||||
|
||||
Six groups are recomposed; every other group keeps its prior boundary and contents (the dependency analysis confirmed the capability families — `bash/`, `pty/`, `code-runtime/`, `sandbox/`, `subprocess/`, `fs/`, `lsp/`, `web/`, `skill/`, and the rest — were already drawn correctly). npm package names did not change; the folder tree carries the whole change.
|
||||
Five regrouping decisions remain current; every other group keeps its prior boundary and contents (the dependency analysis confirmed the capability families — `bash/`, `pty/`, `code-runtime/`, `sandbox/`, `subprocess/`, `fs/`, `lsp/`, `web/`, `skill/`, and the rest — were already drawn correctly). The original sixth decision collected the SDK project initializer, launcher tooling, and runtime JSON-RPC packages under `scaffold/`; [removing that unreleased toolchain](../simplification/2026-08-11-remove-sdk-project-toolchain.md) superseded it by deleting the project tooling and moving the surviving runtime trio to `sdk/`.
|
||||
|
||||
| Group | Members (folder names) | From |
|
||||
|---|---|---|
|
||||
| `session/` | session-persistence, session-persistence-jsonl, session-persistence-sqlite, session-checkpoint-policy, session-projection, session-projection-cache, session-title, session-title-llm, session-title-first-message-llm, session-title-all-messages-llm, session-telemetry, session-telemetry-otel | `session-persistence/` + `session-projection/` + `session-title/` + `telemetry/` |
|
||||
| `interaction/` | user-interaction, user-approval, permission, tool-ask-user, commands, tui | `ui/` |
|
||||
| `boot/` | app-boot | `ui/` |
|
||||
| `scaffold/` | helper, scripts, create-sdk, protocol, client, server, telemetry | `sdk/` + `ui/jsonrpc` |
|
||||
| `guard/` | repeat-tool-guard, timeout-policy | `guard/` + `timeout/` |
|
||||
| `self-modification/` | tool-cordis | `cordis/` |
|
||||
|
||||
- **`session/`** is the durable session data plane: the persistence seam with its backends and checkpoint policy, the projection fold that serves whole values from that log, log-backed titles, and OTel reporting. The title fold is itself load-bearing for the read side (`session-query` peer-depends on `dsh-session-title`), so titles belong with the data plane, not in a derived-services annex. The plain name is deliberate (prefer names a human would say); the nearby `core/session` package remains the live in-memory service, while this group is the durable family around it. `session-query/` stays a standalone group — the read/tool surface has its own model tools and SQLite FTS backend and is consumed independently of persistence internals. Absorbing `telemetry/` ended the group-name collision with `dsh-telemetry`.
|
||||
- **`session/`** is the durable session data plane: the persistence seam with its backends and checkpoint policy, the projection fold that serves whole values from that log, log-backed titles, and OTel reporting. The title fold is itself load-bearing for the read side (`session-query` peer-depends on `dsh-session-title`), so titles belong with the data plane, not in a derived-services annex. The plain name is deliberate (prefer names a human would say); the nearby `core/session` package remains the live in-memory service, while this group is the durable family around it. `session-query/` stays a standalone group — the read/tool surface has its own model tools and SQLite FTS backend and is consumed independently of persistence internals.
|
||||
- **`interaction/`** is the human-collaboration plane plus the terminal channel that answers it: the question/approval seams, the permission preset, the model-facing `ask_user_question` tool, the human-command registry (`plan-mode` and `command-goal` already consume `commands` together with the interaction seams), and `tui` — the interactive channel is the plane's richest provider and consumer (peer edges to `commands` and `user-interaction`), and a one-package `tui/` group would spend a top-level name on one plugin.
|
||||
- **`boot/`** is a role-complete single-package group: the shared bin boot glue that belongs to no channel and no assembly (consumed by `apps/cli`, the `scaffold/` launcher, and the `examples/` demo bins).
|
||||
- **`scaffold/`** is the developer-tooling family: project helper, launcher, initializer, wire protocol with both ends (`server` is the former `ui/jsonrpc`), and launcher telemetry. Renamed from `sdk/`: the whole `packages/` tree *is* the SDK, so a group named `sdk/` inside it said nothing; `scaffold/` names the create/launch/drive-a-project role. Folders drop the legacy `sdk-` prefix (`protocol`, `client`, `server`), matching the `client/`/`host/` role-named folder style; the three affected npm names are mapped explicitly beside the group wildcard in `tsconfig.base.json` until the deferred renames land.
|
||||
- **`boot/`** is a role-complete single-package group: the shared bin boot glue that belongs to no channel and no assembly (consumed by `apps/cli` and the `examples/` demo bins).
|
||||
- **`guard/`** keeps its documented role, loop-hygiene guards, and gains the tool-call timeout enforcer, dissolving the one-package `timeout/` group whose name collided with `util/timeout`.
|
||||
- **`self-modification/`** names the role `cordis/` obscured: the toolset with which the agent inspects and mounts plugins in its own live runtime, and the landing zone for future self-modification packages.
|
||||
|
||||
@@ -40,35 +37,30 @@ Six groups are recomposed; every other group keeps its prior boundary and conten
|
||||
|
||||
## Deferred renames (FIXME markers)
|
||||
|
||||
Five npm names should eventually change, but renaming inside the reorganization would have turned a pure-move PR into an import-churn PR. Instead, each affected package's module JSDoc carries a `FIXME` naming the intended new name. `FIXME` blocks a tagged release ([marker semantics](../../../../docs/development.md)), which is the wanted forcing function: these renames are only free while nothing external consumes the packages.
|
||||
Two npm names should eventually change, but renaming inside the reorganization would have turned a pure-move PR into an import-churn PR. Each affected package's module JSDoc carries a `FIXME` naming the intended new name. `FIXME` blocks a tagged release ([marker semantics](../../../../docs/development.md)), which is the wanted forcing function: these renames are only free while nothing external consumes the packages. Removing the unreleased SDK project toolchain deleted the other three packages and their rename markers instead of preserving names for code that no longer exists.
|
||||
|
||||
| Current npm name | Intended name | Why |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-jsonrpc` | `@deepseek-ai/dsh-sdk-server` | Names the wire encoding, not the role; it is the server half of the SDK protocol |
|
||||
| `@deepseek-ai/dsh-telemetry` | `@deepseek-ai/dsh-sdk-telemetry` | Collides with the `dsh-session-telemetry` family; it is launcher-side SDK telemetry |
|
||||
| `@deepseek-ai/dsh-helper` | `@deepseek-ai/dsh-sdk-helper` | Indefensibly generic as a published name |
|
||||
| `@deepseek-ai/dsh-scripts` | `@deepseek-ai/dsh-sdk-scripts` | Same |
|
||||
| `@deepseek-ai/dsh-timeout-policy` | `@deepseek-ai/dsh-timeout-guard` | Suggestion, not settled: aligns the name with its `guard/` home; decide at resolution time |
|
||||
|
||||
The first four are settled intent; resolving them converges the SDK wire stack's npm names on `dsh-sdk-*` (the npm prefix names the product stack; the `scaffold/` folder names the role). `@deepseek-ai/create-sdk` keeps its documented npm-initializer exception.
|
||||
|
||||
## What the move touched
|
||||
|
||||
The moves landed as pure `git mv` moves, so rename detection carries the history. A group move touched: the moved package's `tsconfig.json` relative `references` and every dependent's entry (including the `apps/cli` project references), the tsconfig aggregate and path maps, group READMEs (five new bilingual triplets, deletions for dissolved groups, the [packages/README.md](../../../../packages/README.md) hierarchy table, the root `AGENTS.md` layout map), regenerated artifacts (`docs/module-graph.md`, path-embedding catalogs, the lockfile's importer keys), and root-relative `packages/...` citations in prose and gate scripts. Remaining group-path referents (workspace configs, test globs, lint keys) were found mechanically by the acceptance gates failing loud — the repository's own misconfiguration rule.
|
||||
The moves landed as pure `git mv` moves, so rename detection carries the history. A group move touched: the moved package's `tsconfig.json` relative `references` and every dependent's entry (including the `apps/cli` project references), the tsconfig aggregate and path maps, group READMEs, the [packages/README.md](../../../../packages/README.md) hierarchy table, the root `AGENTS.md` layout map, regenerated artifacts (`docs/module-graph.md`, path-embedding catalogs, and the lockfile's importer keys), and root-relative `packages/...` citations in prose and gate scripts. Remaining group-path referents (workspace configs, test globs, lint keys) were found mechanically by the acceptance gates failing loud — the repository's own misconfiguration rule.
|
||||
|
||||
A group move did not touch: npm names, imports, `cordis.yml` configs, snapshot fixtures, the `pnpm-workspace.yaml`/`tsdown` globs (both `packages/*/*`), or the Python runtime manifest — all reference packages by npm name.
|
||||
|
||||
`client/` and `host/` were out of scope and are unchanged. The `experimental/` group proposal (PR #844) is orthogonal — a release-boundary container, not a clustering decision.
|
||||
`client/` and `host/` were out of scope and are unchanged.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Coarse domain buckets** (`exec/` = subprocess+sandbox+bash+pty+code-runtime, `workspace/` = fs+lsp+workspace, `orchestration/` = subagent+workflow+tasks, `knowledge/` = web+skill, `collab/` = plan+todo+goal; ~16 groups). Rejected: the measured graph contradicts the merges. `sandbox` and `subprocess` are shared infrastructure consumed across families (bash ×5, fs ×5, pty, lsp, mcp, subagent, scaffold edges), `web` ↔ `skill` have zero edges, and a large bucket reproduces the `ui/` grab-bag at a larger scale.
|
||||
**Coarse domain buckets** (`exec/` = subprocess+sandbox+bash+pty+code-runtime, `workspace/` = fs+lsp+workspace, `orchestration/` = subagent+workflow+tasks, `knowledge/` = web+skill, `collab/` = plan+todo+goal; ~16 groups). Rejected: the measured graph contradicts the merges. `sandbox` and `subprocess` are shared infrastructure consumed across families (bash ×5, fs ×5, pty, lsp, mcp, and subagent edges), `web` ↔ `skill` have zero edges, and a large bucket reproduces the `ui/` grab-bag at a larger scale.
|
||||
|
||||
**Abstract layer names** (`capability/`, `policy/`, `extension/`, `provider/`). Rejected: they describe every plugin equally badly, and a `capability/` bucket would hold ~50 packages.
|
||||
|
||||
**A full npm rename sweep** (`dsh-<group>-<pkg>` for every package). Rejected: npm names are flat, so group-prefixing adds churn across imports, configs, and fixtures with no disambiguation gain; targeted FIXME-tracked renames cover the actual collisions.
|
||||
|
||||
**Performing the five renames inside the reorganization.** Rejected: renames multiply open-PR conflicts and destroy the pure-move review property. The FIXME markers keep them visible release blockers to resolve as small follow-up PRs.
|
||||
**Performing the deferred renames inside the reorganization.** Rejected: renames multiply open-PR conflicts and destroy the pure-move review property. The remaining FIXME markers keep them visible release blockers to resolve as small follow-up PRs.
|
||||
|
||||
**A two-way session split** (`session-core/` + `session-utils/`). Rejected: query belongs to neither side cleanly, and `session-core` invites confusion with `core/session` (`dsh-session`, the live in-memory service, which stays in `core/`).
|
||||
|
||||
@@ -78,9 +70,7 @@ A group move did not touch: npm names, imports, `cordis.yml` configs, snapshot f
|
||||
|
||||
**A standalone one-package `tui/` group.** Rejected: `tui` is the interaction plane's primary provider/consumer (peer edges to `commands`, `user-interaction`), and a top-level name spent on one plugin adds a group without adding information; it folds into `interaction/`.
|
||||
|
||||
**Keeping the group name `sdk/`.** Rejected: the whole `packages/` tree is the SDK, so an `sdk/` group inside it discriminates nothing — the same disease as `cordis/`. `scaffold/` names the actual role (create, launch, and drive projects from outside).
|
||||
|
||||
**Moving `app-boot` to `apps/`.** Rejected: `apps/` is the assembly tier over the package tier, and `dsh-app-boot` is a library that package-tier code imports (`scaffold/scripts`' launcher peer-depends on it) — placing it in `apps/` would invert the tiers and put a workspace library outside the `packages/*/*` build globs. It stays a package; `boot/` is its role-complete home.
|
||||
**Moving `app-boot` to `apps/`.** Rejected: `apps/` is the assembly tier over the package tier, and `dsh-app-boot` is a package-tier library — placing it in `apps/` would invert the tiers and put a workspace library outside the `packages/*/*` build globs. It stays a package; `boot/` is its role-complete home.
|
||||
|
||||
**Moving `tool-cordis` into `core/`.** Rejected: self-modification is its own product seam, expected to grow; the spine stays minimal. The group was first named `self-evolve/`; the name settled on `self-modification/` as the plainer term.
|
||||
|
||||
@@ -88,9 +78,9 @@ A group move did not touch: npm names, imports, `cordis.yml` configs, snapshot f
|
||||
|
||||
## Consequences
|
||||
|
||||
- The tree matches the map: the six recomposed groups hold exactly the listed members; the groups `ui/`, `sdk/`, `telemetry/`, `timeout/`, `cordis/`, `session-persistence/`, `session-projection/`, and `session-title/` no longer exist; every other group's contents are unchanged. The workspace package-name set is identical before and after (zero npm renames), and the five FIXME markers pin the deferred ones. A FIXME that later proves wrong must be removed explicitly with rationale, never silently dropped.
|
||||
- The five still-current regrouped families hold the listed members; the groups `ui/`, `telemetry/`, `timeout/`, `cordis/`, `session-persistence/`, `session-projection/`, and `session-title/` no longer exist. The regrouping itself changed no npm names. The later SDK toolchain removal intentionally changed the package set and restored `sdk/` as the precise home of the runtime SDK trio. Two FIXME markers pin the remaining deferred renames; a FIXME that later proves wrong must be removed explicitly with rationale, never silently dropped.
|
||||
- What pins the result: `pnpm run typecheck`, the unit suites of every moved group, `verify-package-paths`, `verify-md-links`, and the corpus-wide translation pairing all pass on the moved tree; the group-scoped test globs in `vitest.snapshot.config.ts` were rewritten with the moves so the suites collect the same test files as before (a fail-open glob would silently drop coverage).
|
||||
- Every open PR touching a moved file rebases across the move once; rename detection resolves most hunks mechanically.
|
||||
- Single-package groups remain (`boot/`, `self-modification/`, and existing ones such as `acp/`). Accepted deliberately: each is role-complete rather than a fragment of a family, and a truthful small group beats a nominal merge.
|
||||
- The `scaffold/` folders diverge from their npm names until the deferred renames land — the one transitional asymmetry, carried by three explicit `paths` entries in `tsconfig.base.json` and resolved by the FIXME renames.
|
||||
- The `sdk/` role folders map explicitly to their npm names in `tsconfig.base.json`; the `server/` mapping remains transitional until `dsh-jsonrpc` is renamed.
|
||||
- What this gave up: nothing functional — the change is navigational. Muscle memory and external links to old GitHub paths break, which is acceptable pre-release with no external consumers.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user